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

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage diff --git a/.agents/skills/infer-conventions/SKILL.md b/.agents/skills/infer-conventions/SKILL.md new file mode 100644 index 00000000..11a93275 --- /dev/null +++ b/.agents/skills/infer-conventions/SKILL.md @@ -0,0 +1,104 @@ +--- +name: infer-conventions +description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand." +license: MIT +metadata: + author: laravel +--- + +# Infer Conventions + +Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it. + +## Ground Rules (read before you start) + +- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer. +- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record. +- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule. +- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering. +- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped. +- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar. +- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details. + +## Process + +Each step ends on a checkable completion criterion. Do not advance until it holds. + +Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output. + +### Step 0: Orient + +Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2. + +This app ships a frontend stack, so the frontend checklist group applies. Sweep it. + +Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents. + +### Step 1: Predefined sweep + +Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict: + +- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files. +- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled. +- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention. +- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most). +- Tooling-owned or Already-recorded. Skip per the ground rules. + +Done when: every applicable dimension carries exactly one of those verdicts. + +### Step 2: Open-ended pass + +First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude. + +Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal. + +Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none). + +### Step 3: Confirm + +Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style. + +Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo. + +Done when: every candidate is approved, rejected, or (conflicts) decided. + +### Step 4: Record + +Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand. + +Record this: + +> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models. + +Not this: + +> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models. + +Done when: every approved item has a successful tool response, and any failure is reported with its rule text. + +### Step 5: Summarize + +List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions. + +## Glob mapping + +Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path. + +Examples: + +- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one. +- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer. +- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses. +- Tests: `tests/**`. +- Migrations and database: `database/migrations/**`. +- Truly app-wide (rare, e.g. auth retrieval): `app/**`. + +`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there. + +## Edge cases + +- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4. +- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing. +- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything. +- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface. +- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths. diff --git a/.agents/skills/infer-conventions/references/checklist.md b/.agents/skills/infer-conventions/references/checklist.md new file mode 100644 index 00000000..2b45cc25 --- /dev/null +++ b/.agents/skills/infer-conventions/references/checklist.md @@ -0,0 +1,141 @@ +# Detection Checklist + +Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`). + +Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence. + +--- + +## A. Validation & HTTP input + +1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`. + - Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`. +2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal. + - Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`. +3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties. + - Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`. +4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods. + - Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`. + +## B. Controllers & routing + +5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method. + - Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes. +6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs. + - Hint: read a few controller methods; `ls app/Actions app/Services`. +7. Route handler style: closures in `routes/*.php` vs controller classes. + - Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`. +8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute. + - Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes. +9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`. + - Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`. +10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`. + - Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files. + +## C. Authorization + +11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`. + - Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`. +12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade. + - Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`. + +## D. Eloquent & models + +13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list. + - Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`. +14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain. + - Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`. +15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`. + - Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`. +16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings. + - Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models. +17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`). + - Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built. +18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes. + - Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`. +19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes. + - Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`. +20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture. + - Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`. + +## E. Architecture & organization + +21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked. + - Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find. +22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere. + - Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`. +23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location. + - Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps. +24. Decoupling: events + listeners vs direct service calls. + - Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`. +25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`). + - Hint: ratio of `config(` vs `Config::` (etc.) across `app/`. +26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules). + - Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders. +27. Enums: backed vs pure; case naming; where they live. + - Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`. + +## F. Frontend & views + +This app ships a frontend stack, so the items below apply. + +28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA. + - Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`. +29. Blade composition: class `` components vs anonymous components (`@props`) vs `@include` partials. + - Hint: `ls app/View/Components`; grep `constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`. + - Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`. +34. `down()` methods: real reverse logic vs omitted / one-way migrations. + - Hint: grep `function down` vs the migration count. +35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model. + - Hint: grep `->enum(` in migrations vs string columns cast to enums. +36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`. + - Hint: grep `DB::transaction`, `beginTransaction` in `app/`. +37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save. + - Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`. + +## H. Testing + +38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes. + - Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`. +39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`. + - Hint: grep those trait names in `tests/`. +40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories. + - Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide. +41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery. + - Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`. +42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`. + - Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`. + +## I. Responses & API resources + +43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly. + - Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers. +44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately. + - Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`. +45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority. + - Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them. +46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`. + - Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views. + +## J. Strings, collections & dates + +47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`. + - Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`. +48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`). + - Hint: grep `Str::of(` vs `Str::` vs native string funcs. +49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting. + - Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy. + +--- + +Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from. diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..d136d755 --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..f12876e4 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..b65e3b56 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,206 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level, inside a transaction +DB::transaction(function () use ($id) { + $product = Product::where('id', $id)->lockForUpdate()->first(); + + // Read and update the product while the lock is held... +}); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..5f0b3a1e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..c5becef8 --- /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. Not supported by the `file`, `dynamodb`, `database` or `storage` drivers. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..18e8d9e1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..9bea727b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..c49ba164 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..bd2cfca0 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,150 @@ +# 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 +#[Scope] +protected function active(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 +#[Scope] +protected function published(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..4b148667 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..82e329e8 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..feaecf80 --- /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.example.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.example.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..7c717336 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..af671c0d --- /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')->index()->constrained(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..c41915e2 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..b6e30864 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..a9847945 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..358af15f --- /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/PATCH/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. + +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 MIME type and size. Both `mimes` and `mimetypes` read the file's contents to guess its MIME type; `mimes` just expresses the allow-list as extensions. The `extensions` rule checks only the client-supplied filename, so never rely on it alone. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..a8afb369 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..2677e0bb --- /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` behaves the same, except it defers that work until a test actually touches the database, so tests that never query it skip the migration entirely. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..5fde1064 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..4643ccbf --- /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 live updates; `wire:model` is deferred by default +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) diff --git a/.agents/skills/livewire-development/reference/javascript-hooks.md b/.agents/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..660d66b5 --- /dev/null +++ b/.agents/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..ab271616 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,166 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..c0cb2fbc --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..67c206f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,208 @@ 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 running on PHP 8.4. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version. + +Before relying on a package's API, confirm its installed version: +- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show ` for a single package. +- JS packages: check `package.json` for the installed versions. + +## Skills Activation + +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Use `search-docs` before changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again. +- 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"]`. + +## Project Rules + +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. +- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + +=== laravel-fortify/core rules === + +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. + +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..0c14741c --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You might use sub-agents or 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 and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. + +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..a0bab0dc --- /dev/null +++ b/boost.json @@ -0,0 +1,19 @@ +{ + "agents": [ + "opencode" + ], + "cloud": false, + "guidelines": true, + "mcp": true, + "nightwatch": false, + "sail": false, + "skills": [ + "infer-conventions", + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/composer.json b/composer.json index 1f848aaf..547a4793 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.5", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index e4255dbd..5b977876 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": "4038df3fd598c391599a1e9a16d424b1", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6521,6 +6521,83 @@ ], "time": "2026-02-05T09:14:44+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -6877,35 +6954,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.5.5", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "a6c798975a893d3c0a609ebc6ed37a007ecbedec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/a6c798975a893d3c0a609ebc6ed37a007ecbedec", + "reference": "a6c798975a893d3c0a609ebc6ed37a007ecbedec", "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" + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.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 +7005,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,41 +7016,48 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-08-19T02:26:00+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.9.4", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "7ca5b923630118696602d14348cd0466a5e853ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/7ca5b923630118696602d14348cd0466a5e853ec", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { "laravel": { "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" }, "providers": [ "Laravel\\Mcp\\Server\\McpServiceProvider" @@ -6982,8 +7067,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +7074,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 +7090,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-08-13T15:01:07+00:00" }, { "name": "laravel/pail", @@ -7153,31 +7241,33 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", "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" + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^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", + "laravel/pint": "^1.29", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" }, "type": "library", "extra": { @@ -7209,7 +7299,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-07-18T17:53:15+00:00" }, { "name": "laravel/sail", @@ -9974,5 +10064,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..3681caa5 --- /dev/null +++ b/opencode.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": [ + "php", + "artisan", + "boost:mcp" + ] + }, + "playwright": { + "type": "local", + "enabled": true, + "command": [ + "npx", + "-y", + "@playwright/mcp@latest" + ] + } + } +} diff --git a/package-lock.json b/package-lock.json index b558d2d8..ebd5b77d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "shop", "dependencies": { "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", @@ -481,9 +482,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", "cpu": [ "arm" ], @@ -494,9 +495,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", "cpu": [ "arm64" ], @@ -507,9 +508,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", "cpu": [ "arm64" ], @@ -520,9 +521,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", "cpu": [ "x64" ], @@ -533,9 +534,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ "arm64" ], @@ -546,9 +547,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", "cpu": [ "x64" ], @@ -559,12 +560,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -572,12 +576,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -585,12 +592,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -598,12 +608,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -611,12 +624,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -624,12 +640,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -637,12 +656,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -650,12 +672,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -663,12 +688,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -676,12 +704,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -689,12 +720,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -715,12 +749,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -728,9 +765,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", "cpu": [ "x64" ], @@ -741,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", "cpu": [ "arm64" ], @@ -754,9 +791,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", "cpu": [ "arm64" ], @@ -767,9 +804,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", "cpu": [ "ia32" ], @@ -780,9 +817,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", "cpu": [ "x64" ], @@ -793,9 +830,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", "cpu": [ "x64" ], @@ -1063,11 +1100,23 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1135,14 +1184,15 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/baseline-browser-mapping": { @@ -1316,6 +1366,23 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1486,9 +1553,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1669,6 +1736,19 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2014,10 +2094,16 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -2045,9 +2131,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -2057,9 +2143,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -2076,7 +2162,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2091,10 +2177,13 @@ "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/require-directory": { "version": "2.1.1", @@ -2106,12 +2195,12 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2121,41 +2210,44 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" } }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2314,9 +2406,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -2398,9 +2490,9 @@ } }, "node_modules/vite-plugin-full-reload/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" From 5047b3ea24c369d14fa48f305cb23af365f1013a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Thu, 20 Aug 2026 22:53:01 +0200 Subject: [PATCH 2/9] Init --- .codex/config.toml | 3 +++ README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .codex/config.toml diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..864e1fbd --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] diff --git a/README.md b/README.md index 0c14741c..245fb805 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 do in one go without stopping. You might use sub-agents or 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 do in one go without stopping. You must use sub-agents! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and verified by a 2nd agent. Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. From ac0e33a247181bb773b87bf41b9eefb6760d4520 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Thu, 20 Aug 2026 23:13:48 +0200 Subject: [PATCH 3/9] Implement bounded tenancy foundation --- .env.example | 6 +- app/Enums/StoreDomainType.php | 10 ++ app/Enums/StoreStatus.php | 9 ++ app/Enums/StoreUserRole.php | 11 ++ app/Http/Middleware/ResolveStore.php | 82 ++++++++++++++ app/Models/Concerns/BelongsToStore.php | 27 +++++ app/Models/Organization.php | 19 ++++ app/Models/Scopes/StoreScope.php | 29 +++++ app/Models/Store.php | 55 ++++++++++ app/Models/StoreDomain.php | 25 +++++ app/Models/StoreSettings.php | 36 ++++++ app/Models/StoreUser.php | 35 ++++++ app/Models/User.php | 24 +++- app/Policies/CollectionPolicy.php | 38 +++++++ app/Policies/CustomerPolicy.php | 28 +++++ app/Policies/DiscountPolicy.php | 38 +++++++ app/Policies/FulfillmentPolicy.php | 24 ++++ app/Policies/OrderPolicy.php | 43 ++++++++ app/Policies/PagePolicy.php | 38 +++++++ app/Policies/ProductPolicy.php | 48 ++++++++ app/Policies/RefundPolicy.php | 24 ++++ app/Policies/StorePolicy.php | 48 ++++++++ app/Policies/ThemePolicy.php | 43 ++++++++ app/Traits/ChecksStoreRole.php | 96 ++++++++++++++++ bootstrap/app.php | 14 ++- config/auth.php | 14 +++ config/cache.php | 2 +- config/database.php | 6 +- config/queue.php | 2 +- config/session.php | 2 +- config/tenancy.php | 14 +++ database/factories/OrganizationFactory.php | 26 +++++ database/factories/StoreDomainFactory.php | 29 +++++ database/factories/StoreFactory.php | 32 ++++++ database/factories/StoreSettingsFactory.php | 29 +++++ database/factories/StoreUserFactory.php | 28 +++++ ...57_add_enum_defaults_to_tenancy_tables.php | 54 +++++++++ ..._settings_json_to_store_settings_table.php | 28 +++++ ...57_add_tls_mode_to_store_domains_table.php | 28 +++++ specs/progress.md | 13 +++ tests/Feature/Tenancy/ResolveStoreTest.php | 103 ++++++++++++++++++ tests/Unit/Policies/StorePolicyTest.php | 50 +++++++++ tests/Unit/Tenancy/BelongsToStoreTest.php | 44 ++++++++ 43 files changed, 1343 insertions(+), 11 deletions(-) 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/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/Policies/CollectionPolicy.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/ProductPolicy.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 app/Traits/ChecksStoreRole.php create mode 100644 config/tenancy.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/factories/StoreUserFactory.php create mode 100644 database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php create mode 100644 database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php create mode 100644 database/migrations/2026_08_20_210057_add_tls_mode_to_store_domains_table.php create mode 100644 specs/progress.md create mode 100644 tests/Feature/Tenancy/ResolveStoreTest.php create mode 100644 tests/Unit/Policies/StorePolicyTest.php create mode 100644 tests/Unit/Tenancy/BelongsToStoreTest.php diff --git a/.env.example b/.env.example index c0660ea1..9c450749 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ DB_CONNECTION=sqlite # DB_USERNAME=root # DB_PASSWORD= -SESSION_DRIVER=database +SESSION_DRIVER=file SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -35,9 +35,9 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=sync -CACHE_STORE=database +CACHE_STORE=file # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 diff --git a/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) + ? 'admin' + : $context; + + $store = $context === 'admin' + ? $this->resolveAdminStore($request) + : $this->resolveStorefrontStore($request); + + abort_unless($store instanceof Store, $context === 'admin' ? 403 : 404); + + if ($store->status === StoreStatus::Suspended) { + if ($context === 'storefront') { + abort(503, 'This store is currently unavailable.'); + } + + if (! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true)) { + abort(403); + } + } + + $binding = (string) config('tenancy.binding', 'current_store'); + app()->instance($binding, $store); + View::share((string) config('tenancy.view_share', 'currentStore'), $store); + + return $next($request); + } + + private function resolveStorefrontStore(Request $request): ?Store + { + $hostname = Str::lower(rtrim(trim($request->getHost()), '.')); + $cacheKey = config('tenancy.cache_prefix', 'store-domains').':'.$hostname; + + $storeId = Cache::remember( + $cacheKey, + (int) config('tenancy.store_cache_ttl', 300), + fn (): ?int => StoreDomain::query() + ->where('hostname', $hostname) + ->where('type', config('tenancy.storefront_domain_type', StoreDomainType::Storefront->value)) + ->value('store_id'), + ); + + return $storeId === null ? null : Store::query()->find($storeId); + } + + private function resolveAdminStore(Request $request): ?Store + { + $storeId = $request->session()->get(config('tenancy.admin_session_key', 'current_store_id')); + $user = $request->user('web') ?? $request->user(); + + if ($storeId === null || $user === null) { + return null; + } + + return $user->stores()->whereKey($storeId)->first(); + } + + private function isAdminRequest(Request $request): bool + { + $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/'); + + return $request->is($prefix, $prefix.'/*') || $request->routeIs($prefix.'.*'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..15228382 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,27 @@ +bound('current_store') && app('current_store') instanceof Store) { + $model->setAttribute('store_id', app('current_store')->getKey()); + } + }); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..7f90697a --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,19 @@ +hasMany(Store::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..e9b4c6d0 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,29 @@ +bound('current_store')) { + $builder->whereNull($model->qualifyColumn('store_id')); + + return; + } + + $currentStore = app('current_store'); + + if ($currentStore instanceof \App\Models\Store) { + $builder->where($model->qualifyColumn('store_id'), $currentStore->getKey()); + + return; + } + + $builder->whereNull($model->qualifyColumn('store_id')); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..859dd614 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,55 @@ + StoreStatus::Active->value, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + + protected function casts(): array + { + return ['status' => StoreStatus::class, 'metadata' => 'array']; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps(); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function isActive(): bool + { + return $this->status === StoreStatus::Active; + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..2d4d4c29 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,25 @@ + StoreDomainType::class, 'is_primary' => 'boolean']; + } + + 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..867155e2 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,36 @@ + */ + use BelongsToStore, HasFactory; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + protected $keyType = 'int'; + + protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json']; + + protected $attributes = [ + 'settings_json' => '{}', + ]; + + protected function casts(): array + { + return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array']; + } + + 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..b53f10d9 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + public $incrementing = false; + + protected $table = 'store_users'; + + protected $fillable = ['store_id', 'user_id', 'role']; + + protected function casts(): array + { + return ['role' => StoreUserRole::class]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..0bcfb2fc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,8 +2,10 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; use Illuminate\Database\Eloquent\Factories\HasFactory; +// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -23,6 +25,8 @@ class User extends Authenticatable 'name', 'email', 'password', + 'status', + 'last_login_at', ]; /** @@ -47,9 +51,27 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'last_login_at' => 'datetime', ]; } + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps(); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $pivot = $this->stores()->where('stores.id', $store->getKey())->first()?->pivot; + + return $pivot?->role instanceof StoreUserRole ? $pivot->role : ($pivot?->role ? StoreUserRole::tryFrom($pivot->role) : null); + } + + public function canManageStore(Store $store): bool + { + return in_array($this->roleForStore($store), [StoreUserRole::Owner, StoreUserRole::Admin], true); + } + /** * Get the user's initials */ diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..d3218877 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,38 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Collection $collection): bool + { + return $this->userHasModelStoreRole($user, $collection, StoreUserRole::cases()); + } + + public function create(User $user): bool + { + return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Collection $collection): bool + { + return $this->userHasModelStoreRole($user, $collection, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->userHasModelStoreRole($user, $collection, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..19f4fc62 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,28 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Customer $customer): bool + { + return $this->userHasModelStoreRole($user, $customer, StoreUserRole::cases()); + } + + public function update(User $user, Customer $customer): bool + { + return $this->userHasModelStoreRole($user, $customer, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..8d236583 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,38 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Discount $discount): bool + { + return $this->userHasModelStoreRole($user, $discount, StoreUserRole::cases()); + } + + public function create(User $user): bool + { + return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Discount $discount): bool + { + return $this->userHasModelStoreRole($user, $discount, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->userHasModelStoreRole($user, $discount, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..a4bcc417 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,24 @@ +userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function view(User $user, Fulfillment $fulfillment): bool + { + return $this->userHasModelStoreRole($user, $fulfillment, StoreUserRole::cases()); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..953d87e9 --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,43 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Order $order): bool + { + return $this->userHasModelStoreRole($user, $order, StoreUserRole::cases()); + } + + public function update(User $user, Order $order): bool + { + return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function cancel(User $user, Order $order): bool + { + return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function createRefund(User $user, Order $order): bool + { + return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..ba5c8a74 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,38 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Page $page): bool + { + return $this->userHasModelStoreRole($user, $page, StoreUserRole::cases()); + } + + public function create(User $user): bool + { + return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Page $page): bool + { + return $this->userHasModelStoreRole($user, $page, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Page $page): bool + { + return $this->userHasModelStoreRole($user, $page, [StoreUserRole::Owner, StoreUserRole::Admin]); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..4f17597e --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,48 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Product $product): bool + { + return $this->userHasModelStoreRole($user, $product, StoreUserRole::cases()); + } + + public function create(User $user): bool + { + return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function update(User $user, Product $product): bool + { + return $this->userHasModelStoreRole($user, $product, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function delete(User $user, Product $product): bool + { + return $this->userHasModelStoreRole($user, $product, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function archive(User $user, Product $product): bool + { + return $this->delete($user, $product); + } + + public function restore(User $user, Product $product): bool + { + return $this->delete($user, $product); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..aa861f35 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,24 @@ +userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function view(User $user, Refund $refund): bool + { + return $this->userHasModelStoreRole($user, $refund, StoreUserRole::cases()); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..e4beff23 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,48 @@ +stores()->exists(); + } + + public function view(User $user, Store $store): bool + { + return $this->userHasModelStoreRole($user, $store, StoreUserRole::cases()); + } + + public function create(User $user): bool + { + return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function update(User $user, Store $store): bool + { + return $this->userHasModelStoreRole($user, $store, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function delete(User $user, Store $store): bool + { + return $this->userHasModelStoreRole($user, $store, [StoreUserRole::Owner]); + } + + public function restore(User $user, Store $store): bool + { + return $this->delete($user, $store); + } + + public function forceDelete(User $user, Store $store): bool + { + return $this->delete($user, $store); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..7a07bde7 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,43 @@ +userHasCurrentStoreRole($user, StoreUserRole::cases()); + } + + public function view(User $user, Theme $theme): bool + { + return $this->userHasModelStoreRole($user, $theme, StoreUserRole::cases()); + } + + public function create(User $user): bool + { + return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function update(User $user, Theme $theme): bool + { + return $this->userHasModelStoreRole($user, $theme, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->userHasModelStoreRole($user, $theme, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function publish(User $user, Theme $theme): bool + { + return $this->update($user, $theme); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..94d250b0 --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,96 @@ +exists) { + return null; + } + + return StoreUser::query() + ->where('store_id', $storeId) + ->where('user_id', $user->getKey()) + ->first()?->role; + } + + /** + * @param array $roles + */ + public function hasRole(User $user, int $storeId, array $roles): bool + { + $userRole = $this->getStoreRole($user, $storeId); + + if ($userRole === null) { + return false; + } + + foreach ($roles as $role) { + $role = $role instanceof StoreUserRole ? $role : StoreUserRole::tryFrom((string) $role); + + if ($role === $userRole) { + return true; + } + } + + return false; + } + + public function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + protected function currentStoreId(): ?int + { + $binding = (string) config('tenancy.binding', 'current_store'); + + if (! app()->bound($binding)) { + return null; + } + + $store = app($binding); + + return $store instanceof Store ? (int) $store->getKey() : null; + } + + /** + * @param array $roles + */ + protected function userHasCurrentStoreRole(User $user, array $roles): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->hasRole($user, $storeId, $roles); + } + + /** + * @param array $roles + */ + protected function userHasModelStoreRole(User $user, Model $model, array $roles): bool + { + $storeId = $model instanceof Store + ? $model->getKey() + : $model->getAttribute('store_id'); + + return $storeId !== null && $this->hasRole($user, (int) $storeId, $roles); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..2ee7c76d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,11 +7,23 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->group('storefront', [ + 'store.resolve:storefront', + ]); + + $middleware->group('admin', [ + 'store.resolve:admin', + ]); + + $middleware->alias([ + 'store.resolve' => App\Http\Middleware\ResolveStore::class, + 'role.check' => App\Http\Middleware\EnsureStoreRole::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..eddc80d9 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -64,6 +68,10 @@ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'customer', + 'model' => App\Models\Customer::class, + ], // 'users' => [ // 'driver' => 'database', @@ -97,6 +105,12 @@ 'expire' => 60, 'throttle' => 60, ], + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/cache.php b/config/cache.php index b32aead2..9289977f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/database.php b/config/database.php index df933e7f..1c89b57c 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' => (int) env('DB_BUSY_TIMEOUT', 5000), + 'journal_mode' => env('DB_JOURNAL_MODE', 'WAL'), + 'synchronous' => env('DB_SYNCHRONOUS', 'NORMAL'), 'transaction_mode' => 'DEFERRED', ], 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/config/tenancy.php b/config/tenancy.php new file mode 100644 index 00000000..a3638dea --- /dev/null +++ b/config/tenancy.php @@ -0,0 +1,14 @@ + 'current_store', + 'view_share' => 'currentStore', + 'admin_session_key' => 'current_store_id', + 'admin_path_prefix' => 'admin', + 'cache_prefix' => 'store-domains', + 'cache_ttl' => (int) env('STORE_CACHE_TTL', 300), + 'store_cache_ttl' => (int) env('STORE_CACHE_TTL', 300), + 'storefront_domain_type' => StoreDomainType::Storefront->value, +]; diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..10590ee4 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,26 @@ + + */ +class OrganizationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'slug' => fake()->unique()->slug(2), + 'billing_email' => fake()->unique()->companyEmail(), + 'status' => 'active', + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..8de74c44 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => false, + 'tls_mode' => 'managed', + ]; + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..69f21442 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,32 @@ + + */ +class StoreFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'name' => fake()->company().' Store', + 'handle' => fake()->unique()->slug(2), + 'status' => 'active', + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + 'primary_domain' => null, + 'metadata' => [], + ]; + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..495023f0 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [], + 'general_json' => [], + 'checkout_json' => [], + 'notification_json' => [], + 'social_json' => [], + ]; + } +} diff --git a/database/factories/StoreUserFactory.php b/database/factories/StoreUserFactory.php new file mode 100644 index 00000000..98fbedc2 --- /dev/null +++ b/database/factories/StoreUserFactory.php @@ -0,0 +1,28 @@ + + */ +class StoreUserFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'user_id' => User::factory(), + 'role' => StoreUserRole::Staff, + ]; + } +} diff --git a/database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php b/database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php new file mode 100644 index 00000000..3971eaa2 --- /dev/null +++ b/database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php @@ -0,0 +1,54 @@ +enum('status', ['active', 'suspended'])->default('active')->change(); + $table->string('default_currency', 3)->default('USD')->change(); + }); + + Schema::table('store_domains', function (Blueprint $table): void { + $table->enum('type', ['storefront', 'admin', 'api'])->default('storefront')->change(); + }); + + Schema::table('store_users', function (Blueprint $table): void { + $table->enum('role', ['owner', 'admin', 'staff', 'support'])->default('staff')->change(); + }); + + Schema::table('users', function (Blueprint $table): void { + $table->enum('status', ['active', 'disabled'])->default('active')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->string('status')->default('active')->change(); + }); + + Schema::table('store_users', function (Blueprint $table): void { + $table->string('role')->default('staff')->change(); + }); + + Schema::table('store_domains', function (Blueprint $table): void { + $table->string('type')->default('storefront')->change(); + }); + + Schema::table('stores', function (Blueprint $table): void { + $table->string('status')->default('active')->change(); + $table->string('default_currency', 3)->default('EUR')->change(); + }); + } +}; diff --git a/database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php b/database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php new file mode 100644 index 00000000..e15d0243 --- /dev/null +++ b/database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php @@ -0,0 +1,28 @@ +text('settings_json')->default('{}')->after('store_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('store_settings', function (Blueprint $table): void { + $table->dropColumn('settings_json'); + }); + } +}; diff --git a/database/migrations/2026_08_20_210057_add_tls_mode_to_store_domains_table.php b/database/migrations/2026_08_20_210057_add_tls_mode_to_store_domains_table.php new file mode 100644 index 00000000..0a443aed --- /dev/null +++ b/database/migrations/2026_08_20_210057_add_tls_mode_to_store_domains_table.php @@ -0,0 +1,28 @@ +enum('tls_mode', ['managed', 'bring_your_own'])->default('managed')->after('is_primary'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('store_domains', function (Blueprint $table): void { + $table->dropColumn('tls_mode'); + }); + } +}; diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..6ea3677f --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,13 @@ +# Implementation Progress + +## Foundation tenancy slice + +- [x] SQLite, cache, session, and queue defaults configured for the self-contained app. +- [x] Organization, store, domain, store-user, and store-settings schema/model relationships established. +- [x] Store status, domain type, and store-user role enums available. +- [x] `BelongsToStore` and `StoreScope` enforce current-store query and create boundaries. +- [x] `ResolveStore` supports hostname-based storefront resolution and session-based admin resolution. +- [x] Store-role helpers and policy scaffolding added for the specified admin resources. +- [x] Focused Pest coverage added for resolution, tenant isolation, and role authorization. + +Catalog, cart, checkout, order, and storefront UI implementation remains outside this bounded slice. diff --git a/tests/Feature/Tenancy/ResolveStoreTest.php b/tests/Feature/Tenancy/ResolveStoreTest.php new file mode 100644 index 00000000..77668445 --- /dev/null +++ b/tests/Feature/Tenancy/ResolveStoreTest.php @@ -0,0 +1,103 @@ + 'array']); + + Route::middleware(['web', 'storefront'])->get('/tenant-resolution-test', function () { + return response()->json([ + 'store_id' => app('current_store')->getKey(), + 'view_store_id' => view()->shared('currentStore')->getKey(), + ]); + }); + + Route::middleware(['web', 'admin'])->get('/admin/tenant-resolution-test', function () { + return response()->json(['store_id' => app('current_store')->getKey()]); + }); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +test('a storefront request resolves and shares the store by hostname', function () { + $store = Store::factory()->create(); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'fashion.example.test', + 'type' => StoreDomainType::Storefront, + ]); + + $this->get('http://fashion.example.test/tenant-resolution-test') + ->assertSuccessful() + ->assertJson([ + 'store_id' => $store->getKey(), + 'view_store_id' => $store->getKey(), + ]); +}); + +test('a storefront hostname is cached as a store id', function () { + $store = Store::factory()->create(); + + $domain = StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'cached.example.test', + ]); + + $this->get('http://cached.example.test/tenant-resolution-test')->assertSuccessful(); + + $domain->delete(); + + $this->get('http://cached.example.test/tenant-resolution-test') + ->assertSuccessful() + ->assertJson(['store_id' => $store->getKey()]); +}); + +test('unknown storefront hosts return not found', function () { + $this->get('http://missing.example.test/tenant-resolution-test')->assertNotFound(); +}); + +test('suspended storefronts return service unavailable', function () { + $store = Store::factory()->create(['status' => StoreStatus::Suspended]); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'suspended.example.test', + ]); + + $this->get('http://suspended.example.test/tenant-resolution-test') + ->assertServiceUnavailable(); +}); + +test('admin requests resolve only the session store the user belongs to', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + $store->users()->attach($user, ['role' => StoreUserRole::Owner]); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/tenant-resolution-test') + ->assertSuccessful() + ->assertJson(['store_id' => $store->getKey()]); +}); + +test('admin requests reject a session store without membership', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->getKey()]) + ->get('/admin/tenant-resolution-test') + ->assertForbidden(); +}); diff --git a/tests/Unit/Policies/StorePolicyTest.php b/tests/Unit/Policies/StorePolicyTest.php new file mode 100644 index 00000000..d99f0f52 --- /dev/null +++ b/tests/Unit/Policies/StorePolicyTest.php @@ -0,0 +1,50 @@ +forgetInstance('current_store'); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +test('product permissions are based on the users role in the current store', function () { + $store = Store::factory()->create(); + $otherStore = Store::factory()->create(); + $staff = User::factory()->create(); + + $store->users()->attach($staff, ['role' => StoreUserRole::Staff]); + app()->instance('current_store', $store); + + $policy = new ProductPolicy; + + expect($policy->viewAny($staff))->toBeTrue() + ->and($policy->create($staff))->toBeTrue() + ->and($policy->delete($staff, Product::make(['store_id' => $store->getKey()])))->toBeFalse() + ->and($policy->view($staff, Product::make(['store_id' => $otherStore->getKey()])))->toBeFalse(); +}); + +test('store deletion is restricted to owners', function () { + $store = Store::factory()->create(); + $owner = User::factory()->create(); + $admin = User::factory()->create(); + + $store->users()->attach($owner, ['role' => StoreUserRole::Owner]); + $store->users()->attach($admin, ['role' => StoreUserRole::Admin]); + + $policy = new StorePolicy; + + expect($policy->delete($owner, $store))->toBeTrue() + ->and($policy->delete($admin, $store))->toBeFalse() + ->and($policy->update($admin, $store))->toBeTrue(); +}); diff --git a/tests/Unit/Tenancy/BelongsToStoreTest.php b/tests/Unit/Tenancy/BelongsToStoreTest.php new file mode 100644 index 00000000..ab1a4c27 --- /dev/null +++ b/tests/Unit/Tenancy/BelongsToStoreTest.php @@ -0,0 +1,44 @@ +forgetInstance('current_store'); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +test('tenant models are created and queried for the current store only', function () { + $organization = Organization::factory()->create(); + $store = Store::factory()->for($organization)->create(); + $otherStore = Store::factory()->for($organization)->create(); + + app()->instance('current_store', $store); + $settings = StoreSettings::create([ + 'store_id' => $otherStore->getKey(), + 'settings_json' => ['store' => 'current'], + ]); + + expect($settings->store_id)->toBe($store->getKey()) + ->and(StoreSettings::query()->pluck('store_id')->all())->toBe([$store->getKey()]); +}); + +test('tenant queries return no rows when no current store is resolved', function () { + $organization = Organization::factory()->create(); + $store = Store::factory()->for($organization)->create(); + + StoreSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'settings_json' => ['store' => 'unresolved'], + ]); + + expect(StoreSettings::query()->count())->toBe(0) + ->and(StoreSettings::withoutGlobalScopes()->count())->toBe(1); +}); From def6e641fc326c47206db3d6a31e84e525e8ee77 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 21 Aug 2026 00:29:49 +0200 Subject: [PATCH 4/9] Build self-contained multi-tenant shop --- app/Auth/CustomerUserProvider.php | 27 + app/Contracts/PaymentProvider.php | 16 + app/Enums/CartStatus.php | 10 + app/Enums/CheckoutStatus.php | 15 + app/Enums/CollectionStatus.php | 10 + app/Enums/DiscountType.php | 9 + app/Enums/DiscountValueType.php | 10 + app/Enums/FinancialStatus.php | 12 + app/Enums/FulfillmentShipmentStatus.php | 10 + app/Enums/FulfillmentStatus.php | 11 + app/Enums/InventoryPolicy.php | 9 + app/Enums/MediaStatus.php | 10 + app/Enums/MediaType.php | 9 + app/Enums/OrderStatus.php | 14 + app/Enums/PageStatus.php | 9 + app/Enums/PaymentMethod.php | 10 + app/Enums/PaymentStatus.php | 12 + app/Enums/ProductStatus.php | 10 + app/Enums/RefundStatus.php | 10 + app/Enums/ThemeStatus.php | 9 + app/Enums/VariantStatus.php | 9 + app/Events/FulfillmentDelivered.php | 14 + app/Events/FulfillmentShipped.php | 14 + app/Events/OrderCancelled.php | 14 + app/Events/OrderCreated.php | 14 + app/Events/OrderFulfilled.php | 14 + app/Events/OrderPaid.php | 14 + app/Events/OrderRefunded.php | 14 + app/Events/ProductStatusChanged.php | 15 + .../CartVersionConflictException.php | 13 + app/Exceptions/FulfillmentGuardException.php | 13 + .../InsufficientInventoryException.php | 13 + app/Exceptions/InvalidDiscountException.php | 13 + .../InvalidProductTransitionException.php | 7 + app/Http/Controllers/Api/AdminController.php | 156 +++++ .../Api/StorefrontAnalyticsController.php | 25 + .../Api/StorefrontCartController.php | 108 ++++ .../Api/StorefrontCheckoutController.php | 139 ++++ app/Http/Middleware/EnsureStoreRole.php | 22 + app/Http/Middleware/ResolveStore.php | 28 +- app/Jobs/AggregateAnalytics.php | 44 ++ app/Jobs/CancelUnpaidBankTransferOrders.php | 22 + app/Jobs/CleanupAbandonedCarts.php | 19 + app/Jobs/DeliverWebhook.php | 63 ++ app/Jobs/ExpireAbandonedCheckouts.php | 29 + app/Jobs/ProcessMediaUpload.php | 27 + app/Livewire/Admin/Analytics/Index.php | 7 + app/Livewire/Admin/Apps/Index.php | 7 + app/Livewire/Admin/Apps/Show.php | 7 + app/Livewire/Admin/Auth/ForgotPassword.php | 25 + app/Livewire/Admin/Auth/Login.php | 40 ++ app/Livewire/Admin/Auth/ResetPassword.php | 46 ++ app/Livewire/Admin/Collections/Create.php | 7 + app/Livewire/Admin/Collections/Edit.php | 7 + app/Livewire/Admin/Collections/Index.php | 7 + app/Livewire/Admin/Customers/Index.php | 18 + app/Livewire/Admin/Customers/Show.php | 21 + app/Livewire/Admin/Dashboard.php | 19 + app/Livewire/Admin/Developers/Index.php | 7 + app/Livewire/Admin/Discounts/Form.php | 44 ++ app/Livewire/Admin/Discounts/Index.php | 14 + app/Livewire/Admin/Inventory/Index.php | 7 + app/Livewire/Admin/Navigation/Index.php | 7 + app/Livewire/Admin/Orders/Index.php | 18 + app/Livewire/Admin/Orders/Show.php | 61 ++ app/Livewire/Admin/Pages/Create.php | 7 + app/Livewire/Admin/Pages/Edit.php | 7 + app/Livewire/Admin/Pages/Index.php | 7 + app/Livewire/Admin/Products/Form.php | 62 ++ app/Livewire/Admin/Products/Index.php | 32 + app/Livewire/Admin/Search/Settings.php | 7 + app/Livewire/Admin/Section.php | 57 ++ app/Livewire/Admin/Settings/General.php | 31 + app/Livewire/Admin/Settings/Shipping.php | 31 + app/Livewire/Admin/Settings/Taxes.php | 30 + app/Livewire/Admin/Themes/Editor.php | 7 + app/Livewire/Admin/Themes/Index.php | 7 + .../Storefront/Account/Addresses/Index.php | 30 + .../Account/Auth/ForgotPassword.php | 25 + .../Storefront/Account/Auth/Login.php | 48 ++ .../Storefront/Account/Auth/Register.php | 34 + .../Storefront/Account/Auth/ResetPassword.php | 46 ++ app/Livewire/Storefront/Account/Dashboard.php | 15 + .../Storefront/Account/Orders/Index.php | 13 + .../Storefront/Account/Orders/Show.php | 21 + app/Livewire/Storefront/Cart/Show.php | 75 +++ .../Storefront/Checkout/Confirmation.php | 28 + app/Livewire/Storefront/Checkout/Show.php | 76 +++ app/Livewire/Storefront/Collections/Index.php | 14 + app/Livewire/Storefront/Collections/Show.php | 41 ++ app/Livewire/Storefront/Home.php | 27 + app/Livewire/Storefront/Pages/Show.php | 21 + app/Livewire/Storefront/Products/Show.php | 54 ++ app/Livewire/Storefront/Search/Index.php | 23 + app/Livewire/Storefront/Search/Modal.php | 30 + app/Models/AnalyticsDaily.php | 24 + app/Models/AnalyticsEvent.php | 18 + app/Models/App.php | 21 + app/Models/AppInstallation.php | 24 + app/Models/Cart.php | 41 ++ app/Models/CartLine.php | 21 + app/Models/Checkout.php | 45 ++ app/Models/Collection.php | 25 + app/Models/Customer.php | 55 ++ app/Models/CustomerAddress.php | 21 + app/Models/Discount.php | 28 + app/Models/Fulfillment.php | 27 + app/Models/FulfillmentLine.php | 21 + app/Models/InventoryItem.php | 35 + app/Models/NavigationItem.php | 21 + app/Models/NavigationMenu.php | 19 + app/Models/Order.php | 53 ++ app/Models/OrderLine.php | 31 + app/Models/Page.php | 27 + app/Models/Payment.php | 25 + app/Models/Product.php | 57 ++ app/Models/ProductMedia.php | 21 + app/Models/ProductOption.php | 22 + app/Models/ProductOptionValue.php | 22 + app/Models/ProductVariant.php | 46 ++ app/Models/Refund.php | 26 + app/Models/SearchQuery.php | 13 + app/Models/SearchSetting.php | 22 + app/Models/ShippingRate.php | 21 + app/Models/ShippingZone.php | 31 + app/Models/TaxSettings.php | 28 + app/Models/Theme.php | 30 + app/Models/ThemeFile.php | 16 + app/Models/ThemeSetting.php | 21 + app/Models/WebhookDelivery.php | 21 + app/Models/WebhookSubscription.php | 26 + app/Observers/ProductObserver.php | 24 + app/Policies/FulfillmentPolicy.php | 2 +- app/Policies/RefundPolicy.php | 2 +- app/Providers/AppServiceProvider.php | 27 +- app/Services/AnalyticsService.php | 35 + app/Services/CartService.php | 160 +++++ app/Services/CheckoutService.php | 127 ++++ app/Services/DiscountService.php | 103 +++ app/Services/FulfillmentService.php | 96 +++ app/Services/InventoryService.php | 70 ++ app/Services/MockPaymentProvider.php | 39 ++ app/Services/OrderService.php | 148 +++++ app/Services/PaymentProvider.php | 5 + app/Services/PaymentService.php | 60 ++ app/Services/PricingEngine.php | 69 ++ app/Services/ProductService.php | 104 +++ app/Services/RefundService.php | 85 +++ app/Services/SearchService.php | 82 +++ app/Services/ShippingCalculator.php | 53 ++ app/Services/TaxCalculator.php | 29 + app/Services/VariantMatrixService.php | 64 ++ app/Services/WebhookService.php | 33 + app/Support/HandleGenerator.php | 22 + app/Support/HtmlSanitizer.php | 18 + app/ValueObjects/DiscountResult.php | 9 + app/ValueObjects/PaymentResult.php | 15 + app/ValueObjects/PricingResult.php | 14 + app/ValueObjects/RefundResult.php | 8 + app/ValueObjects/TaxLine.php | 13 + app/ValueObjects/TaxResult.php | 9 + bootstrap/app.php | 5 + config/shop.php | 5 + database/factories/CartFactory.php | 16 + database/factories/CollectionFactory.php | 16 + database/factories/CustomerFactory.php | 16 + database/factories/DiscountFactory.php | 16 + database/factories/InventoryItemFactory.php | 28 + database/factories/OrderFactory.php | 16 + database/factories/ProductFactory.php | 22 + database/factories/ProductVariantFactory.php | 16 + .../2026_08_20_000000_create_shop_schema.php | 596 ++++++++++++++++++ ..._20_220000_add_search_and_auth_support.php | 38 ++ ..._20_220001_add_webhook_delivery_status.php | 22 + ..._220002_add_spec_compatibility_columns.php | 75 +++ ...8_20_220003_add_discount_code_to_carts.php | 22 + database/seeders/DatabaseSeeder.php | 9 +- database/seeders/OrganizationSeeder.php | 16 + database/seeders/ShopSeeder.php | 120 ++++ database/seeders/StoreDomainSeeder.php | 16 + database/seeders/StoreSeeder.php | 16 + database/seeders/StoreSettingsSeeder.php | 16 + .../storefront/product-card.blade.php | 19 + resources/views/layouts/admin.blade.php | 5 + resources/views/layouts/auth.blade.php | 4 +- resources/views/layouts/empty.blade.php | 1 + resources/views/layouts/storefront.blade.php | 42 ++ .../livewire/admin/analytics/index.blade.php | 3 + .../views/livewire/admin/apps/index.blade.php | 3 + .../views/livewire/admin/apps/show.blade.php | 3 + .../admin/auth/forgot-password.blade.php | 1 + .../views/livewire/admin/auth/login.blade.php | 1 + .../admin/auth/reset-password.blade.php | 1 + .../admin/collections/create.blade.php | 3 + .../livewire/admin/collections/edit.blade.php | 3 + .../admin/collections/index.blade.php | 3 + .../livewire/admin/customers/index.blade.php | 1 + .../livewire/admin/customers/show.blade.php | 1 + .../views/livewire/admin/dashboard.blade.php | 1 + .../livewire/admin/developers/index.blade.php | 3 + .../livewire/admin/discounts/form.blade.php | 1 + .../livewire/admin/discounts/index.blade.php | 1 + .../livewire/admin/inventory/index.blade.php | 3 + .../livewire/admin/navigation/index.blade.php | 3 + .../livewire/admin/orders/index.blade.php | 1 + .../livewire/admin/orders/show.blade.php | 1 + .../livewire/admin/pages/create.blade.php | 3 + .../views/livewire/admin/pages/edit.blade.php | 3 + .../livewire/admin/pages/index.blade.php | 3 + .../livewire/admin/products/form.blade.php | 1 + .../livewire/admin/products/index.blade.php | 1 + .../livewire/admin/search/settings.blade.php | 3 + .../views/livewire/admin/section.blade.php | 1 + .../livewire/admin/settings/general.blade.php | 1 + .../admin/settings/shipping.blade.php | 1 + .../livewire/admin/settings/taxes.blade.php | 1 + .../livewire/admin/themes/editor.blade.php | 3 + .../livewire/admin/themes/index.blade.php | 3 + .../account/addresses/index.blade.php | 1 + .../account/auth/forgot-password.blade.php | 1 + .../storefront/account/auth/login.blade.php | 1 + .../account/auth/register.blade.php | 1 + .../account/auth/reset-password.blade.php | 1 + .../storefront/account/dashboard.blade.php | 1 + .../storefront/account/orders/index.blade.php | 1 + .../storefront/account/orders/show.blade.php | 1 + .../livewire/storefront/cart/show.blade.php | 1 + .../checkout/confirmation.blade.php | 1 + .../storefront/checkout/show.blade.php | 1 + .../storefront/collections/index.blade.php | 1 + .../storefront/collections/show.blade.php | 1 + .../storefront/home-fallback.blade.php | 1 + .../views/livewire/storefront/home.blade.php | 18 + .../livewire/storefront/pages/show.blade.php | 1 + .../storefront/products/show.blade.php | 3 + .../storefront/search/index.blade.php | 1 + .../storefront/search/modal.blade.php | 9 + routes/api.php | 40 ++ routes/console.php | 6 + routes/web.php | 127 +++- specs/progress.md | 34 +- tests/Feature/AdminApiTest.php | 38 ++ tests/Feature/CommerceFlowTest.php | 141 +++++ tests/Feature/SearchAnalyticsWebhookTest.php | 79 +++ .../Storefront/CustomerAuthenticationTest.php | 41 ++ tests/Unit/DomainServicesTest.php | 107 ++++ tests/Unit/PricingEngineTest.php | 53 ++ 247 files changed, 6818 insertions(+), 28 deletions(-) create mode 100644 app/Auth/CustomerUserProvider.php create mode 100644 app/Contracts/PaymentProvider.php create mode 100644 app/Enums/CartStatus.php create mode 100644 app/Enums/CheckoutStatus.php create mode 100644 app/Enums/CollectionStatus.php create mode 100644 app/Enums/DiscountType.php create mode 100644 app/Enums/DiscountValueType.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/InventoryPolicy.php create mode 100644 app/Enums/MediaStatus.php create mode 100644 app/Enums/MediaType.php create mode 100644 app/Enums/OrderStatus.php create mode 100644 app/Enums/PageStatus.php create mode 100644 app/Enums/PaymentMethod.php create mode 100644 app/Enums/PaymentStatus.php create mode 100644 app/Enums/ProductStatus.php create mode 100644 app/Enums/RefundStatus.php create mode 100644 app/Enums/ThemeStatus.php create mode 100644 app/Enums/VariantStatus.php create mode 100644 app/Events/FulfillmentDelivered.php create mode 100644 app/Events/FulfillmentShipped.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/Events/ProductStatusChanged.php create mode 100644 app/Exceptions/CartVersionConflictException.php create mode 100644 app/Exceptions/FulfillmentGuardException.php create mode 100644 app/Exceptions/InsufficientInventoryException.php create mode 100644 app/Exceptions/InvalidDiscountException.php create mode 100644 app/Exceptions/InvalidProductTransitionException.php create mode 100644 app/Http/Controllers/Api/AdminController.php create mode 100644 app/Http/Controllers/Api/StorefrontAnalyticsController.php create mode 100644 app/Http/Controllers/Api/StorefrontCartController.php create mode 100644 app/Http/Controllers/Api/StorefrontCheckoutController.php create mode 100644 app/Http/Middleware/EnsureStoreRole.php create mode 100644 app/Jobs/AggregateAnalytics.php create mode 100644 app/Jobs/CancelUnpaidBankTransferOrders.php create mode 100644 app/Jobs/CleanupAbandonedCarts.php create mode 100644 app/Jobs/DeliverWebhook.php create mode 100644 app/Jobs/ExpireAbandonedCheckouts.php create mode 100644 app/Jobs/ProcessMediaUpload.php 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/ForgotPassword.php create mode 100644 app/Livewire/Admin/Auth/Login.php create mode 100644 app/Livewire/Admin/Auth/ResetPassword.php create mode 100644 app/Livewire/Admin/Collections/Create.php create mode 100644 app/Livewire/Admin/Collections/Edit.php create mode 100644 app/Livewire/Admin/Collections/Index.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/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/Create.php create mode 100644 app/Livewire/Admin/Pages/Edit.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/Section.php create mode 100644 app/Livewire/Admin/Settings/General.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/Livewire/Storefront/Account/Addresses/Index.php create mode 100644 app/Livewire/Storefront/Account/Auth/ForgotPassword.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/Auth/ResetPassword.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 app/Livewire/Storefront/Cart/Show.php create mode 100644 app/Livewire/Storefront/Checkout/Confirmation.php create mode 100644 app/Livewire/Storefront/Checkout/Show.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/Livewire/Storefront/Search/Modal.php create mode 100644 app/Models/AnalyticsDaily.php create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Models/App.php create mode 100644 app/Models/AppInstallation.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/Collection.php create mode 100644 app/Models/Customer.php create mode 100644 app/Models/CustomerAddress.php create mode 100644 app/Models/Discount.php create mode 100644 app/Models/Fulfillment.php create mode 100644 app/Models/FulfillmentLine.php create mode 100644 app/Models/InventoryItem.php create mode 100644 app/Models/NavigationItem.php create mode 100644 app/Models/NavigationMenu.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderLine.php create mode 100644 app/Models/Page.php create mode 100644 app/Models/Payment.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/Models/Refund.php create mode 100644 app/Models/SearchQuery.php create mode 100644 app/Models/SearchSetting.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/Models/Theme.php create mode 100644 app/Models/ThemeFile.php create mode 100644 app/Models/ThemeSetting.php create mode 100644 app/Models/WebhookDelivery.php create mode 100644 app/Models/WebhookSubscription.php create mode 100644 app/Observers/ProductObserver.php create mode 100644 app/Services/AnalyticsService.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/FulfillmentService.php create mode 100644 app/Services/InventoryService.php create mode 100644 app/Services/MockPaymentProvider.php create mode 100644 app/Services/OrderService.php create mode 100644 app/Services/PaymentProvider.php create mode 100644 app/Services/PaymentService.php create mode 100644 app/Services/PricingEngine.php create mode 100644 app/Services/ProductService.php create mode 100644 app/Services/RefundService.php create mode 100644 app/Services/SearchService.php create mode 100644 app/Services/ShippingCalculator.php create mode 100644 app/Services/TaxCalculator.php create mode 100644 app/Services/VariantMatrixService.php create mode 100644 app/Services/WebhookService.php create mode 100644 app/Support/HandleGenerator.php create mode 100644 app/Support/HtmlSanitizer.php create mode 100644 app/ValueObjects/DiscountResult.php create mode 100644 app/ValueObjects/PaymentResult.php create mode 100644 app/ValueObjects/PricingResult.php create mode 100644 app/ValueObjects/RefundResult.php create mode 100644 app/ValueObjects/TaxLine.php create mode 100644 app/ValueObjects/TaxResult.php create mode 100644 config/shop.php create mode 100644 database/factories/CartFactory.php create mode 100644 database/factories/CollectionFactory.php create mode 100644 database/factories/CustomerFactory.php create mode 100644 database/factories/DiscountFactory.php create mode 100644 database/factories/InventoryItemFactory.php create mode 100644 database/factories/OrderFactory.php create mode 100644 database/factories/ProductFactory.php create mode 100644 database/factories/ProductVariantFactory.php create mode 100644 database/migrations/2026_08_20_000000_create_shop_schema.php create mode 100644 database/migrations/2026_08_20_220000_add_search_and_auth_support.php create mode 100644 database/migrations/2026_08_20_220001_add_webhook_delivery_status.php create mode 100644 database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php create mode 100644 database/migrations/2026_08_20_220003_add_discount_code_to_carts.php create mode 100644 database/seeders/OrganizationSeeder.php create mode 100644 database/seeders/ShopSeeder.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 resources/views/components/storefront/product-card.blade.php create mode 100644 resources/views/layouts/admin.blade.php create mode 100644 resources/views/layouts/empty.blade.php create mode 100644 resources/views/layouts/storefront.blade.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/forgot-password.blade.php create mode 100644 resources/views/livewire/admin/auth/login.blade.php create mode 100644 resources/views/livewire/admin/auth/reset-password.blade.php create mode 100644 resources/views/livewire/admin/collections/create.blade.php create mode 100644 resources/views/livewire/admin/collections/edit.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/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/create.blade.php create mode 100644 resources/views/livewire/admin/pages/edit.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/section.blade.php create mode 100644 resources/views/livewire/admin/settings/general.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 resources/views/livewire/storefront/account/addresses/index.blade.php create mode 100644 resources/views/livewire/storefront/account/auth/forgot-password.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/auth/reset-password.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/livewire/storefront/cart/show.blade.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 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-fallback.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/livewire/storefront/search/modal.blade.php create mode 100644 routes/api.php create mode 100644 tests/Feature/AdminApiTest.php create mode 100644 tests/Feature/CommerceFlowTest.php create mode 100644 tests/Feature/SearchAnalyticsWebhookTest.php create mode 100644 tests/Feature/Storefront/CustomerAuthenticationTest.php create mode 100644 tests/Unit/DomainServicesTest.php create mode 100644 tests/Unit/PricingEngineTest.php diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..5b2deb17 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,27 @@ +createModel(); + $query = $model->newQuery(); + + if (app()->bound('current_store')) { + $query->where($model->qualifyColumn('store_id'), app('current_store')->getKey()); + } + + return $query; + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..aa5aa1aa --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,16 @@ +assertStore($storeId); + $data = $request->validate(['status' => ['nullable', 'in:draft,active,archived'], 'query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]); + $products = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'collections'])->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void { + $nested->where('title', 'like', '%'.$queryText.'%')->orWhere('vendor', 'like', '%'.$queryText.'%')->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', '%'.$queryText.'%')); + }))->latest('updated_at')->paginate($data['per_page'] ?? 25); + + return $this->paginated($products); + } + + public function storeProduct(Request $request, int $storeId, ProductService $products): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'vendor' => ['nullable', 'string', 'max:255'], 'product_type' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'in:draft,active,archived'], 'variants' => ['nullable', 'array']]); + $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]); + + return response()->json(['data' => $product->load('variants')->toArray()], 201); + } + + public function showProduct(int $storeId, int $productId): JsonResponse + { + $this->assertStore($storeId); + $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'options.values', 'media', 'collections'])->findOrFail($productId); + + return response()->json(['data' => $product->toArray()]); + } + + public function updateProduct(Request $request, int $storeId, int $productId, ProductService $products): JsonResponse + { + $this->assertStore($storeId); + $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId); + $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description' => ['sometimes', 'nullable', 'string'], 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], 'status' => ['sometimes', 'in:draft,active,archived']]); + + return response()->json(['data' => $products->update($product, $data)->toArray()]); + } + + public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse + { + $this->assertStore($storeId); + $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId); + $products->transitionStatus($product, ProductStatus::Archived); + + return response()->json(['message' => 'Product archived']); + } + + public function collections(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]); + $collections = Collection::withoutGlobalScopes()->where('store_id', $storeId)->withCount('products')->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where('title', 'like', '%'.$queryText.'%'))->latest()->paginate($data['per_page'] ?? 25); + + return $this->paginated($collections); + } + + public function storeCollection(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'status' => ['nullable', 'in:draft,active,archived'], 'product_ids' => ['nullable', 'array']]); + $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']); + $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId); + + return response()->json(['data' => $collection->load('products')->toArray()], 201); + } + + public function updateCollection(Request $request, int $storeId, int $collectionId): JsonResponse + { + $this->assertStore($storeId); + $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId); + $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description_html' => ['sometimes', 'nullable', 'string'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array']]); + $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null)); + + if (array_key_exists('product_ids', $data)) { + $this->syncCollectionProducts($collection, $data['product_ids'], $storeId); + } + + return response()->json(['data' => $collection->load('products')->toArray()]); + } + + public function deleteCollection(int $storeId, int $collectionId): JsonResponse + { + $this->assertStore($storeId); + Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId)->delete(); + + return response()->json(['message' => 'Collection deleted']); + } + + public function orders(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validate(['status' => ['nullable', 'string'], 'financial_status' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]); + $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('customer')->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['financial_status'] ?? null, fn ($query, string $status) => $query->where('financial_status', $status))->latest('placed_at')->paginate($data['per_page'] ?? 25); + + return $this->paginated($orders); + } + + public function showOrder(int $storeId, int $orderId): JsonResponse + { + $this->assertStore($storeId); + $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with(['customer', 'lines', 'payments', 'refunds', 'fulfillments.lines'])->findOrFail($orderId); + + return response()->json(['data' => $order->toArray()]); + } + + public function customers(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]); + $customers = Customer::withoutGlobalScopes()->where('store_id', $storeId)->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void { + $nested->where('email', 'like', '%'.$queryText.'%')->orWhere('first_name', 'like', '%'.$queryText.'%')->orWhere('last_name', 'like', '%'.$queryText.'%'); + }))->latest()->paginate($data['per_page'] ?? 25); + + return $this->paginated($customers); + } + + public function discounts(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + + return $this->paginated(Discount::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25))); + } + + private function assertStore(int $storeId): void + { + abort_unless((int) app('current_store')->getKey() === $storeId, 404); + } + + private function syncCollectionProducts(Collection $collection, array $productIds, int $storeId): void + { + $validIds = Product::withoutGlobalScopes()->where('store_id', $storeId)->whereIn('id', $productIds)->pluck('id')->all(); + $collection->products()->sync(array_fill_keys($validIds, ['position' => 0])); + } + + private function paginated($paginator): JsonResponse + { + return response()->json(['data' => $paginator->items(), 'meta' => ['current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'last_page' => $paginator->lastPage()]]); + } +} diff --git a/app/Http/Controllers/Api/StorefrontAnalyticsController.php b/app/Http/Controllers/Api/StorefrontAnalyticsController.php new file mode 100644 index 00000000..47182ce1 --- /dev/null +++ b/app/Http/Controllers/Api/StorefrontAnalyticsController.php @@ -0,0 +1,25 @@ +validate([ + 'type' => ['required', 'string'], + 'properties' => ['nullable', 'array'], + 'client_event_id' => ['nullable', 'string', 'max:255'], + ]); + $event = $this->analytics->track(app('current_store'), $data['type'], $data['properties'] ?? [], $request->hasSession() ? $request->session()->getId() : null, $request->user('customer')?->getKey(), $data['client_event_id'] ?? null); + + return response()->json(['id' => $event->getKey(), 'status' => 'accepted'], 202); + } +} diff --git a/app/Http/Controllers/Api/StorefrontCartController.php b/app/Http/Controllers/Api/StorefrontCartController.php new file mode 100644 index 00000000..8194fedc --- /dev/null +++ b/app/Http/Controllers/Api/StorefrontCartController.php @@ -0,0 +1,108 @@ +validate(['currency' => ['nullable', 'string', 'size:3']]); + $cart = $this->carts->create(app('current_store'), $request->user('customer')); + + if (isset($data['currency'])) { + $cart->update(['currency' => strtoupper($data['currency'])]); + } + + $request->session()->put(['cart_id' => $cart->getKey(), 'cart_id_'.app('current_store')->getKey() => $cart->getKey()]); + + return response()->json($this->payload($cart->refresh()), 201); + } + + public function show(int $cartId): JsonResponse + { + return response()->json($this->payload($this->cart($cartId))); + } + + public function addLine(Request $request, int $cartId): JsonResponse + { + $data = $request->validate(['variant_id' => ['required', 'integer'], 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], 'cart_version' => ['nullable', 'integer'], 'expected_version' => ['nullable', 'integer']]); + $cart = $this->cart($cartId); + + try { + $this->carts->assertVersion($cart, $data['cart_version'] ?? $data['expected_version'] ?? null); + $this->carts->addLine($cart, $data['variant_id'], $data['quantity']); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'cart' => $this->payload($cart->refresh())], 409); + } catch (InsufficientInventoryException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422); + } + + return response()->json($this->payload($this->cart($cartId)), 201); + } + + public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse + { + $data = $request->validate(['quantity' => ['required', 'integer', 'min:1', 'max:9999'], 'cart_version' => ['required', 'integer']]); + $cart = $this->cart($cartId); + + try { + $this->carts->assertVersion($cart, $data['cart_version']); + $this->carts->updateLineQuantity($cart, $lineId, $data['quantity']); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'cart' => $this->payload($cart->refresh())], 409); + } catch (InsufficientInventoryException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422); + } + + return response()->json($this->payload($this->cart($cartId))); + } + + public function removeLine(Request $request, int $cartId, int $lineId): JsonResponse + { + $data = $request->validate(['cart_version' => ['required', 'integer']]); + $cart = $this->cart($cartId); + + try { + $this->carts->assertVersion($cart, $data['cart_version']); + $this->carts->removeLine($cart, $lineId); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'cart' => $this->payload($cart->refresh())], 409); + } + + return response()->json($this->payload($this->cart($cartId))); + } + + private function cart(int $cartId): Cart + { + $cart = Cart::query()->with(['lines.variant.product.media', 'lines.variant.inventory'])->findOrFail($cartId); + $customerId = request()->user('customer')?->getKey(); + + if ($customerId !== null) { + abort_unless((int) $cart->customer_id === (int) $customerId, 404); + } else { + $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]); + abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404); + } + + return $cart; + } + + /** @return array */ + private function payload(Cart $cart): array + { + $lines = $cart->lines->map(fn ($line): array => ['id' => $line->id, 'variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_subtotal_amount' => $line->line_subtotal_amount, 'line_discount_amount' => $line->line_discount_amount, 'line_total_amount' => $line->line_total_amount, 'image_url' => $line->variant->product->media->first()?->url, 'requires_shipping' => $line->variant->requires_shipping, 'available_quantity' => $line->variant->inventory?->availableQuantity() ?? 0])->values()->all(); + $subtotal = (int) collect($lines)->sum('line_total_amount'); + + return ['id' => $cart->id, 'store_id' => $cart->store_id, 'customer_id' => $cart->customer_id, 'currency' => $cart->currency, 'cart_version' => $cart->cart_version, 'status' => $cart->status, 'lines' => $lines, 'totals' => ['subtotal' => $subtotal, 'discount' => 0, 'total' => $subtotal, 'currency' => $cart->currency, 'line_count' => count($lines), 'item_count' => (int) collect($lines)->sum('quantity')], 'created_at' => $cart->created_at, 'updated_at' => $cart->updated_at]; + } +} diff --git a/app/Http/Controllers/Api/StorefrontCheckoutController.php b/app/Http/Controllers/Api/StorefrontCheckoutController.php new file mode 100644 index 00000000..8e346588 --- /dev/null +++ b/app/Http/Controllers/Api/StorefrontCheckoutController.php @@ -0,0 +1,139 @@ +validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]); + $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']); + $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer')); + $this->pricing->calculate($checkout); + + return response()->json($this->payload($checkout->refresh()), 201); + } + + public function show(int $checkoutId): JsonResponse + { + $checkout = $this->checkout($checkoutId); + + if ($checkout->isExpired()) { + return response()->json(['message' => 'Checkout expired.'], 410); + } + + return response()->json($this->payload($checkout)); + } + + public function address(Request $request, int $checkoutId): JsonResponse + { + $data = $request->validate(['shipping_address' => ['required', 'array'], 'billing_address' => ['nullable', 'array'], 'use_shipping_as_billing' => ['nullable', 'boolean']]); + try { + $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true); + } catch (\LogicException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); + } + + return response()->json($this->payload($checkout)); + } + + public function shippingMethod(Request $request, int $checkoutId): JsonResponse + { + $data = $request->validate(['shipping_method_id' => ['required', 'integer'], 'shipping_rate_id' => ['nullable', 'integer']]); + try { + $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $data['shipping_rate_id'] ?? $data['shipping_method_id']); + } catch (\LogicException|\InvalidArgumentException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); + } + + return response()->json($this->payload($checkout)); + } + + public function applyDiscount(Request $request, int $checkoutId): JsonResponse + { + $data = $request->validate(['code' => ['required', 'string', 'max:64']]); + $checkout = $this->checkout($checkoutId); + try { + $discount = $this->discounts->validate($data['code'], app('current_store'), $checkout->cart); + } catch (InvalidDiscountException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => $exception->reason], 422); + } + $checkout->update(['discount_code' => $discount->code]); + $this->pricing->calculate($checkout->refresh()); + + return response()->json($this->payload($checkout->refresh())); + } + + public function pay(Request $request, int $checkoutId): JsonResponse + { + $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], 'card_number' => ['nullable', 'string'], 'card_expiry' => ['nullable', 'string'], 'card_cvc' => ['nullable', 'string']]); + try { + $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']); + $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data); + } catch (InsufficientInventoryException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422); + } catch (\LogicException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); + } + + if ($order === null) { + return response()->json(['message' => 'Payment failed.', 'code' => 'payment_failed'], 422); + } + + return response()->json(['order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'total_amount' => $order->total_amount], 'message' => $order->financial_status->value === 'pending' ? 'Bank transfer instructions generated.' : 'Order confirmed.']); + } + + public function paymentMethod(Request $request, int $checkoutId): JsonResponse + { + $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]); + try { + $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']); + } catch (InsufficientInventoryException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422); + } catch (\LogicException $exception) { + return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); + } + + return response()->json($this->payload($checkout)); + } + + private function checkout(int $checkoutId): Checkout + { + $checkout = Checkout::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId); + $customerId = request()->user('customer')?->getKey(); + + if ($customerId !== null) { + abort_unless((int) $checkout->customer_id === (int) $customerId, 404); + } else { + $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]); + abort_unless(in_array($checkout->cart_id, $sessionCartIds, true), 404); + } + + return $checkout; + } + + /** @return array */ + private function payload(Checkout $checkout): array + { + $totals = $checkout->totals_json ?? $this->pricing->calculate($checkout)->toArray(); + $rates = $checkout->shipping_address_json === null ? collect() : $this->shipping->getAvailableRates(app('current_store'), $checkout->shipping_address_json); + + return ['id' => $checkout->id, 'store_id' => $checkout->store_id, 'cart_id' => $checkout->cart_id, 'customer_id' => $checkout->customer_id, 'status' => $checkout->status, 'email' => $checkout->email, 'payment_method' => $checkout->payment_method, 'shipping_address_json' => $checkout->shipping_address_json, 'billing_address_json' => $checkout->billing_address_json, 'shipping_method_id' => $checkout->shipping_rate_id, 'discount_code' => $checkout->discount_code, 'lines' => $checkout->cart->lines->map(fn ($line): array => ['variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_total_amount' => $line->line_total_amount])->all(), 'totals' => $totals, 'available_shipping_methods' => $rates->map(fn ($rate): array => ['id' => $rate->id, 'name' => $rate->name, 'type' => $rate->type, 'price_amount' => $rate->price_amount, 'currency' => $rate->currency, 'estimated_days_min' => $rate->estimated_days_min, 'estimated_days_max' => $rate->estimated_days_max])->all(), 'expires_at' => $checkout->expires_at, 'created_at' => $checkout->created_at]; + } +} diff --git a/app/Http/Middleware/EnsureStoreRole.php b/app/Http/Middleware/EnsureStoreRole.php new file mode 100644 index 00000000..5180c142 --- /dev/null +++ b/app/Http/Middleware/EnsureStoreRole.php @@ -0,0 +1,22 @@ +user()?->roleForStore($store); + $allowedRoles = array_map(fn (string $value): StoreUserRole => StoreUserRole::from($value), $roles); + + abort_unless($role !== null && in_array($role, $allowedRoles, true), 403); + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php index 8e6ce8c8..8bcce48a 100644 --- a/app/Http/Middleware/ResolveStore.php +++ b/app/Http/Middleware/ResolveStore.php @@ -9,6 +9,7 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -17,6 +18,13 @@ class ResolveStore { public function handle(Request $request, Closure $next, string $context = 'storefront'): Response { + if (! Schema::hasTable('stores')) { + return $next($request); + } + + if ($this->isPublicAdminAuthRequest($request)) { + return $next($request); + } $context = $context === 'storefront' && $this->isAdminRequest($request) ? 'admin' : $context; @@ -25,6 +33,10 @@ public function handle(Request $request, Closure $next, string $context = 'store ? $this->resolveAdminStore($request) : $this->resolveStorefrontStore($request); + if ($store === null && $context === 'storefront' && $this->isPublicCustomerAuthRequest($request)) { + return $next($request); + } + abort_unless($store instanceof Store, $context === 'admin' ? 403 : 404); if ($store->status === StoreStatus::Suspended) { @@ -77,6 +89,20 @@ private function isAdminRequest(Request $request): bool { $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/'); - return $request->is($prefix, $prefix.'/*') || $request->routeIs($prefix.'.*'); + if ($request->is($prefix, $prefix.'/*') || $request->routeIs($prefix.'.*')) { + return true; + } + + return $request->is('livewire/update') && str_contains((string) $request->headers->get('referer'), '/admin'); + } + + private function isPublicAdminAuthRequest(Request $request): bool + { + return $request->is('admin/login', 'admin/forgot-password', 'admin/reset-password/*'); + } + + private function isPublicCustomerAuthRequest(Request $request): bool + { + return $request->is('forgot-password', 'reset-password/*'); } } diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..daecec4e --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,44 @@ +date ?? now()->subDay()->toDateString()); + $stores = $this->store === null ? Store::query()->get() : collect([$this->store]); + + foreach ($stores as $store) { + $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('created_at', $date)->get(); + $orders = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('placed_at', $date)->whereIn('financial_status', ['paid', 'partially_refunded'])->get(); + $revenue = (int) $orders->sum('total_amount'); + + AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert( + ['store_id' => $store->getKey(), 'date' => $date->toDateString()], + [ + 'orders_count' => $orders->count(), + 'revenue_amount' => $revenue, + 'aov_amount' => $orders->count() > 0 ? intdiv($revenue, $orders->count()) : 0, + 'visits_count' => $events->where('type', 'page_view')->count(), + 'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(), + 'checkout_started_count' => $events->where('type', 'checkout_started')->count(), + ], + ); + } + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..875f9e30 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,22 @@ +where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.')); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..3780e0ca --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,19 @@ +where('status', 'active')->where('updated_at', '<', now()->subDays(14))->update(['status' => 'abandoned']); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..4b79186f --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,63 @@ + */ + public function backoff(): array + { + return [60, 300, 1800, 7200, 43200]; + } + + public function handle(WebhookService $webhooks): void + { + $this->delivery->load('subscription'); + $payload = json_encode($this->delivery->payload, JSON_THROW_ON_ERROR); + $subscription = $this->delivery->subscription; + $response = Http::withHeaders([ + 'X-Platform-Signature' => $webhooks->sign($payload, $subscription->secret_encrypted), + 'X-Platform-Event' => $this->delivery->event, + 'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(), + 'X-Platform-Timestamp' => (string) now()->timestamp, + ])->timeout(10)->post($subscription->target_url, $this->delivery->payload); + + $this->delivery->increment('attempts'); + $this->delivery->update(['response_status' => $response->status(), 'response_body' => mb_substr($response->body(), 0, 10000)]); + + if ($response->successful()) { + $subscription->update(['consecutive_failures' => 0]); + $this->delivery->update(['status' => 'delivered', 'delivered_at' => now(), 'next_attempt_at' => null]); + + return; + } + + $subscription->increment('consecutive_failures'); + + if ($subscription->fresh()->consecutive_failures >= 5) { + $subscription->update(['status' => 'paused']); + } + + throw new \RuntimeException('Webhook delivery failed with HTTP '.$response->status()); + } + + public function failed(?Throwable $exception): void + { + $this->delivery->update(['status' => 'failed', 'next_attempt_at' => null]); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..72c0d415 --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,29 @@ +whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired])->where('expires_at', '<', now())->with('cart.lines.variant.inventory')->each(function (Checkout $checkout) use ($inventory): void { + foreach ($checkout->cart->lines as $line) { + if ($line->variant->inventory !== null && $checkout->status === CheckoutStatus::PaymentSelected) { + $inventory->release($line->variant->inventory, $line->quantity); + } + } + + $checkout->update(['status' => CheckoutStatus::Expired]); + }); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..d9008a86 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,27 @@ +media->update(['status' => 'ready']); + } catch (Throwable $exception) { + $this->media->update(['status' => 'failed', 'metadata' => ['error' => $exception->getMessage()]]); + throw $exception; + } + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..407f3d05 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,7 @@ +validate(['email' => ['required', 'email']]); + Password::broker('users')->sendResetLink(['email' => $this->email]); + $this->message = 'If an account exists for that email, a reset link has been sent.'; + } + + public function render(): mixed + { + return view('livewire.admin.auth.forgot-password')->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..6203e8ee --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,40 @@ +validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + + if (! Auth::guard('web')->attempt($credentials, $this->remember)) { + $this->addError('email', 'These credentials do not match our records.'); + + return; + } + + session()->regenerate(); + $store = auth()->user()->stores()->first(); + + if ($store !== null) { + session()->put('current_store_id', $store->getKey()); + } + + $this->redirect(route('admin.dashboard'), navigate: true); + } + + public function render(): mixed + { + return view('livewire.admin.auth.login')->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Auth/ResetPassword.php b/app/Livewire/Admin/Auth/ResetPassword.php new file mode 100644 index 00000000..5dee5bd3 --- /dev/null +++ b/app/Livewire/Admin/Auth/ResetPassword.php @@ -0,0 +1,46 @@ +token = $token; + $this->email = request()->string('email')->toString(); + } + + public function resetPassword(): void + { + $data = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]); + $status = Password::broker('users')->reset(['email' => $data['email'], 'password' => $data['password'], 'password_confirmation' => $this->passwordConfirmation, 'token' => $this->token], function (User $user, string $password): void { + $user->password = $password; + $user->save(); + }); + + if ($status !== Password::PASSWORD_RESET) { + $this->addError('email', __($status)); + + return; + } + + $this->redirect(route('admin.login'), navigate: true); + } + + public function render(): mixed + { + return view('livewire.admin.auth.reset-password')->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Collections/Create.php b/app/Livewire/Admin/Collections/Create.php new file mode 100644 index 00000000..1070db69 --- /dev/null +++ b/app/Livewire/Admin/Collections/Create.php @@ -0,0 +1,7 @@ +when($this->search !== '', fn ($query) => $query->where('email', 'like', '%'.$this->search.'%')->orWhere('first_name', 'like', '%'.$this->search.'%')->orWhere('last_name', 'like', '%'.$this->search.'%'))->withCount('orders')->latest()->paginate(15); + + return view('livewire.admin.customers.index', compact('customers'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..efaa2313 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,21 @@ +customer = $customer->load(['orders', 'addresses']); + } + + public function render(): mixed + { + return view('livewire.admin.customers.show')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..203c97d2 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,19 @@ +with('customer')->latest('placed_at')->take(10)->get(); + $sales = (int) Order::query()->where('financial_status', 'paid')->sum('total_amount'); + $orderCount = (int) Order::query()->count(); + + return view('livewire.admin.dashboard', ['orders' => $orders, 'sales' => $sales, 'orderCount' => $orderCount, 'productCount' => Product::query()->count()])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..f09050df --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,7 @@ +discount = $discount; + + if ($discount !== null) { + $this->code = (string) $discount->code; + $this->valueType = $discount->value_type->value; + $this->valueAmount = $discount->value_amount; + } + } + + public function save(): void + { + $data = $this->validate(['code' => ['required', 'string', 'max:64'], 'valueType' => ['required', 'in:percent,fixed,free_shipping'], 'valueAmount' => ['required', 'integer', 'min:0']]); + + $this->authorize($this->discount === null ? 'create' : 'update', $this->discount ?? Discount::class); + $this->discount = Discount::updateOrCreate(['id' => $this->discount?->id], ['store_id' => app('current_store')->getKey(), 'code' => strtoupper($data['code']), 'type' => 'code', 'value_type' => $data['valueType'], 'value_amount' => $data['valueAmount'], 'status' => 'active', 'starts_at' => now(), 'rules_json' => []]); + $this->message = 'Discount saved'; + } + + public function render(): mixed + { + return view('livewire.admin.discounts.form')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..74482dfa --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,14 @@ + Discount::query()->latest()->get()])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..75880ce1 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,7 @@ +with('customer')->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status))->latest('placed_at')->paginate(15); + + return view('livewire.admin.orders.index', compact('orders'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..12bfc763 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,61 @@ +order = $order->load(['lines.variant.inventory', 'payments', 'fulfillments.lines']); + } + + public function confirmPayment(OrderService $orders): void + { + $this->authorize('update', $this->order); + $orders->confirmPayment($this->order); + $this->message = 'Payment confirmed'; + $this->order = $this->order->refresh()->load(['lines.variant.inventory', 'payments', 'fulfillments.lines']); + } + + public function fulfill(FulfillmentService $fulfillments): void + { + $this->authorize('createFulfillment', $this->order); + $lines = $this->order->lines->map(fn ($line): array => ['order_line_id' => $line->id, 'quantity' => $line->quantity])->all(); + $fulfillments->create($this->order, $lines); + $this->message = 'Fulfillment created'; + $this->order = $this->order->refresh()->load(['lines', 'payments', 'fulfillments.lines']); + } + + public function refund(RefundService $refunds): void + { + $this->authorize('createRefund', $this->order); + $payment = $this->order->payments->first(); + + if ($payment === null) { + $this->addError('refundAmount', 'No payment found.'); + + return; + } + + $refunds->create($this->order, $payment, $this->refundAmount ?: $payment->amount, 'Admin refund', true); + $this->message = 'Refund processed'; + $this->order = $this->order->refresh()->load(['lines', 'payments', 'refunds']); + } + + public function render(): mixed + { + return view('livewire.admin.orders.show')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Pages/Create.php b/app/Livewire/Admin/Pages/Create.php new file mode 100644 index 00000000..dca9a4e3 --- /dev/null +++ b/app/Livewire/Admin/Pages/Create.php @@ -0,0 +1,7 @@ +product = $product; + + if ($product !== null) { + $this->title = $product->title; + $this->description = (string) $product->description; + $this->vendor = (string) $product->vendor; + $this->productType = (string) $product->product_type; + $this->status = $product->status->value; + $this->priceAmount = (int) ($product->defaultVariant()?->price_amount ?? 0); + } + } + + public function save(ProductService $products): void + { + $data = $this->validate(['title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'vendor' => ['nullable', 'string', 'max:255'], 'productType' => ['nullable', 'string', 'max:255'], 'priceAmount' => ['required', 'integer', 'min:0'], 'status' => ['required', 'in:draft,active,archived']]); + $payload = ['title' => $data['title'], 'description' => $data['description'], 'vendor' => $data['vendor'], 'product_type' => $data['productType'], 'status' => ProductStatus::from($data['status']), 'variants' => [['title' => 'Default', 'price_amount' => $data['priceAmount'], 'is_default' => true]]]; + + if ($this->product === null) { + $this->authorize('create', Product::class); + $this->product = $products->create(app('current_store'), $payload); + } else { + $this->authorize('update', $this->product); + $products->update($this->product, $payload); + } + + $this->message = 'Product saved'; + } + + public function render(): mixed + { + return view('livewire.admin.products.form')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..dadec5ec --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,32 @@ +findOrFail($productId); + $this->authorize('archive', $product); + $products->transitionStatus($product, ProductStatus::Archived); + $this->message = 'Product archived'; + } + + public function render(): mixed + { + $products = Product::query()->with(['variants.inventory'])->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%'))->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status))->latest()->paginate(15); + + return view('livewire.admin.products.index', compact('products'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..a41ca96c --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,7 @@ +heading = match (true) { + request()->is('admin/inventory') => 'Inventory', + request()->is('admin/collections') => 'Collections', + request()->is('admin/themes*') => 'Themes', + request()->is('admin/pages') => 'Pages', + request()->is('admin/navigation') => 'Navigation', + request()->is('admin/apps*') => 'Apps', + request()->is('admin/developers') => 'Developers', + request()->is('admin/analytics') => 'Analytics', + request()->is('admin/search/settings') => 'Search settings', + default => $this->heading, + }; + } + + public function render(): mixed + { + return view('livewire.admin.section', ['rows' => $this->rows()])->layout('layouts.admin'); + } + + /** @return list */ + private function rows(): array + { + return match (true) { + request()->is('admin/inventory') => InventoryItem::query()->with('variant.product')->latest()->take(50)->get()->map(fn (InventoryItem $item): array => ['title' => $item->variant?->product?->title ?? 'Unknown product', 'subtitle' => $item->variant?->title ?? 'Unknown variant', 'value' => $item->availableQuantity().' available'])->all(), + request()->is('admin/collections*') => Collection::query()->withCount('products')->latest()->take(50)->get()->map(fn (Collection $collection): array => ['title' => $collection->title, 'subtitle' => $collection->status->value, 'value' => $collection->products_count.' products'])->all(), + request()->is('admin/themes*') => Theme::query()->latest()->take(50)->get()->map(fn (Theme $theme): array => ['title' => $theme->name, 'subtitle' => 'Version '.$theme->version, 'value' => $theme->status->value])->all(), + request()->is('admin/pages*') => Page::query()->latest()->take(50)->get()->map(fn (Page $page): array => ['title' => $page->title, 'subtitle' => '/pages/'.$page->handle, 'value' => $page->status->value])->all(), + request()->is('admin/navigation') => NavigationMenu::query()->withCount('items')->latest()->take(50)->get()->map(fn (NavigationMenu $menu): array => ['title' => $menu->name, 'subtitle' => $menu->handle, 'value' => $menu->items_count.' links'])->all(), + request()->is('admin/apps*') => AppInstallation::query()->with('app')->latest()->take(50)->get()->map(fn (AppInstallation $installation): array => ['title' => $installation->app?->name ?? 'Installed app', 'subtitle' => $installation->status, 'value' => 'Connected'])->all(), + request()->is('admin/developers') => WebhookSubscription::query()->latest()->take(50)->get()->map(fn (WebhookSubscription $subscription): array => ['title' => $subscription->event, 'subtitle' => $subscription->target_url, 'value' => $subscription->status])->all(), + request()->is('admin/analytics') => AnalyticsDaily::query()->latest('date')->take(30)->get()->map(fn (AnalyticsDaily $day): array => ['title' => $day->date->toDateString(), 'subtitle' => $day->visits_count.' visits', 'value' => $day->orders_count.' orders · €'.number_format($day->revenue_amount / 100, 2)])->all(), + request()->is('admin/search/settings') => (($settings = SearchSetting::query()->first()) === null ? [] : [['title' => 'Search indexing', 'subtitle' => count($settings->synonyms ?? []).' synonym groups', 'value' => $settings->enabled ? 'Enabled' : 'Disabled']]), + default => [], + }; + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php new file mode 100644 index 00000000..e10729ae --- /dev/null +++ b/app/Livewire/Admin/Settings/General.php @@ -0,0 +1,31 @@ +storeName = (string) ($settings?->general_json['store_name'] ?? app('current_store')->name); + } + + public function save(): void + { + StoreSettings::updateOrCreate(['store_id' => app('current_store')->getKey()], ['general_json' => ['store_name' => $this->storeName]]); + app('current_store')->update(['name' => $this->storeName]); + $this->message = 'Settings saved'; + } + + public function render(): mixed + { + return view('livewire.admin.settings.general')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..f6c6c0de --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,31 @@ +validate(['zoneName' => ['required', 'string'], 'rateName' => ['required', 'string'], 'amount' => ['required', 'integer', 'min:0']]); + $zone = ShippingZone::updateOrCreate(['store_id' => app('current_store')->getKey(), 'name' => $data['zoneName']], ['countries_json' => ['DE'], 'regions_json' => []]); + ShippingRate::updateOrCreate(['shipping_zone_id' => $zone->getKey(), 'name' => $data['rateName']], ['type' => 'flat', 'price_amount' => $data['amount'], 'currency' => app('current_store')->default_currency, 'is_active' => true]); + $this->message = 'Shipping rate saved'; + } + + public function render(): mixed + { + return view('livewire.admin.settings.shipping', ['zones' => ShippingZone::with('rates')->latest()->get()])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..30d82c49 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,30 @@ +rate = (int) (TaxSettings::first()?->default_rate_basis_points ?? 1900); + } + + public function save(): void + { + $this->validate(['rate' => ['required', 'integer', 'min:0', 'max:10000']]); + TaxSettings::updateOrCreate(['store_id' => app('current_store')->getKey()], ['mode' => 'exclusive', 'default_rate_basis_points' => $this->rate, 'rates_json' => ['DE' => $this->rate]]); + $this->message = 'Tax settings saved'; + } + + public function render(): mixed + { + return view('livewire.admin.settings.taxes')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..9656ddee --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,7 @@ +validate(['label' => ['nullable', 'string', 'max:255'], 'address1' => ['required', 'string', 'max:500'], 'city' => ['required', 'string', 'max:255'], 'countryCode' => ['required', 'size:2'], 'postalCode' => ['required', 'max:20']]); + auth('customer')->user()->addresses()->create(['label' => $data['label'], 'address_json' => ['address1' => $data['address1'], 'city' => $data['city'], 'country_code' => $data['countryCode'], 'postal_code' => $data['postalCode']], 'is_default' => auth('customer')->user()->addresses()->count() === 0]); + $this->reset(['label', 'address1', 'city', 'postalCode']); + } + + public function render(): mixed + { + return view('livewire.storefront.account.addresses.index', ['addresses' => auth('customer')->user()->addresses()->latest()->get()])->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php new file mode 100644 index 00000000..9f0a5bd1 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php @@ -0,0 +1,25 @@ +validate(['email' => ['required', 'email']]); + Password::broker('customers')->sendResetLink(['email' => $this->email]); + $this->message = 'If an account exists for that email, a reset link has been sent.'; + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.forgot-password')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..146d7e89 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,48 @@ +validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + + if (! Auth::guard('customer')->attempt($credentials, $this->remember)) { + $this->addError('email', 'These credentials do not match our records.'); + + return; + } + + session()->regenerate(); + $customer = Auth::guard('customer')->user(); + $store = app('current_store'); + $guestCartId = session('cart_id_'.$store->getKey(), session('cart_id')); + $guestCart = $guestCartId === null ? null : Cart::withoutGlobalScopes()->whereKey($guestCartId)->where('store_id', $store->getKey())->whereNull('customer_id')->where('status', 'active')->first(); + $customerCart = $customer->carts()->where('status', 'active')->latest()->first() ?? $carts->create($store, $customer); + + if ($guestCart !== null && $guestCart->getKey() !== $customerCart->getKey()) { + $carts->mergeOnLogin($guestCart, $customerCart); + } else { + session(['cart_id_'.$store->getKey() => $customerCart->getKey(), 'cart_id' => $customerCart->getKey()]); + } + + $this->redirect(route('account.dashboard')); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.login')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..611ff7f3 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,34 @@ +validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', Rule::unique('customers', 'email')->where('store_id', app('current_store')->getKey())], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]); + $customer = Customer::create(['store_id' => app('current_store')->getKey(), 'first_name' => $data['firstName'], 'last_name' => $data['lastName'], 'email' => $data['email'], 'password_hash' => $data['password'], 'status' => 'active']); + Auth::guard('customer')->login($customer); + $this->redirect(route('account.dashboard')); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.register')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php new file mode 100644 index 00000000..7472c578 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php @@ -0,0 +1,46 @@ +token = $token; + $this->email = request()->string('email')->toString(); + } + + public function resetPassword(): void + { + $data = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]); + $status = Password::broker('customers')->reset(['email' => $data['email'], 'password' => $data['password'], 'password_confirmation' => $this->passwordConfirmation, 'token' => $this->token], function (Customer $customer, string $password): void { + $customer->password_hash = $password; + $customer->save(); + }); + + if ($status !== Password::PASSWORD_RESET) { + $this->addError('email', __($status)); + + return; + } + + $this->redirect(route('account.login'), navigate: true); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.reset-password')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..3cd164f6 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,15 @@ +user()->loadCount('orders'); + + return view('livewire.storefront.account.dashboard', compact('customer'))->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..b5a0da0f --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,13 @@ + auth('customer')->user()->orders()->latest()->get()])->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..2e385dfd --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,21 @@ +order = auth('customer')->user()->orders()->with('lines')->where('order_number', $orderNumber)->firstOrFail(); + } + + public function render(): mixed + { + return view('livewire.storefront.account.orders.show')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..1753aa91 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,75 @@ +cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + } + + public function increase(int $lineId, CartService $carts): void + { + $line = $this->cart->lines->firstWhere('id', $lineId); + $carts->updateLineQuantity($this->cart, $lineId, $line->quantity + 1); + $this->refreshCart(); + } + + public function decrease(int $lineId, CartService $carts): void + { + $line = $this->cart->lines->firstWhere('id', $lineId); + + if ($line->quantity > 1) { + $carts->updateLineQuantity($this->cart, $lineId, $line->quantity - 1); + } + + $this->refreshCart(); + } + + public function remove(int $lineId, CartService $carts): void + { + $carts->removeLine($this->cart, $lineId); + $this->refreshCart(); + } + + public function checkout(CheckoutService $checkouts): void + { + $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user()); + $this->redirect(route('checkout.show', $checkout), navigate: true); + } + + public function applyDiscount(DiscountService $discounts): void + { + try { + $discount = $discounts->validate($this->discountCode, app('current_store'), $this->cart); + $this->cart->update(['discount_code' => $discount->code]); + $this->message = 'Discount applied'; + } catch (InvalidDiscountException $exception) { + $this->addError('discountCode', $exception->getMessage()); + } + } + + private function refreshCart(): void + { + $this->cart = $this->cart->refresh()->load(['lines.variant.product', 'lines.variant.inventory']); + } + + public function render(): mixed + { + return view('livewire.storefront.cart.show')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..670bb689 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,28 @@ +order = Order::query()->with(['lines', 'customer', 'checkout'])->where('checkout_id', $checkoutId)->first() + ?? Order::query()->with(['lines', 'customer', 'checkout'])->where('id', $checkoutId)->firstOrFail(); + + $customerId = Auth::guard('customer')->id(); + $sessionCartIds = array_filter([session('cart_id'), session('cart_id_'.app('current_store')->getKey())]); + abort_unless(($customerId !== null && (int) $this->order->customer_id === (int) $customerId) + || ($customerId === null && in_array($this->order->checkout?->cart_id, $sessionCartIds, true)), 404); + } + + public function render(): mixed + { + return view('livewire.storefront.checkout.confirmation')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..8b255d7f --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,76 @@ + '', 'last_name' => '', 'address1' => '', 'city' => '', 'country_code' => 'DE', 'postal_code' => '']; + + public ?int $shippingRateId = null; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = '4242424242424242'; + + public string $message = ''; + + public function mount(int $checkoutId): void + { + $this->checkout = CheckoutModel::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId); + $customerId = auth('customer')->id(); + $sessionCartIds = array_filter([session('cart_id'), session('cart_id_'.app('current_store')->getKey())]); + abort_unless(($customerId !== null && (int) $this->checkout->customer_id === (int) $customerId) + || ($customerId === null && in_array($this->checkout->cart_id, $sessionCartIds, true)), 404); + + if ($this->checkout->shipping_address_json !== null) { + $this->shippingAddress = $this->checkout->shipping_address_json; + } + } + + public function saveAddress(CheckoutService $checkouts): void + { + $this->checkout = $checkouts->setAddress($this->checkout, $this->shippingAddress); + $this->message = 'Address saved'; + } + + public function chooseShipping(CheckoutService $checkouts): void + { + $this->validate(['shippingRateId' => ['required', 'integer']]); + $this->checkout = $checkouts->setShippingMethod($this->checkout, $this->shippingRateId); + $this->message = 'Shipping method saved'; + } + + public function pay(CheckoutService $checkouts, PaymentService $payments): void + { + $this->validate(['paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer']]); + $this->checkout = $checkouts->selectPaymentMethod($this->checkout, $this->paymentMethod); + $order = $payments->pay($this->checkout, PaymentMethod::from($this->paymentMethod), ['card_number' => $this->cardNumber]); + + if ($order === null) { + $this->addError('paymentMethod', 'Your payment was declined.'); + + return; + } + + $this->redirect(route('checkout.confirmation', ['checkoutId' => $order->checkout_id]), navigate: true); + } + + public function render(ShippingCalculator $shipping, PricingEngine $pricing): mixed + { + $rates = $this->checkout->shipping_address_json === null ? collect() : $shipping->getAvailableRates(app('current_store'), $this->checkout->shipping_address_json); + $this->checkout->load(['cart.lines.variant.product', 'shippingRate']); + $totals = $this->checkout->totals_json ?? $pricing->calculate($this->checkout)->toArray(); + + return view('livewire.storefront.checkout.show', compact('rates', 'totals'))->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..77e5bd09 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,14 @@ + ProductCollection::query()->where('status', 'active')->latest()->get()])->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..38d46a74 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,41 @@ +collection = ProductCollection::query()->where('handle', $handle)->with(['products.variants.inventory', 'products.media'])->firstOrFail(); + } + + public function render(): mixed + { + $products = $this->collection->products->filter(fn ($product): bool => $product->status->value === 'active' && (! $this->inStock || $product->variants->contains(fn ($variant): bool => $variant->availableQuantity() > 0))); + + if ($this->sort === 'price_asc') { + $products = $products->sortBy(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0); + } elseif ($this->sort === 'price_desc') { + $products = $products->sortByDesc(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0); + } elseif ($this->sort === 'newest') { + $products = $products->sortByDesc('created_at'); + } + + return view('livewire.storefront.collections.show', ['products' => $products])->layout('layouts.storefront'); + } + + public function clearFilters(): void + { + $this->inStock = false; + $this->sort = 'featured'; + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..d599cebd --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,27 @@ +store = app()->bound('current_store') ? app('current_store') : null; + } + + public function render(): mixed + { + if ($this->store === null) { + return view('livewire.storefront.home-fallback')->layout('layouts.empty'); + } + + return view('livewire.storefront.home', ['collections' => Collection::query()->where('status', 'active')->withCount('products')->latest()->take(4)->get(), 'products' => Product::query()->published()->with(['variants.inventory', 'media'])->latest('published_at')->take(8)->get()])->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..7e8ff858 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,21 @@ +page = Page::query()->where('handle', $handle)->where('status', 'published')->firstOrFail(); + } + + public function render(): mixed + { + return view('livewire.storefront.pages.show')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..66692fcc --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,54 @@ +product = Product::query()->with(['variants.inventory', 'media', 'options.values'])->where('handle', $handle)->firstOrFail(); + abort_unless($this->product->status->value === 'active', 404); + $this->selectedVariantId = $this->product->defaultVariant()?->getKey() ?? 0; + } + + public function addToCart(CartService $carts): void + { + $cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + try { + $carts->addLine($cart, $this->selectedVariantId, $this->quantity); + } catch (InsufficientInventoryException $exception) { + $this->addError('quantity', $exception->getMessage()); + + return; + } + $this->message = 'Added to cart'; + $this->dispatch('cart-updated'); + } + + public function selectVariant(int $variantId): void + { + abort_unless($this->product->variants->contains('id', $variantId), 404); + + $this->selectedVariantId = $variantId; + } + + public function render(): mixed + { + $this->product->loadMissing(['variants.inventory', 'media', 'options.values']); + + return view('livewire.storefront.products.show')->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..f14f6a0d --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,23 @@ +query = $q ?? request()->string('q')->toString(); + } + + public function render(SearchService $search): mixed + { + $products = $search->search(app('current_store'), $this->query, [], 12); + + return view('livewire.storefront.search.index', compact('products'))->layout('layouts.storefront'); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..21b42974 --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,30 @@ +open) { + return; + } + + $this->suggestions = $search->autocomplete(app('current_store'), $this->query)->map(fn ($product): array => ['id' => $product->getKey(), 'title' => $product->title, 'handle' => $product->handle])->all(); + } + + /** @var array */ + public array $suggestions = []; + + public function render(): mixed + { + return view('livewire.storefront.search.modal'); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..b667a141 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,24 @@ + 'date']; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..c278c83b --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,18 @@ + 'array']; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..6621c4cb --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,21 @@ + 'array']; + } + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..d0afec6f --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,24 @@ + 'array']; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..5e1b275e --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,41 @@ + CartStatus::class]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function subtotalAmount(): 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..38c9e79c --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,21 @@ +belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id')->with(['product', 'inventory']); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..0c124b17 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,45 @@ + CheckoutStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'totals_json' => 'array', 'tax_provider_snapshot_json' => 'array', 'expires_at' => 'datetime']; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..72cf6802 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,25 @@ + CollectionStatus::class]; + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products')->withPivot('position')->orderBy('collection_products.position'); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..d0ff785d --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,55 @@ + 'datetime', 'metadata' => 'array', 'password_hash' => 'hashed']; + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function getAuthPassword(): ?string + { + return $this->password_hash; + } + + public function getNameAttribute(): string + { + return trim($this->first_name.' '.$this->last_name); + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..c7a86bc2 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,21 @@ + 'array', 'is_default' => 'boolean']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..1262f302 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,28 @@ + DiscountType::class, 'value_type' => DiscountValueType::class, 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'rules_json' => 'array']; + } + + public function isAvailable(): bool + { + return $this->status === 'active' + && ($this->starts_at === null || $this->starts_at->isPast()) + && ($this->ends_at === null || $this->ends_at->isFuture()) + && ($this->usage_limit === null || $this->usage_count < $this->usage_limit); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..8d4052d0 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,27 @@ + 'datetime', 'delivered_at' => 'datetime', 'fulfilled_at' => 'datetime']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..d3dd1dcf --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,21 @@ +belongsTo(Fulfillment::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..f6c5e11d --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,35 @@ + InventoryPolicy::class]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function availableQuantity(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } + + public function canSell(int $quantity): bool + { + return $this->policy === InventoryPolicy::Continue || $this->availableQuantity() >= $quantity; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..c2014d74 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,21 @@ +belongsTo(NavigationMenu::class, 'navigation_menu_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..4b072ff9 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,19 @@ +hasMany(NavigationItem::class)->orderBy('position'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..783dc555 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,53 @@ + OrderStatus::class, 'financial_status' => FinancialStatus::class, 'fulfillment_status' => FulfillmentStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'placed_at' => 'datetime', 'metadata' => 'array']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..20aa2ff0 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,31 @@ + 'array', 'discount_allocations_json' => 'array']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..86ae5d5d --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,27 @@ + PageStatus::class, 'published_at' => 'datetime']; + } + + protected static function booted(): void + { + static::saving(function (Page $page): void { + $page->content = app(HtmlSanitizer::class)->sanitize($page->content); + }); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..489f2eb1 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,25 @@ + PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..ea159b2f --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,57 @@ + ProductStatus::class, 'tags' => 'array', 'metadata' => 'array', 'published_at' => 'datetime']; + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products')->withPivot('position'); + } + + public function orders(): BelongsToMany + { + return $this->belongsToMany(Order::class, 'order_lines'); + } + + public function scopePublished($query): void + { + $query->where('status', ProductStatus::Active)->whereNotNull('published_at')->where('published_at', '<=', now()); + } + + public function defaultVariant(): ?ProductVariant + { + return $this->variants->firstWhere('is_default', true) ?? $this->variants->first(); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..bab6fcb8 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,21 @@ + 'array']; + } + + 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..8733f007 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,22 @@ +belongsTo(Product::class); + } + + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..9ffba709 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,22 @@ +belongsTo(ProductOption::class, 'product_option_id'); + } + + public function variants(): BelongsToMany + { + return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..7d1873c5 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,46 @@ + 'boolean', 'is_default' => 'boolean', 'metadata' => 'array', 'status' => VariantStatus::class]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function inventory(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } + + public function orders(): BelongsToMany + { + return $this->belongsToMany(Order::class, 'order_lines', 'variant_id', 'order_id'); + } + + public function availableQuantity(): int + { + $inventory = $this->inventory; + + return $inventory ? $inventory->availableQuantity() : 0; + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..cec2cf3d --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,26 @@ + 'boolean']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..f14e1530 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,13 @@ + 'array', 'stopwords' => 'array', 'enabled' => 'boolean']; + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..ed2e6750 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,21 @@ + 'array', 'is_active' => 'boolean']; + } + + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'shipping_zone_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..1622d81f --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,31 @@ + 'array', 'regions_json' => 'array']; + } + + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class); + } + + public function matchesCountry(string $countryCode): bool + { + $countries = $this->countries_json ?? []; + + return $countries === [] || in_array(strtoupper($countryCode), array_map('strtoupper', $countries), true); + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..9470454d --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,28 @@ + 'array', 'provider_config_json' => 'array', 'config_json' => 'array', 'prices_include_tax' => 'boolean']; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..12f0daf4 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,30 @@ + ThemeStatus::class, 'settings' => 'array']; + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settingsRows(): HasMany + { + return $this->hasMany(ThemeSetting::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..710c3504 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,16 @@ +belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSetting.php b/app/Models/ThemeSetting.php new file mode 100644 index 00000000..637b24f4 --- /dev/null +++ b/app/Models/ThemeSetting.php @@ -0,0 +1,21 @@ + 'array']; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..efe657b9 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,21 @@ + 'array', 'delivered_at' => 'datetime', 'next_attempt_at' => 'datetime']; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'webhook_subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..50b53c56 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,26 @@ + 'encrypted']; + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..725082f3 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,24 @@ +syncProduct($product); + } + + public function updated(Product $product): void + { + app(SearchService::class)->syncProduct($product); + } + + public function deleted(Product $product): void + { + app(SearchService::class)->removeProduct($product->getKey()); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php index a4bcc417..521cf7f7 100644 --- a/app/Policies/FulfillmentPolicy.php +++ b/app/Policies/FulfillmentPolicy.php @@ -19,6 +19,6 @@ public function create(User $user, Order $order): bool public function view(User $user, Fulfillment $fulfillment): bool { - return $this->userHasModelStoreRole($user, $fulfillment, StoreUserRole::cases()); + return $this->userHasModelStoreRole($user, $fulfillment->loadMissing('order')->order, StoreUserRole::cases()); } } diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php index aa861f35..e54476bb 100644 --- a/app/Policies/RefundPolicy.php +++ b/app/Policies/RefundPolicy.php @@ -19,6 +19,6 @@ public function create(User $user, Order $order): bool public function view(User $user, Refund $refund): bool { - return $this->userHasModelStoreRole($user, $refund, StoreUserRole::cases()); + return $this->userHasModelStoreRole($user, $refund->loadMissing('order')->order, StoreUserRole::cases()); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..9f79d2ae 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,24 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider as PaymentProviderContract; +use App\Models\Product; +use App\Observers\ProductObserver; +use App\Services\MockPaymentProvider; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; +use Livewire\Mechanisms\HandleRequests\EndpointResolver; class AppServiceProvider extends ServiceProvider { @@ -15,7 +28,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class); + Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model'])); } /** @@ -24,6 +38,17 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + Model::preventLazyLoading(! app()->isProduction()); + Product::observe(ProductObserver::class); + Livewire::setUpdateRoute(function ($handle): mixed { + return Route::post(EndpointResolver::updatePath(), $handle) + ->middleware(['web', 'store.resolve']) + ->name('shop.livewire.update'); + }); + RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip())); + RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip())); + RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip())); + RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip())); } /** diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..b43e7742 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,35 @@ + */ + private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']; + + public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null): AnalyticsEvent + { + if (! in_array($type, $this->eventTypes, true)) { + throw new \InvalidArgumentException('Unsupported analytics event type.'); + } + + if ($clientEventId !== null) { + $existing = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->where('client_event_id', $clientEventId)->first(); + + if ($existing !== null) { + return $existing; + } + } + + return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties]); + } + + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->getKey())->whereBetween('date', [$startDate, $endDate])->orderBy('date')->get(); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..98ece4e2 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,160 @@ +create(['store_id' => $store->getKey(), 'customer_id' => $customer?->getKey(), 'currency' => $store->default_currency, 'cart_version' => 1, 'status' => 'active']); + } + + public function addLine(Cart $cart, int $variantId, int $quantity): CartLine + { + if ($quantity < 1 || $quantity > 9999) { + throw new \InvalidArgumentException('Quantity must be between 1 and 9999.'); + } + + return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine { + $cart = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($cart->getKey()); + $variant = ProductVariant::with(['product', 'inventory'])->findOrFail($variantId); + + if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) { + abort(404); + } + + $line = $cart->lines->firstWhere('variant_id', $variant->getKey()); + $newQuantity = ($line?->quantity ?? 0) + $quantity; + + if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) { + throw new InsufficientInventoryException; + } + + $line ??= new CartLine(['cart_id' => $cart->getKey(), 'variant_id' => $variant->getKey()]); + $line->fill([ + 'quantity' => $newQuantity, + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $variant->price_amount * $newQuantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $variant->price_amount * $newQuantity, + ])->save(); + $cart->increment('cart_version'); + + return $line->load('variant.product'); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine + { + if ($quantity < 1 || $quantity > 9999) { + throw new \InvalidArgumentException('Quantity must be between 1 and 9999.'); + } + + return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine { + $line = $cart->lines()->with(['variant.inventory'])->findOrFail($lineId); + + if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) { + throw new InsufficientInventoryException; + } + + $line->update(['quantity' => $quantity, 'line_subtotal_amount' => $line->unit_price_amount * $quantity, 'line_total_amount' => $line->unit_price_amount * $quantity]); + $cart->increment('cart_version'); + + return $line->refresh(); + }); + } + + public function removeLine(Cart $cart, int $lineId): void + { + DB::transaction(function () use ($cart, $lineId): void { + $cart->lines()->findOrFail($lineId)->delete(); + $cart->increment('cart_version'); + }); + } + + public function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) { + throw new CartVersionConflictException; + } + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $key = 'cart_id_'.$store->getKey(); + $cart = $customer?->carts()->where('status', 'active')->latest()->first(); + + if ($cart === null) { + $sessionCartId = session($key, session('cart_id')); + $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first(); + } + + $cart ??= $this->create($store, $customer); + session([$key => $cart->getKey(), 'cart_id' => $cart->getKey()]); + + return $cart->load(['lines.variant.product', 'lines.variant.inventory']); + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + if ($guest->store_id !== $customer->store_id) { + throw new \InvalidArgumentException('Carts must belong to the same store.'); + } + + DB::transaction(function () use ($guest, $customer): void { + $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey()); + $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey()); + $customerLines = $customer->lines->keyBy('variant_id'); + + foreach ($guest->lines as $guestLine) { + $variant = $guestLine->variant; + + if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) { + continue; + } + + $customerLine = $customerLines->get($guestLine->variant_id); + $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity); + + if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) { + continue; + } + + $attributes = [ + 'quantity' => $quantity, + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $variant->price_amount * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $variant->price_amount * $quantity, + ]; + + if ($customerLine === null) { + $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]); + $customerLines->put($variant->getKey(), $customerLine); + } else { + $customerLine->update($attributes); + } + + $customer->increment('cart_version'); + } + + $guest->update(['status' => 'abandoned']); + session()->forget('cart_id_'.$guest->store_id); + }); + + return $customer->refresh()->load('lines'); + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..6630c6aa --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,127 @@ +load('lines'); + + if ($cart->lines->isEmpty()) { + throw new \InvalidArgumentException('Cannot checkout an empty cart.'); + } + + Validator::make(['email' => $email], ['email' => ['required', 'email']])->validate(); + + return Checkout::create(['store_id' => $cart->store_id, 'cart_id' => $cart->getKey(), 'customer_id' => $customer?->getKey(), 'status' => CheckoutStatus::Started, 'email' => $email, 'discount_code' => $cart->discount_code, 'expires_at' => now()->addHours(24)]); + } + + public function setAddress(Checkout $checkout, array $address, ?array $billing = null, bool $useShippingAsBilling = true): Checkout + { + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + throw new \LogicException('This checkout can no longer be changed.'); + } + + Validator::make($address, ['first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'address1' => ['required', 'string', 'max:500'], 'city' => ['required', 'string', 'max:255'], 'country_code' => ['required', 'string', 'size:2'], 'postal_code' => ['required', 'string', 'max:20']])->validate(); + $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]); + $this->pricing->calculate($checkout->refresh()); + + return $checkout->refresh(); + } + + public function setShippingMethod(Checkout $checkout, int $rateId): Checkout + { + $checkout->loadMissing('cart.lines.variant'); + + if (! $this->requiresShipping($checkout)) { + $checkout->update(['shipping_rate_id' => null, 'shipping_method_id' => null, 'status' => CheckoutStatus::ShippingSelected]); + $this->pricing->calculate($checkout->refresh()); + + return $checkout->refresh(); + } + + if ($checkout->shipping_address_json === null) { + throw new \LogicException('An address is required before selecting shipping.'); + } + + $rate = $checkout->shipping_address_json === null ? null : $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json)->firstWhere('id', $rateId); + + if ($rate === null) { + throw new \InvalidArgumentException('The selected shipping method is unavailable.'); + } + + $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]); + $this->pricing->calculate($checkout->refresh()); + + return $checkout->refresh(); + } + + public function selectPaymentMethod(Checkout $checkout, string $method): Checkout + { + Validator::make(['method' => $method], ['method' => ['required', 'in:credit_card,paypal,bank_transfer']])->validate(); + + $checkout->loadMissing('cart.lines.variant'); + + if ($checkout->shipping_address_json === null) { + throw new \LogicException('An address is required before selecting payment.'); + } + + if ($this->requiresShipping($checkout) && $checkout->shipping_rate_id === null) { + throw new \LogicException('A shipping method is required before selecting payment.'); + } + + if ($checkout->status === CheckoutStatus::PaymentSelected) { + return $checkout->refresh(); + } + + $checkout->load('cart.lines.variant.inventory'); + + DB::transaction(function () use ($checkout, $method): void { + foreach ($checkout->cart->lines as $line) { + if ($line->variant->inventory !== null) { + $this->inventory->reserve($line->variant->inventory, $line->quantity); + } + } + + $checkout->update(['payment_method' => $method, 'status' => CheckoutStatus::PaymentSelected, 'expires_at' => now()->addHours(24)]); + }); + + return $checkout->refresh(); + } + + public function expireCheckout(Checkout $checkout): void + { + if ($checkout->status === CheckoutStatus::Expired || $checkout->status === CheckoutStatus::Completed) { + return; + } + + DB::transaction(function () use ($checkout): void { + if ($checkout->status === CheckoutStatus::PaymentSelected) { + $checkout->load('cart.lines.variant.inventory'); + + foreach ($checkout->cart->lines as $line) { + if ($line->variant->inventory !== null) { + $this->inventory->release($line->variant->inventory, $line->quantity); + } + } + } + + $checkout->update(['status' => CheckoutStatus::Expired]); + }); + } + + private function requiresShipping(Checkout $checkout): bool + { + return $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant->requires_shipping); + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..9df01e6e --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,103 @@ +where('store_id', $store->getKey())->whereRaw('lower(code) = ?', [strtolower($code)])->first(); + + if ($discount === null) { + throw new InvalidDiscountException('This discount code does not exist.', 'discount_not_found'); + } + + if ($discount->status !== 'active' || ($discount->ends_at !== null && $discount->ends_at->isPast())) { + throw new InvalidDiscountException('This discount code has expired.', 'discount_expired'); + } + + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + throw new InvalidDiscountException('This discount code is not active yet.', 'discount_not_yet_active'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('This discount code has reached its usage limit.', 'discount_usage_limit_reached'); + } + + $cart->loadMissing('lines.variant.product.collections'); + $minimum = (int) ($discount->rules_json['min_purchase_amount'] ?? $discount->rules_json['minimum_purchase_amount'] ?? 0); + + if ((int) $cart->lines->sum('line_subtotal_amount') < $minimum) { + throw new InvalidDiscountException('This discount requires a higher subtotal.', 'discount_min_purchase_not_met'); + } + + if ($this->qualifyingLines($discount, $cart->lines)->isEmpty()) { + throw new InvalidDiscountException('This discount does not apply to the items in your cart.', 'discount_not_applicable'); + } + + return $discount; + } + + /** @param array, quantity?: int}> $lines */ + public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult + { + $qualifyingLines = array_values(array_filter($lines, fn (array $line): bool => $this->lineQualifies($discount, $line))); + $eligibleSubtotal = array_sum(array_map(fn (array $line): int => (int) ($line['amount'] ?? 0), $qualifyingLines)); + $amount = match ($discount->value_type) { + DiscountValueType::Percent => intdiv($eligibleSubtotal * $discount->value_amount, 100), + DiscountValueType::Fixed => min($discount->value_amount, $eligibleSubtotal), + DiscountValueType::FreeShipping => 0, + }; + $allocations = []; + + if ($amount > 0 && $eligibleSubtotal > 0) { + $lastIndex = count($qualifyingLines) - 1; + $allocated = 0; + + foreach ($qualifyingLines as $index => $line) { + $lineAmount = (int) ($line['amount'] ?? 0); + $allocation = $index === $lastIndex + ? $amount - $allocated + : min($amount - $allocated, intdiv((2 * $amount * $lineAmount) + $eligibleSubtotal, 2 * $eligibleSubtotal)); + $allocations[(int) ($line['line_id'] ?? 0)] = $allocation; + $allocated += $allocation; + } + } + + return new DiscountResult($amount, $allocations, $discount->value_type === DiscountValueType::FreeShipping); + } + + /** @param \Illuminate\Support\Collection $lines */ + private function qualifyingLines(Discount $discount, Collection $lines): Collection + { + return $lines->filter(function ($line) use ($discount): bool { + return $this->lineQualifies($discount, [ + 'product_id' => $line->variant?->product_id, + 'collection_ids' => $line->variant?->product?->collections?->modelKeys() ?? [], + ]); + }); + } + + /** @param array{product_id?: int|null, collection_ids?: array} $line */ + private function lineQualifies(Discount $discount, array $line): bool + { + $rules = $discount->rules_json ?? []; + $productIds = array_map('intval', array_filter($rules['applicable_product_ids'] ?? [])); + $collectionIds = array_map('intval', array_filter($rules['applicable_collection_ids'] ?? [])); + + if ($productIds === [] && $collectionIds === []) { + return true; + } + + return in_array((int) ($line['product_id'] ?? 0), $productIds, true) + || array_intersect($collectionIds, array_map('intval', $line['collection_ids'] ?? [])) !== []; + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..968ca604 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,96 @@ +financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new FulfillmentGuardException; + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $order->load(['lines', 'fulfillments.lines']); + $fulfilledQuantities = $order->fulfillments->flatMap->lines->groupBy('order_line_id')->map(fn ($items): int => (int) $items->sum('quantity')); + $requestedQuantities = []; + + foreach ($lines as $line) { + $orderLine = $order->lines->firstWhere('id', $line['order_line_id']); + $quantity = (int) ($line['quantity'] ?? 0); + $requestedQuantities[$line['order_line_id']] = ($requestedQuantities[$line['order_line_id']] ?? 0) + $quantity; + + if ($orderLine === null || $quantity < 1 || $orderLine->quantity < $requestedQuantities[$line['order_line_id']] + (int) ($fulfilledQuantities[$orderLine->id] ?? 0)) { + throw new \InvalidArgumentException('The fulfillment quantity exceeds the unfulfilled quantity.'); + } + } + + $fulfillment = $order->fulfillments()->create(array_merge($tracking ?? [], ['status' => 'pending'])); + + foreach ($lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line['order_line_id'], 'quantity' => $line['quantity']]); + } + + $this->refreshOrderStatus($order->refresh()); + + return $fulfillment->load('lines.orderLine'); + }); + } + + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + if ($fulfillment->status !== 'pending') { + throw new \LogicException('Only pending fulfillments can be shipped.'); + } + + $fulfillment->update(array_merge($tracking ?? [], ['status' => 'shipped', 'shipped_at' => now(), 'fulfilled_at' => now()])); + FulfillmentShipped::dispatch($fulfillment->refresh()); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + if ($fulfillment->status !== 'shipped') { + throw new \LogicException('Only shipped fulfillments can be delivered.'); + } + + $fulfillment->update(['status' => 'delivered', 'delivered_at' => now()]); + $fulfillment->load('order'); + FulfillmentDelivered::dispatch($fulfillment); + + if ($fulfillment->order !== null) { + $fulfillment->order->update(['status' => 'fulfilled']); + } + } + + private function refreshOrderStatus(Order $order): void + { + $order->load(['lines', 'fulfillments.lines']); + $fulfilledQuantities = []; + + foreach ($order->fulfillments as $fulfillment) { + foreach ($fulfillment->lines as $line) { + $fulfilledQuantities[$line->order_line_id] = ($fulfilledQuantities[$line->order_line_id] ?? 0) + $line->quantity; + } + } + + $fulfilled = collect($order->lines)->every(fn ($line): bool => ($fulfilledQuantities[$line->id] ?? 0) >= $line->quantity); + $partial = $fulfilledQuantities !== []; + $order->update(['fulfillment_status' => $fulfilled ? FulfillmentStatus::Fulfilled : ($partial ? FulfillmentStatus::Partial : FulfillmentStatus::Unfulfilled)]); + + if ($fulfilled) { + $order->update(['status' => OrderStatus::Fulfilled]); + OrderFulfilled::dispatch($order->refresh()); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..2a7b2d81 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,70 @@ +canSell($quantity); + } + + public function reserve(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey()); + + if (! $lockedItem->canSell($quantity)) { + throw new InsufficientInventoryException; + } + + $lockedItem->increment('quantity_reserved', $quantity); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey()); + $lockedItem->update(['quantity_reserved' => max(0, $lockedItem->quantity_reserved - $quantity)]); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey()); + $lockedItem->update([ + 'quantity_on_hand' => $lockedItem->quantity_on_hand - $quantity, + 'quantity_reserved' => max(0, $lockedItem->quantity_reserved - $quantity), + ]); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + $this->assertPositiveQuantity($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey()); + $lockedItem->increment('quantity_on_hand', $quantity); + }); + } + + private function assertPositiveQuantity(int $quantity): void + { + if ($quantity < 1) { + throw new \InvalidArgumentException('Inventory quantity must be positive.'); + } + } +} diff --git a/app/Services/MockPaymentProvider.php b/app/Services/MockPaymentProvider.php new file mode 100644 index 00000000..d578dcb2 --- /dev/null +++ b/app/Services/MockPaymentProvider.php @@ -0,0 +1,39 @@ + new PaymentResult(PaymentStatus::Failed, 'mock_'.Str::lower(Str::random(16)), 'Your card was declined.'), + '4000000000009995' => new PaymentResult(PaymentStatus::Failed, 'mock_'.Str::lower(Str::random(16)), 'Your card has insufficient funds.'), + default => new PaymentResult(PaymentStatus::Captured, 'mock_'.Str::lower(Str::random(16)), 'Payment captured.'), + }; + } + + return new PaymentResult(PaymentStatus::Captured, 'mock_'.Str::lower(Str::random(16)), 'Payment captured.'); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult(true, 'mock_refund_'.Str::lower(Str::random(16)), 'Refund issued.'); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..c0394016 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,148 @@ +load(['cart.lines.variant.product', 'cart.lines.variant.inventory', 'shippingRate']); + $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first(); + + if ($existing !== null) { + return $existing->load(['lines', 'payments']); + } + + $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency]; + $discount = $checkout->discount_code === null ? null : Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first(); + $status = $paymentResult?->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending; + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'checkout_id' => $checkout->getKey(), + 'order_number' => $this->generateOrderNumber(Store::findOrFail($checkout->store_id)), + 'currency' => $totals['currency'], + 'status' => $status === FinancialStatus::Paid ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $status, + 'fulfillment_status' => 'unfulfilled', + 'payment_method' => $checkout->payment_method, + 'email' => $checkout->email, + 'shipping_address_json' => $checkout->shipping_address_json, + 'billing_address_json' => $checkout->billing_address_json, + 'subtotal_amount' => $totals['subtotal'], + 'discount_amount' => $totals['discount'] ?? 0, + 'shipping_amount' => $totals['shipping'] ?? 0, + 'tax_amount' => $totals['tax'] ?? 0, + 'total_amount' => $totals['total'], + 'placed_at' => now(), + ]); + + foreach ($checkout->cart->lines as $line) { + $order->lines()->create([ + 'product_id' => $line->variant->product_id, + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant->product->title, + 'title_snapshot' => $line->variant->product->title.' · '.$line->variant->title, + 'variant_title' => $line->variant->title, + 'sku' => $line->variant->sku, + 'sku_snapshot' => $line->variant->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_subtotal_amount' => $line->line_subtotal_amount, + 'line_discount_amount' => $line->line_discount_amount, + 'line_total_amount' => $line->line_total_amount, + 'tax_lines_json' => $totals['tax_lines'] ?? [], + 'discount_allocations_json' => $discount === null || $line->line_discount_amount < 1 ? [] : [['discount_id' => $discount->getKey(), 'amount' => $line->line_discount_amount]], + ]); + + if ($paymentResult?->status === PaymentStatus::Captured && $line->variant->inventory !== null) { + $this->inventory->commit($line->variant->inventory, $line->quantity); + } + } + + $checkout->update(['status' => 'completed']); + $checkout->cart->update(['status' => 'converted']); + OrderCreated::dispatch($order); + $order->load(['lines', 'payments']); + + if ($status === FinancialStatus::Paid) { + OrderPaid::dispatch($order); + + if ($checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) { + $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]); + } + + $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]); + } + } + + return $order; + }); + } + + public function generateOrderNumber(Store $store): string + { + $lastNumber = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->selectRaw("max(cast(replace(order_number, '#', '') as integer)) as value")->value('value'); + + return '#'.(max(1000, (int) $lastNumber) + 1); + } + + public function cancel(Order $order, string $reason): void + { + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw new \LogicException('Fulfilled orders cannot be cancelled.'); + } + + DB::transaction(function () use ($order, $reason): void { + $order->load('lines.variant.inventory')->update(['status' => OrderStatus::Cancelled, 'metadata' => array_merge($order->metadata ?? [], ['cancellation_reason' => $reason])]); + + foreach ($order->lines as $line) { + if ($line->variant?->inventory !== null && $order->financial_status === FinancialStatus::Pending) { + $this->inventory->release($line->variant->inventory, $line->quantity); + } + } + + OrderCancelled::dispatch($order->refresh()); + }); + } + + public function confirmPayment(Order $order): void + { + if ($order->payment_method !== 'bank_transfer' || $order->financial_status !== FinancialStatus::Pending) { + throw new \LogicException('Only pending bank transfer orders can be confirmed.'); + } + + DB::transaction(function () use ($order): void { + $order->load('lines.variant.inventory')->update(['financial_status' => FinancialStatus::Paid, 'status' => OrderStatus::Paid]); + + foreach ($order->lines as $line) { + if ($line->variant?->inventory !== null) { + $this->inventory->commit($line->variant->inventory, $line->quantity); + } + } + + $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]); + OrderPaid::dispatch($order->refresh()); + }); + } +} diff --git a/app/Services/PaymentProvider.php b/app/Services/PaymentProvider.php new file mode 100644 index 00000000..548859ca --- /dev/null +++ b/app/Services/PaymentProvider.php @@ -0,0 +1,5 @@ +where('checkout_id', $checkout->getKey())->first(); + + if ($existing !== null) { + return $existing->load(['lines', 'payments']); + } + + if ($checkout->status !== CheckoutStatus::PaymentSelected) { + throw new \LogicException('Checkout must have a selected payment method before payment.'); + } + + $checkout->update(['payment_method' => $method]); + $this->pricing->calculate($checkout->refresh()); + $checkout->load('cart.lines.variant.inventory'); + + $result = $this->provider->charge($checkout, $method, $details); + + if ($result->status === PaymentStatus::Failed) { + foreach ($checkout->cart->lines as $line) { + if ($line->variant->inventory !== null) { + $this->inventory->release($line->variant->inventory, $line->quantity); + } + } + + $checkout->update(['status' => CheckoutStatus::ShippingSelected]); + + return null; + } + + $order = $this->orders->createFromCheckout($checkout, $result); + Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]); + + if ($checkout->discount_code !== null) { + Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->where('code', $checkout->discount_code)->increment('usage_count'); + } + + return $order->refresh()->load(['lines', 'payments']); + }); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..446e151b --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,69 @@ +load(['cart.lines.variant.product.collections', 'shippingRate']); + $cart = $checkout->cart; + $lines = $cart->lines; + $subtotal = (int) $lines->sum(fn ($line): int => $line->unit_price_amount * $line->quantity); + $discountAmount = 0; + $freeShipping = false; + + foreach ($lines as $line) { + $line->updateQuietly(['line_discount_amount' => 0, 'line_total_amount' => $line->line_subtotal_amount]); + } + + if ($checkout->discount_code !== null) { + $discount = Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first(); + + if ($discount?->isAvailable()) { + $result = $this->discounts->calculate($discount, $subtotal, $lines->map(fn ($line): array => [ + 'line_id' => $line->id, + 'amount' => $line->unit_price_amount * $line->quantity, + 'product_id' => $line->variant->product_id, + 'collection_ids' => $line->variant->product->collections->modelKeys(), + ])->all()); + $discountAmount = $result->amount; + $freeShipping = $result->freeShipping; + + foreach ($lines as $line) { + $lineDiscount = $result->allocations[$line->id] ?? 0; + $line->updateQuietly(['line_discount_amount' => $lineDiscount, 'line_total_amount' => max(0, $line->line_subtotal_amount - $lineDiscount)]); + } + } + } + + $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0; + $taxSettings = TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $checkout->store_id], ['default_rate_basis_points' => 0]); + $taxLines = []; + + foreach ($lines as $line) { + $lineAmount = max(0, $line->line_subtotal_amount - $line->line_discount_amount); + $taxLines = [...$taxLines, ...$this->taxes->calculate($lineAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines]; + } + + if ($shippingAmount > 0) { + $taxLines = [...$taxLines, ...$this->taxes->calculate($shippingAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines]; + } + + $taxTotal = (int) collect($taxLines)->sum('amount'); + $discountedSubtotal = max(0, $subtotal - $discountAmount); + $pricesIncludeTax = (bool) $taxSettings->prices_include_tax || $taxSettings->mode === 'inclusive'; + $total = max(0, $discountedSubtotal + $shippingAmount + ($pricesIncludeTax ? 0 : $taxTotal)); + $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency); + $checkout->update(['totals_json' => $result->toArray()]); + + return $result; + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..977fb3fa --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,104 @@ +create([ + 'store_id' => $store->getKey(), + 'title' => $data['title'], + 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()), + 'description' => $this->sanitizer->sanitize($data['description'] ?? null), + 'description_html' => $this->sanitizer->sanitize($data['description_html'] ?? $data['description'] ?? null), + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + 'status' => $status, + 'published_at' => $status === ProductStatus::Active ? now() : null, + ]); + + foreach ($data['variants'] ?? [['title' => 'Default', 'price_amount' => 0, 'is_default' => true]] as $position => $variant) { + $product->variants()->create(array_merge($variant, ['position' => $position, 'is_default' => $variant['is_default'] ?? $position === 0])); + } + + if ($status === ProductStatus::Active && (trim((string) $product->title) === '' || ! $product->variants()->where('price_amount', '>', 0)->exists())) { + throw new InvalidProductTransitionException('An active product requires a title and a priced variant.'); + } + + return $product->load('variants'); + }); + } + + public function update(Product $product, array $data): Product + { + $updates = array_intersect_key($data, array_flip(['title', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at'])); + + $newStatus = null; + + if (array_key_exists('status', $updates)) { + $newStatus = $updates['status'] instanceof ProductStatus ? $updates['status'] : ProductStatus::from($updates['status']); + unset($updates['status']); + } + + if (array_key_exists('description', $data)) { + $updates['description'] = $this->sanitizer->sanitize($data['description']); + $updates['description_html'] = $this->sanitizer->sanitize($data['description']); + } elseif (array_key_exists('description_html', $data)) { + $updates['description_html'] = $this->sanitizer->sanitize($data['description_html']); + } + + $product->update($updates); + + if ($newStatus !== null) { + $this->transitionStatus($product->refresh(), $newStatus); + } + + return $product->refresh()->load('variants'); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $from = $product->status instanceof ProductStatus ? $product->status : ProductStatus::from($product->status); + + if ($from === $newStatus) { + return; + } + + if ($newStatus === ProductStatus::Active && (! $product->variants()->where('price_amount', '>', 0)->exists() || trim((string) $product->title) === '')) { + throw new InvalidProductTransitionException('An active product requires a title and a priced variant.'); + } + + if ($newStatus === ProductStatus::Draft && in_array($from, [ProductStatus::Active, ProductStatus::Archived], true) && $product->orders()->exists()) { + throw new InvalidProductTransitionException('Products with order history cannot be reverted to draft.'); + } + + $product->update(['status' => $newStatus, 'published_at' => $newStatus === ProductStatus::Active ? ($product->published_at ?? now()) : null]); + ProductStatusChanged::dispatch($product->refresh(), $from, $newStatus); + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft || $product->orders()->exists()) { + throw new LogicException('Only draft products with no order history can be deleted.'); + } + + $product->delete(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..563647c7 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,85 @@ +|null $amount + * @param array $lines + */ + public function create(Order $order, Payment $payment, int|array|null $amount = null, ?string $reason = null, bool $restock = false, array $lines = []): Refund + { + if (is_array($amount)) { + $lines = $amount; + $amount = null; + } + + $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); + $order->loadMissing('lines'); + $restockLines = $lines; + + if ($lines !== []) { + $amount = 0; + + foreach ($lines as $lineId => $quantity) { + $orderLine = $order->lines->firstWhere('id', (int) $lineId); + + if ($orderLine === null || $quantity < 1 || $quantity > $orderLine->quantity) { + throw new \InvalidArgumentException('The refund quantity is invalid.'); + } + + $unitAmount = intdiv($orderLine->line_total_amount, $orderLine->quantity); + $amount += $unitAmount * $quantity; + } + } + + $amount ??= $payment->amount - $refunded; + + if ($amount < 1 || $refunded + $amount > $payment->amount) { + throw new \InvalidArgumentException('Refund amount exceeds the captured payment.'); + } + + if ($restock && $restockLines === [] && $refunded + $amount >= $payment->amount) { + $restockLines = $order->lines->mapWithKeys(fn ($line): array => [$line->getKey() => $line->quantity])->all(); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $restockLines): Refund { + $result = $this->provider->refund($payment, $amount); + $refund = $order->refunds()->create(['payment_id' => $payment->getKey(), 'amount' => $amount, 'status' => $result->successful ? 'processed' : 'failed', 'provider_refund_id' => $result->reference, 'reason' => $reason, 'restock' => $restock]); + + if ($result->successful) { + $totalRefunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); + $order->update([ + 'financial_status' => $totalRefunded >= $payment->amount ? FinancialStatus::Refunded : FinancialStatus::PartiallyRefunded, + 'status' => $totalRefunded >= $payment->amount ? 'refunded' : $order->status, + ]); + + if ($restock && $restockLines !== []) { + $order->load('lines.variant.inventory'); + foreach ($restockLines as $lineId => $quantity) { + $line = $order->lines->firstWhere('id', (int) $lineId); + + if ($line->variant?->inventory !== null) { + $this->inventory->restock($line->variant->inventory, (int) $quantity); + } + } + } + + OrderRefunded::dispatch($order->refresh()); + } + + return $refund; + }); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..ada22c1a --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,82 @@ +published() + ->where('store_id', $store->getKey()) + ->when(trim($query) !== '', function (Builder $builder) use ($query): void { + $tokens = preg_split('/[^\pL\pN]+/u', trim($query), -1, PREG_SPLIT_NO_EMPTY); + $match = collect($tokens ?: [])->map(fn (string $token): string => '"'.str_replace('"', '""', $token).'*"')->implode(' '); + + if ($match !== '' && Schema::hasTable('products_fts')) { + $builder->whereIn('id', DB::table('products_fts')->select('product_id')->whereColumn('products_fts.store_id', 'products.store_id')->whereRaw('products_fts MATCH ?', [$match])); + } + }) + ->when($filters['vendor'] ?? null, fn (Builder $builder, string $vendor): Builder => $builder->where('vendor', $vendor)) + ->when(isset($filters['min_price']), fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->where('price_amount', '>=', (int) $filters['min_price']))) + ->when(isset($filters['max_price']), fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->where('price_amount', '<=', (int) $filters['max_price']))) + ->with(['variants.inventory', 'media']) + ->latest('published_at') + ->paginate($perPage); + + if (Schema::hasTable('search_queries')) { + SearchQuery::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'query' => $query, 'results_count' => $products->total(), 'customer_id' => auth('customer')->id()]); + } + + return $products; + } + + public function autocomplete(Store $store, string $prefix, int $limit = 8): Collection + { + if (mb_strlen(trim($prefix)) < 2) { + return collect(); + } + + return Product::withoutGlobalScopes() + ->published() + ->where('store_id', $store->getKey()) + ->where('title', 'like', trim($prefix).'%') + ->orderBy('title') + ->limit($limit) + ->get(['id', 'title', 'handle']); + } + + public function syncProduct(Product $product): void + { + if (! Schema::hasTable('products_fts')) { + return; + } + + $this->removeProduct($product->getKey()); + DB::table('products_fts')->insert([ + 'product_id' => $product->getKey(), + 'store_id' => $product->store_id, + 'title' => $product->title, + 'description' => $product->description, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'tags' => is_array($product->tags) ? implode(' ', $product->tags) : (string) $product->tags, + ]); + } + + public function removeProduct(int $productId): void + { + if (Schema::hasTable('products_fts')) { + DB::table('products_fts')->where('product_id', $productId)->delete(); + } + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..10d675c6 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,53 @@ +where('is_active', true)->whereHas('zone', function ($query) use ($store, $country, $region): void { + $query->where('store_id', $store->getKey())->where(function ($zone) use ($country, $region): void { + $zone->whereJsonContains('countries_json', $country)->orWhereNull('countries_json'); + + if ($region !== '') { + $zone->orWhereJsonContains('regions_json', $region); + } + }); + })->with('zone')->get(); + } + + public function calculate(ShippingRate $rate, Cart $cart): int + { + $lines = $cart->load('lines.variant')->lines; + $lines = $lines->filter(fn ($line): bool => (bool) $line->variant->requires_shipping); + $weight = (int) $lines->sum(fn ($line): int => $line->quantity * ($line->variant->weight_g ?? $line->variant->weight_grams)); + $subtotal = (int) $lines->sum('line_total_amount'); + $config = $rate->config_json ?? []; + + return match ($rate->type) { + 'weight' => $this->rangeAmount($config['ranges'] ?? [], $weight, $rate->price_amount), + 'price' => $this->rangeAmount($config['ranges'] ?? [], $subtotal, $rate->price_amount), + default => $rate->price_amount, + }; + } + + private function rangeAmount(array $ranges, int $value, int $fallback): int + { + foreach ($ranges as $range) { + if ($value >= (int) ($range['min_g'] ?? $range['min_amount'] ?? 0) && $value <= (int) ($range['max_g'] ?? $range['max_amount'] ?? PHP_INT_MAX)) { + return (int) ($range['amount'] ?? $fallback); + } + } + + return $fallback; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..33145379 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,29 @@ +rates_json ?? []; + $rate = (int) ($rates[strtoupper((string) ($address['country_code'] ?? ''))] ?? $settings->default_rate_basis_points); + $tax = $settings->prices_include_tax || $settings->mode === 'inclusive' ? $this->extractInclusive($amount, $rate) : $this->addExclusive($amount, $rate); + + return new TaxResult($tax, $tax > 0 ? [new TaxLine('Sales tax', $rate, $tax)] : []); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + return $rateBasisPoints > 0 ? intdiv($grossAmount * $rateBasisPoints, 10000 + $rateBasisPoints) : 0; + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return intdiv($netAmount * $rateBasisPoints, 10000); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..1b80fd95 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,64 @@ +load(['options.values', 'variants.optionValues']); + $groups = $product->options->map(fn ($option): array => $option->values->all())->filter()->values()->all(); + + if ($groups === []) { + if ($product->variants->isEmpty()) { + $product->variants()->create(['title' => 'Default', 'price_amount' => 0, 'is_default' => true, 'position' => 0]); + } + + return; + } + + $combinations = $this->cartesianProduct($groups); + $existing = $product->variants->keyBy(fn (ProductVariant $variant): string => $variant->optionValues->modelKeys()->sort()->implode('-')); + $position = 0; + + foreach ($combinations as $combination) { + $key = collect($combination)->pluck('id')->sort()->implode('-'); + $variant = $existing->pull($key); + + if ($variant === null) { + $variant = $product->variants()->create([ + 'title' => collect($combination)->pluck('value')->implode(' / '), + 'price_amount' => (int) ($product->variants->first()?->price_amount ?? 0), + 'is_default' => $position === 0, + 'position' => $position, + ]); + } + + $variant->optionValues()->sync(collect($combination)->pluck('id')->all()); + $position++; + } + + foreach ($existing as $orphan) { + if ($orphan->orders()->exists()) { + continue; + } + + $orphan->delete(); + } + } + + /** @param array> $groups */ + private function cartesianProduct(array $groups): array + { + $result = [[]]; + + foreach ($groups as $group) { + $result = collect($result)->flatMap(fn (array $prefix): array => array_map(fn ($value): array => [...$prefix, $value], $group))->all(); + } + + return $result; + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..2b1498b8 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,33 @@ +where('store_id', $store->getKey()) + ->where('event', $eventType) + ->where('status', 'active') + ->get() + ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { + $delivery = $subscription->deliveries()->create(['event' => $eventType, 'payload' => $payload, 'attempts' => 0, 'next_attempt_at' => now()]); + DeliverWebhook::dispatch($delivery); + }); + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..cdd63f51 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,22 @@ +where('store_id', $storeId)->where('handle', $handle)->when($excludeId !== null, fn ($query) => $query->where('id', '<>', $excludeId))->exists()) { + $handle = $base.'-'.$suffix++; + } + + return $handle; + } +} diff --git a/app/Support/HtmlSanitizer.php b/app/Support/HtmlSanitizer.php new file mode 100644 index 00000000..50ae63e1 --- /dev/null +++ b/app/Support/HtmlSanitizer.php @@ -0,0 +1,18 @@ +
    1. '); + $sanitized = preg_replace('/\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $sanitized) ?? $sanitized; + + return preg_replace('/(href|src)\s*=\s*(["\'])\s*javascript:[^"\']*\2/i', '$1=$2$2', $sanitized) ?? $sanitized; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..c1cb6665 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,9 @@ + $allocations */ + public function __construct(public int $amount, public array $allocations = [], public bool $freeShipping = false) {} +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..179e3a10 --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,15 @@ +status, [PaymentStatus::Captured, PaymentStatus::Pending], true); + } +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..d7bb9b62 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,14 @@ + $taxLines */ + public function __construct(public int $subtotal, public int $discount, public int $shipping, public array $taxLines, public int $taxTotal, public int $total, public string $currency) {} + + public function toArray(): array + { + return ['subtotal' => $this->subtotal, 'discount' => $this->discount, 'shipping' => $this->shipping, 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), 'tax' => $this->taxTotal, 'total' => $this->total, 'currency' => $this->currency]; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..b2784d35 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,8 @@ + $this->name, 'rate' => $this->rate, 'amount' => $this->amount]; + } +} diff --git a/app/ValueObjects/TaxResult.php b/app/ValueObjects/TaxResult.php new file mode 100644 index 00000000..6b31510a --- /dev/null +++ b/app/ValueObjects/TaxResult.php @@ -0,0 +1,9 @@ + $lines */ + public function __construct(public int $total, public array $lines = []) {} +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 2ee7c76d..b0d113b8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,11 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToPriorityList( + before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, + prepend: App\Http\Middleware\ResolveStore::class, + ); + $middleware->group('storefront', [ 'store.resolve:storefront', ]); diff --git a/config/shop.php b/config/shop.php new file mode 100644 index 00000000..48eee837 --- /dev/null +++ b/config/shop.php @@ -0,0 +1,5 @@ + (int) env('BANK_TRANSFER_EXPIRY_DAYS', 7), +]; diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..33e18712 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,16 @@ + Store::factory(), 'currency' => 'EUR', 'cart_version' => 1, 'status' => 'active']; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..f6060b4b --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,16 @@ + Store::factory(), 'title' => fake()->words(2, true), 'handle' => fake()->unique()->slug(2), 'description' => fake()->paragraph(), 'status' => 'active', 'image_url' => null]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..07f73803 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,16 @@ + Store::factory(), 'first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'email' => fake()->unique()->safeEmail(), 'password_hash' => 'password', 'status' => 'active']; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..a911b4b5 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,16 @@ + Store::factory(), 'code' => strtoupper(fake()->unique()->lexify('CODE??')), 'type' => 'code', 'value_type' => 'percent', 'value_amount' => 10, 'status' => 'active', 'usage_limit' => null, 'usage_count' => 0, 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), 'rules_json' => []]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..858fb152 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,28 @@ + Store::factory(), 'variant_id' => ProductVariant::factory(), 'quantity_on_hand' => 50, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny]; + } + + public function backorder(): static + { + return $this->state(['quantity_on_hand' => 0, 'policy' => InventoryPolicy::Continue]); + } + + public function soldOut(): static + { + return $this->state(['quantity_on_hand' => 0, 'policy' => InventoryPolicy::Deny]); + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..4aa3994f --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,16 @@ + Store::factory(), 'order_number' => '#'.fake()->unique()->numberBetween(1001, 9999), 'currency' => 'EUR', 'status' => 'processing', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', 'email' => fake()->safeEmail(), 'subtotal_amount' => 2499, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 0, 'total_amount' => 2998, 'placed_at' => now()]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..fa544576 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,22 @@ + Store::factory(), 'title' => fake()->words(3, true), 'handle' => fake()->unique()->slug(3), 'description' => fake()->paragraph(), 'vendor' => fake()->company(), 'product_type' => 'Apparel', 'tags' => ['featured'], 'status' => ProductStatus::Active, 'published_at' => now(), 'sales_count' => 0]; + } + + public function draft(): static + { + return $this->state(['status' => ProductStatus::Draft, 'published_at' => null]); + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..08f5b833 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,16 @@ + Product::factory(), 'title' => 'Default', 'sku' => strtoupper(fake()->bothify('SKU-####')), 'price_amount' => 2499, 'compare_at_amount' => null, 'cost_amount' => 1000, 'weight_grams' => 250, 'requires_shipping' => true, 'is_default' => true, 'position' => 0]; + } +} diff --git a/database/migrations/2026_08_20_000000_create_shop_schema.php b/database/migrations/2026_08_20_000000_create_shop_schema.php new file mode 100644 index 00000000..95e6622a --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_shop_schema.php @@ -0,0 +1,596 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('billing_email')->nullable()->index(); + $table->string('status')->default('active')->index(); + $table->timestamps(); + }); + + Schema::create('stores', function (Blueprint $table): void { + $table->id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique(); + $table->string('status')->default('active')->index(); + $table->string('default_currency', 3)->default('EUR'); + $table->string('default_locale', 10)->default('en'); + $table->string('timezone')->default('UTC'); + $table->string('primary_domain')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'status']); + }); + + Schema::create('store_domains', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname')->unique(); + $table->string('type')->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->timestamps(); + $table->index(['store_id', 'is_primary']); + }); + + Schema::table('users', function (Blueprint $table): void { + $table->string('status')->default('active')->index(); + $table->timestamp('last_login_at')->nullable(); + }); + + Schema::create('store_users', function (Blueprint $table): void { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('staff'); + $table->timestamps(); + $table->primary(['store_id', 'user_id']); + $table->index('user_id'); + $table->index(['store_id', 'role']); + }); + + Schema::create('store_settings', function (Blueprint $table): void { + $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('general_json')->nullable(); + $table->json('checkout_json')->nullable(); + $table->json('notification_json')->nullable(); + $table->json('social_json')->nullable(); + $table->timestamps(); + }); + + Schema::create('products', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description')->nullable(); + $table->string('vendor')->nullable()->index(); + $table->string('product_type')->nullable()->index(); + $table->json('tags')->nullable(); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable(); + $table->unsignedInteger('sales_count')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'handle']); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'published_at']); + }); + + Schema::create('product_options', function (Blueprint $table): void { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + $table->unique(['product_id', 'position']); + }); + + Schema::create('product_option_values', function (Blueprint $table): void { + $table->id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->string('value'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + $table->unique(['product_option_id', 'position']); + }); + + Schema::create('product_variants', function (Blueprint $table): void { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('title')->default('Default'); + $table->string('sku')->nullable()->index(); + $table->string('barcode')->nullable()->index(); + $table->unsignedInteger('price_amount'); + $table->unsignedInteger('compare_at_amount')->nullable(); + $table->unsignedInteger('cost_amount')->nullable(); + $table->unsignedInteger('weight_grams')->default(0); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->unsignedInteger('position')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['product_id', 'position']); + $table->index(['product_id', 'is_default']); + }); + + Schema::create('variant_option_values', function (Blueprint $table): void { + $table->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'); + }); + + Schema::create('inventory_items', function (Blueprint $table): void { + $table->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->string('policy')->default('deny'); + $table->timestamps(); + $table->index('store_id'); + }); + + Schema::create('collections', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description')->nullable(); + $table->string('status')->default('active')->index(); + $table->string('image_url')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'handle']); + $table->index(['store_id', 'status']); + }); + + Schema::create('collection_products', function (Blueprint $table): void { + $table->foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('position')->default(0); + $table->primary(['collection_id', 'product_id']); + $table->index('product_id'); + $table->index(['collection_id', 'position']); + }); + + Schema::create('product_media', function (Blueprint $table): void { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->string('url')->nullable(); + $table->string('alt_text')->nullable(); + $table->string('status')->default('ready'); + $table->unsignedInteger('position')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['product_id', 'position']); + $table->index('status'); + }); + + Schema::create('themes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('status')->default('draft')->index(); + $table->string('version')->default('1.0.0'); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->index(['store_id', 'status']); + }); + + Schema::create('theme_files', function (Blueprint $table): void { + $table->id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->longText('content')->nullable(); + $table->timestamps(); + $table->unique(['theme_id', 'path']); + }); + + Schema::create('theme_settings', function (Blueprint $table): void { + $table->id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('key'); + $table->json('value')->nullable(); + $table->timestamps(); + $table->unique(['theme_id', 'key']); + }); + + Schema::create('pages', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->longText('content')->nullable(); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'handle']); + }); + + Schema::create('navigation_menus', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle'); + $table->timestamps(); + $table->unique(['store_id', 'handle']); + }); + + Schema::create('navigation_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('navigation_menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->string('label'); + $table->string('type')->default('link'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->foreignId('parent_id')->nullable()->constrained('navigation_items')->nullOnDelete(); + $table->timestamps(); + $table->index(['navigation_menu_id', 'position']); + }); + + Schema::create('search_settings', function (Blueprint $table): void { + $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('synonyms')->nullable(); + $table->json('stopwords')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + + Schema::create('search_queries', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('query'); + $table->unsignedInteger('results_count')->default(0); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + $table->index(['store_id', 'created_at']); + $table->index(['store_id', 'query']); + }); + + Schema::create('shipping_zones', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('countries_json')->nullable(); + $table->json('regions_json')->nullable(); + $table->timestamps(); + $table->index('store_id'); + }); + + Schema::create('shipping_rates', function (Blueprint $table): void { + $table->id(); + $table->foreignId('shipping_zone_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('type')->default('flat'); + $table->unsignedInteger('price_amount')->default(0); + $table->string('currency', 3)->default('EUR'); + $table->json('config_json')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('estimated_days_min')->nullable(); + $table->unsignedInteger('estimated_days_max')->nullable(); + $table->timestamps(); + $table->index(['shipping_zone_id', 'is_active']); + }); + + Schema::create('tax_settings', function (Blueprint $table): void { + $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->string('mode')->default('manual'); + $table->unsignedInteger('default_rate_basis_points')->default(0); + $table->json('rates_json')->nullable(); + $table->json('provider_config_json')->nullable(); + $table->timestamps(); + }); + + Schema::create('discounts', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('code')->nullable(); + $table->string('type')->default('code'); + $table->string('value_type')->default('percent'); + $table->unsignedInteger('value_amount')->default(0); + $table->string('status')->default('active')->index(); + $table->unsignedInteger('usage_limit')->nullable(); + $table->unsignedInteger('usage_count')->default(0); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->json('rules_json')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'code']); + $table->index(['store_id', 'type']); + }); + + Schema::create('customers', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('status')->default('active')->index(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->rememberToken(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'email']); + $table->index('store_id'); + }); + + Schema::create('customer_addresses', function (Blueprint $table): void { + $table->id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->json('address_json'); + $table->boolean('is_default')->default(false); + $table->timestamps(); + $table->index(['customer_id', 'is_default']); + }); + + Schema::create('carts', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('currency', 3)->default('EUR'); + $table->unsignedInteger('cart_version')->default(1); + $table->string('status')->default('active')->index(); + $table->timestamps(); + $table->index('store_id'); + $table->index('customer_id'); + }); + + Schema::create('cart_lines', function (Blueprint $table): void { + $table->id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->restrictOnDelete(); + $table->unsignedInteger('quantity'); + $table->unsignedInteger('unit_price_amount'); + $table->unsignedInteger('line_subtotal_amount'); + $table->unsignedInteger('line_discount_amount')->default(0); + $table->unsignedInteger('line_total_amount'); + $table->timestamps(); + $table->unique(['cart_id', 'variant_id']); + $table->index('cart_id'); + }); + + Schema::create('checkouts', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->restrictOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status')->default('started')->index(); + $table->string('email'); + $table->string('payment_method')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->foreignId('shipping_rate_id')->nullable()->constrained('shipping_rates')->nullOnDelete(); + $table->string('discount_code')->nullable(); + $table->json('totals_json')->nullable(); + $table->json('tax_provider_snapshot_json')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + $table->index('cart_id'); + $table->index('customer_id'); + }); + + Schema::create('orders', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('checkout_id')->nullable()->constrained()->nullOnDelete(); + $table->string('order_number'); + $table->string('currency', 3)->default('EUR'); + $table->string('status')->default('pending')->index(); + $table->string('financial_status')->default('pending')->index(); + $table->string('fulfillment_status')->default('unfulfilled')->index(); + $table->string('payment_method')->nullable(); + $table->string('email'); + $table->json('shipping_address_json')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->unsignedInteger('subtotal_amount')->default(0); + $table->unsignedInteger('discount_amount')->default(0); + $table->unsignedInteger('shipping_amount')->default(0); + $table->unsignedInteger('tax_amount')->default(0); + $table->unsignedInteger('total_amount')->default(0); + $table->timestamp('placed_at')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'order_number']); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'financial_status']); + $table->index(['store_id', 'fulfillment_status']); + }); + + Schema::create('order_lines', function (Blueprint $table): void { + $table->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('product_title'); + $table->string('variant_title')->nullable(); + $table->string('sku')->nullable(); + $table->unsignedInteger('quantity'); + $table->unsignedInteger('unit_price_amount'); + $table->unsignedInteger('line_subtotal_amount'); + $table->unsignedInteger('line_discount_amount')->default(0); + $table->unsignedInteger('line_total_amount'); + $table->json('tax_lines_json')->nullable(); + $table->json('discount_allocations_json')->nullable(); + $table->timestamps(); + $table->index('order_id'); + }); + + Schema::create('payments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('provider')->default('mock'); + $table->string('provider_payment_id')->nullable(); + $table->string('method'); + $table->string('status')->default('pending')->index(); + $table->unsignedInteger('amount'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamps(); + $table->index(['provider', 'provider_payment_id']); + $table->index('order_id'); + }); + + Schema::create('refunds', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedInteger('amount'); + $table->string('status')->default('pending'); + $table->text('reason')->nullable(); + $table->boolean('restock')->default(false); + $table->timestamps(); + $table->index(['order_id', 'status']); + }); + + Schema::create('fulfillments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending')->index(); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->text('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('fulfilled_at')->nullable(); + $table->timestamps(); + $table->index(['tracking_company', 'tracking_number']); + }); + + Schema::create('fulfillment_lines', function (Blueprint $table): void { + $table->id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->timestamps(); + $table->unique(['fulfillment_id', 'order_line_id']); + }); + + Schema::create('analytics_events', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable()->index(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('client_event_id')->nullable(); + $table->json('payload')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'client_event_id']); + $table->index(['store_id', 'type']); + $table->index(['store_id', 'created_at']); + }); + + Schema::create('analytics_daily', function (Blueprint $table): void { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->unsignedInteger('orders_count')->default(0); + $table->unsignedInteger('revenue_amount')->default(0); + $table->unsignedInteger('aov_amount')->default(0); + $table->unsignedInteger('visits_count')->default(0); + $table->unsignedInteger('add_to_cart_count')->default(0); + $table->unsignedInteger('checkout_started_count')->default(0); + $table->primary(['store_id', 'date']); + }); + + Schema::create('apps', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('status')->default('active'); + $table->json('scopes')->nullable(); + $table->timestamps(); + }); + + Schema::create('app_installations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('active'); + $table->json('config')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'app_id']); + }); + + Schema::create('oauth_clients', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('client_id')->unique(); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris'); + $table->timestamps(); + }); + + Schema::create('oauth_tokens', function (Blueprint $table): void { + $table->id(); + $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('token_hash')->unique(); + $table->json('scopes')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('webhook_subscriptions', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('event'); + $table->text('target_url'); + $table->text('secret_encrypted'); + $table->string('status')->default('active'); + $table->unsignedInteger('consecutive_failures')->default(0); + $table->timestamps(); + $table->index(['store_id', 'event']); + }); + + Schema::create('webhook_deliveries', function (Blueprint $table): void { + $table->id(); + $table->foreignId('webhook_subscription_id')->constrained()->cascadeOnDelete(); + $table->string('event'); + $table->json('payload'); + $table->unsignedInteger('attempts')->default(0); + $table->unsignedSmallInteger('response_status')->nullable(); + $table->text('response_body')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('next_attempt_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + foreach ([ + 'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients', + 'app_installations', 'apps', 'analytics_daily', 'analytics_events', + 'fulfillment_lines', 'fulfillments', 'refunds', 'payments', 'order_lines', 'orders', + 'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates', + 'shipping_zones', 'search_queries', 'search_settings', 'navigation_items', + 'navigation_menus', 'pages', 'theme_settings', 'theme_files', 'themes', + 'product_media', 'collection_products', 'collections', 'inventory_items', + 'variant_option_values', 'product_variants', 'product_option_values', 'product_options', + 'products', 'customer_addresses', 'customers', 'store_settings', 'store_users', + 'store_domains', 'stores', 'organizations', + ] as $table) { + Schema::dropIfExists($table); + } + + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_08_20_220000_add_search_and_auth_support.php b/database/migrations/2026_08_20_220000_add_search_and_auth_support.php new file mode 100644 index 00000000..754e61a6 --- /dev/null +++ b/database/migrations/2026_08_20_220000_add_search_and_auth_support.php @@ -0,0 +1,38 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('customer_password_reset_tokens', function (Blueprint $table): void { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + DB::statement('CREATE VIRTUAL TABLE products_fts USING fts5(product_id UNINDEXED, store_id UNINDEXED, title, description, vendor, product_type, tags)'); + } + + public function down(): void + { + DB::statement('DROP TABLE IF EXISTS products_fts'); + Schema::dropIfExists('customer_password_reset_tokens'); + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_08_20_220001_add_webhook_delivery_status.php b/database/migrations/2026_08_20_220001_add_webhook_delivery_status.php new file mode 100644 index 00000000..87338206 --- /dev/null +++ b/database/migrations/2026_08_20_220001_add_webhook_delivery_status.php @@ -0,0 +1,22 @@ +string('status')->default('pending')->after('payload')->index(); + }); + } + + public function down(): void + { + Schema::table('webhook_deliveries', function (Blueprint $table): void { + $table->dropColumn('status'); + }); + } +}; diff --git a/database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php b/database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php new file mode 100644 index 00000000..d338cd41 --- /dev/null +++ b/database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php @@ -0,0 +1,75 @@ +longText('description_html')->nullable(); + }); + Schema::table('product_variants', function (Blueprint $table): void { + $table->string('currency', 3)->nullable(); + $table->unsignedInteger('weight_g')->nullable(); + $table->string('status')->default('active')->index(); + }); + Schema::table('product_media', function (Blueprint $table): void { + $table->string('type')->default('image'); + $table->string('storage_key')->nullable(); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('byte_size')->nullable(); + $table->string('checksum')->nullable(); + }); + Schema::table('shipping_rates', function (Blueprint $table): void { + $table->foreignId('zone_id')->nullable()->constrained('shipping_zones')->nullOnDelete(); + }); + Schema::table('tax_settings', function (Blueprint $table): void { + $table->string('provider')->default('none'); + $table->boolean('prices_include_tax')->default(false); + $table->json('config_json')->nullable(); + }); + Schema::table('checkouts', function (Blueprint $table): void { + $table->foreignId('shipping_method_id')->nullable()->constrained('shipping_rates')->nullOnDelete(); + }); + Schema::table('order_lines', function (Blueprint $table): void { + $table->string('title_snapshot')->nullable(); + $table->string('sku_snapshot')->nullable(); + }); + Schema::table('refunds', function (Blueprint $table): void { + $table->string('provider_refund_id')->nullable(); + }); + } + + public function down(): void + { + Schema::table('refunds', function (Blueprint $table): void { + $table->dropColumn('provider_refund_id'); + }); + Schema::table('order_lines', function (Blueprint $table): void { + $table->dropColumn(['title_snapshot', 'sku_snapshot']); + }); + Schema::table('checkouts', function (Blueprint $table): void { + $table->dropForeign(['shipping_method_id']); + $table->dropColumn('shipping_method_id'); + }); + Schema::table('tax_settings', function (Blueprint $table): void { + $table->dropColumn(['provider', 'prices_include_tax', 'config_json']); + }); + Schema::table('shipping_rates', function (Blueprint $table): void { + $table->dropForeign(['zone_id']); + $table->dropColumn('zone_id'); + }); + Schema::table('product_media', function (Blueprint $table): void { + $table->dropColumn(['type', 'storage_key', 'mime_type', 'byte_size', 'checksum']); + }); + Schema::table('product_variants', function (Blueprint $table): void { + $table->dropColumn(['currency', 'weight_g', 'status']); + }); + Schema::table('products', function (Blueprint $table): void { + $table->dropColumn('description_html'); + }); + } +}; diff --git a/database/migrations/2026_08_20_220003_add_discount_code_to_carts.php b/database/migrations/2026_08_20_220003_add_discount_code_to_carts.php new file mode 100644 index 00000000..887fb57b --- /dev/null +++ b/database/migrations/2026_08_20_220003_add_discount_code_to_carts.php @@ -0,0 +1,22 @@ +string('discount_code')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('carts', function (Blueprint $table): void { + $table->dropColumn('discount_code'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..9e5ec70e 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,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->call(ShopSeeder::class); } } diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..0c0493cd --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,16 @@ + 'acme-commerce'], ['name' => 'Acme Commerce', 'billing_email' => 'billing@acme.test', 'status' => 'active']); + $store = Store::firstOrCreate(['handle' => 'acme-fashion'], ['organization_id' => $organization->getKey(), 'name' => 'Acme Fashion', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'status' => 'active']); + StoreDomain::firstOrCreate(['hostname' => 'acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => true, 'tls_mode' => 'managed']); + StoreDomain::firstOrCreate(['hostname' => 'shop.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => false, 'tls_mode' => 'managed']); + StoreDomain::firstOrCreate(['hostname' => 'admin.acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Admin, 'is_primary' => true, 'tls_mode' => 'managed']); + StoreSettings::updateOrCreate(['store_id' => $store->getKey()], ['settings_json' => ['announcement' => 'Free shipping on orders over €50', 'hero_heading' => 'Everyday pieces, thoughtfully made.'], 'general_json' => ['store_name' => 'Acme Fashion']]); + $domestic = ShippingZone::updateOrCreate(['store_id' => $store->getKey(), 'name' => 'Domestic'], ['countries_json' => ['DE'], 'regions_json' => []]); + ShippingRate::updateOrCreate(['shipping_zone_id' => $domestic->getKey(), 'name' => 'Standard Shipping'], ['type' => 'flat', 'price_amount' => 499, 'currency' => 'EUR', 'is_active' => true, 'estimated_days_min' => 3, 'estimated_days_max' => 5]); + TaxSettings::updateOrCreate(['store_id' => $store->getKey()], ['mode' => 'exclusive', 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900]]); + Theme::firstOrCreate(['store_id' => $store->getKey(), 'name' => 'Acme Default'], ['status' => 'published', 'settings' => ['hero_heading' => 'Everyday pieces, thoughtfully made.']]); + Page::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'about'], ['title' => 'About', 'content' => '

      Acme Fashion makes thoughtful everyday pieces for modern wardrobes.

      ', 'status' => 'published', 'published_at' => now()]); + $mainMenu = NavigationMenu::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'main'], ['name' => 'Main menu']); + $mainMenu->items()->delete(); + NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'Collections', 'type' => 'link', 'url' => '/collections', 'position' => 1]); + NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'About', 'type' => 'link', 'url' => '/pages/about', 'position' => 2]); + + $admin = User::firstOrCreate(['email' => 'admin@acme.test'], ['name' => 'Acme Admin', 'password' => 'password', 'email_verified_at' => now(), 'status' => 'active']); + $store->users()->syncWithoutDetaching([$admin->getKey() => ['role' => StoreUserRole::Owner->value]]); + $customer = \App\Models\Customer::firstOrCreate(['store_id' => $store->getKey(), 'email' => 'customer@acme.test'], ['first_name' => 'Jamie', 'last_name' => 'Customer', 'password_hash' => 'password', 'email_verified_at' => now(), 'status' => 'active']); + + $tShirts = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 't-shirts'], ['title' => 'T-Shirts', 'description' => 'Soft, everyday essentials.', 'status' => 'active']); + $newArrivals = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 'new-arrivals'], ['title' => 'New Arrivals', 'description' => 'Fresh pieces for the season.', 'status' => 'active']); + $classic = $this->product($store, 'Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 2499, 80, InventoryPolicy::Deny, ['S', 'M', 'L', 'XL'], ['Black', 'White', 'Navy']); + $jeans = $this->product($store, 'Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 7999, 35, InventoryPolicy::Deny, ['28', '30', '32', '34'], ['Indigo']); + $draft = $this->product($store, 'Coming Soon Jacket', 'coming-soon-jacket', 12999, 0, InventoryPolicy::Deny, ['M'], ['Black'], ProductStatus::Draft); + $soldOut = $this->product($store, 'Sold Out Limited Tee', 'sold-out-limited-tee', 3999, 0, InventoryPolicy::Deny, ['M'], ['White']); + $backorder = $this->product($store, 'Relaxed Backorder Hoodie', 'relaxed-backorder-hoodie', 6999, 0, InventoryPolicy::Continue, ['M', 'L'], ['Navy']); + $tShirts->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $soldOut->getKey() => ['position' => 2]]); + $newArrivals->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $jeans->getKey() => ['position' => 2], $backorder->getKey() => ['position' => 3]]); + + foreach ([ + ['code' => 'WELCOME10', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10], + ['code' => 'FLAT5', 'value_type' => DiscountValueType::Fixed, 'value_amount' => 500], + ['code' => 'FREESHIP', 'value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0], + ['code' => 'EXPIRED20', 'value_type' => DiscountValueType::Percent, 'value_amount' => 20, 'ends_at' => now()->subDay()], + ['code' => 'MAXED', 'value_type' => DiscountValueType::Percent, 'value_amount' => 15, 'usage_limit' => 1, 'usage_count' => 1], + ] as $discount) { + Discount::updateOrCreate(['store_id' => $store->getKey(), 'code' => $discount['code']], array_merge(['type' => 'code', 'status' => 'active', 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), 'usage_limit' => null, 'usage_count' => 0, 'rules_json' => []], $discount)); + } + + $order = Order::withoutGlobalScopes()->firstOrCreate(['store_id' => $store->getKey(), 'order_number' => '#1001'], ['customer_id' => $customer->getKey(), 'currency' => 'EUR', 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', 'payment_method' => PaymentMethod::CreditCard, 'email' => $customer->email, 'subtotal_amount' => 2499, 'shipping_amount' => 499, 'tax_amount' => 0, 'total_amount' => 2998, 'placed_at' => now()->subDay()]); + if ($order->lines()->count() === 0) { + $variant = $classic->variants()->first(); + $order->lines()->create(['product_id' => $classic->getKey(), 'variant_id' => $variant->getKey(), 'product_title' => $classic->title, 'variant_title' => $variant->title, 'sku' => $variant->sku, 'quantity' => 1, 'unit_price_amount' => $variant->price_amount, 'line_subtotal_amount' => $variant->price_amount, 'line_total_amount' => $variant->price_amount]); + $order->payments()->create(['provider' => 'mock', 'provider_payment_id' => 'mock_seed_1001', 'method' => PaymentMethod::CreditCard, 'status' => PaymentStatus::Captured, 'amount' => $order->total_amount]); + } + } + + private function product(Store $store, string $title, string $handle, int $price, int $quantity, InventoryPolicy $policy, array $sizes, array $colors, ProductStatus $status = ProductStatus::Active): Product + { + $product = Product::withoutGlobalScopes()->firstOrCreate(['store_id' => $store->getKey(), 'handle' => $handle], ['title' => $title, 'description' => 'Designed for comfortable everyday wear.', 'vendor' => 'Acme', 'product_type' => 'Apparel', 'tags' => ['featured'], 'status' => $status, 'published_at' => $status === ProductStatus::Active ? now() : null]); + + if ($product->variants()->count() === 0) { + foreach ($sizes as $sizeIndex => $size) { + foreach ($colors as $colorIndex => $color) { + $variant = $product->variants()->create(['title' => $color.' / '.$size, 'sku' => strtoupper('ACME-'.substr($handle, 0, 5).'-'.$size.'-'.$colorIndex), 'price_amount' => $price, 'compare_at_amount' => $price + 500, 'weight_grams' => 250, 'requires_shipping' => true, 'is_default' => $sizeIndex === 0 && $colorIndex === 0, 'position' => ($sizeIndex * count($colors)) + $colorIndex]); + InventoryItem::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'variant_id' => $variant->getKey(), 'quantity_on_hand' => $quantity, 'quantity_reserved' => 0, 'policy' => $policy]); + } + } + } + + if ($product->options()->count() === 0) { + $sizeOption = $product->options()->create(['name' => 'Size', 'position' => 1]); + $colorOption = $product->options()->create(['name' => 'Color', 'position' => 2]); + + foreach ($sizes as $position => $size) { + $sizeOption->values()->create(['value' => $size, 'position' => $position + 1]); + } + + foreach ($colors as $position => $color) { + $colorOption->values()->create(['value' => $color, 'position' => $position + 1]); + } + } + + $optionValues = $product->options()->with('values')->get()->flatMap(fn ($option) => $option->values)->keyBy('value'); + foreach ($product->variants as $variant) { + [$color, $size] = array_pad(array_map('trim', explode('/', $variant->title, 2)), 2, null); + $variant->optionValues()->syncWithoutDetaching(array_values(array_filter([ + $optionValues->get($color)?->getKey(), + $optionValues->get($size)?->getKey(), + ]))); + } + + return $product->load('variants.inventory'); + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..62def160 --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,16 @@ +defaultVariant()) +
      + +
      + @if ($product->media->first()?->url) + {{ $product->title }} + @else +
      + @endif + @if ($variant?->compare_at_amount > $variant?->price_amount)Sale@endif + @if ($product->variants->every(fn ($item): bool => $item->availableQuantity() <= 0) && $product->variants->every(fn ($item): bool => $item->inventory?->policy?->value !== 'continue'))Sold out@endif +
      +
      +

      {{ $product->title }}

      +

      €{{ number_format(($variant?->price_amount ?? 0) / 100, 2) }} @if ($variant?->compare_at_amount)€{{ number_format($variant->compare_at_amount / 100, 2) }}@endif

      +
      +
      +
      diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 00000000..76bfe061 --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,5 @@ + + + {{ $title ?? 'Admin · '.($currentStore?->name ?? 'Shop') }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles + @livewireScripts + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index 71500919..d367f0d6 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -1,3 +1 @@ - - {{ $slot }} - +{{ $title ?? 'Sign in' }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles
      {{ $slot }}
      @livewireScripts diff --git a/resources/views/layouts/empty.blade.php b/resources/views/layouts/empty.blade.php new file mode 100644 index 00000000..27328d43 --- /dev/null +++ b/resources/views/layouts/empty.blade.php @@ -0,0 +1 @@ +Laravel{{ $slot }} diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php new file mode 100644 index 00000000..bf3f2749 --- /dev/null +++ b/resources/views/layouts/storefront.blade.php @@ -0,0 +1,42 @@ + + + + + + {{ $title ?? ($currentStore?->name ?? 'Shop') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + + Skip to content +
      + Free shipping on orders over €50 +
      +
      + +
      +
      {{ $slot }}
      +
      +
      +

      {{ $currentStore?->name ?? 'Shop' }}

      Thoughtful everyday pieces, made to last.

      + + +

      Stay in the loop

      Subscribe for exclusive offers and updates.

      +
      +
      © {{ now()->year }} {{ $currentStore?->name ?? 'Shop' }}. All rights reserved.
      +
      + @livewireScripts + + diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php new file mode 100644 index 00000000..4b68a488 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- The only way to do great work is to love what you do. - Steve Jobs --}} +
      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..3cfb0793 --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- It is never too late to be what you might have been. - George Eliot --}} +
      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..401ee286 --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,3 @@ +
      + {{-- I have not failed. I've just found 10,000 ways that won't work. - Thomas Edison --}} +
      diff --git a/resources/views/livewire/admin/auth/forgot-password.blade.php b/resources/views/livewire/admin/auth/forgot-password.blade.php new file mode 100644 index 00000000..f34a923b --- /dev/null +++ b/resources/views/livewire/admin/auth/forgot-password.blade.php @@ -0,0 +1 @@ +

      Admin access

      Forgot your password?

      @error('email')

      {{ $message }}

      @enderror
      @if ($message)

      {{ $message }}

      @endif
      diff --git a/resources/views/livewire/admin/auth/login.blade.php b/resources/views/livewire/admin/auth/login.blade.php new file mode 100644 index 00000000..5d6be419 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1 @@ +

      Acme Fashion

      Sign in

      Admin access

      @error('email')

      {{ $message }}

      @enderror
      @error('password')

      {{ $message }}

      @enderror
      Forgot password?
      diff --git a/resources/views/livewire/admin/auth/reset-password.blade.php b/resources/views/livewire/admin/auth/reset-password.blade.php new file mode 100644 index 00000000..b64f1615 --- /dev/null +++ b/resources/views/livewire/admin/auth/reset-password.blade.php @@ -0,0 +1 @@ +

      Admin access

      Reset your password

      @error('email')

      {{ $message }}

      @enderror
      diff --git a/resources/views/livewire/admin/collections/create.blade.php b/resources/views/livewire/admin/collections/create.blade.php new file mode 100644 index 00000000..5ef6ee27 --- /dev/null +++ b/resources/views/livewire/admin/collections/create.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Walk as if you are kissing the Earth with your feet. - Thich Nhat Hanh --}} +
      diff --git a/resources/views/livewire/admin/collections/edit.blade.php b/resources/views/livewire/admin/collections/edit.blade.php new file mode 100644 index 00000000..401ee286 --- /dev/null +++ b/resources/views/livewire/admin/collections/edit.blade.php @@ -0,0 +1,3 @@ +
      + {{-- I have not failed. I've just found 10,000 ways that won't work. - Thomas Edison --}} +
      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..b1e7a31c --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- It is not the man who has too little, but the man who craves more, that is poor. - Seneca --}} +
      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..ea8afa4d --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1 @@ +

      Customers

      @foreach ($customers as $customer)@endforeach
      CustomerOrdersJoined
      {{ $customer->name }}

      {{ $customer->email }}

      {{ $customer->orders_count }}{{ $customer->created_at->format('M j, Y') }}
      {{ $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..3525048e --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1 @@ +
      ← Customers

      {{ $customer->name }}

      {{ $customer->email }}

      Order history

      @forelse ($customer->orders as $order){{ $order->order_number }}€{{ number_format($order->total_amount / 100, 2) }}@empty

      No orders.

      @endforelse

      Addresses

      @forelse ($customer->addresses as $address)
      {{ $address->address_json['address1'] ?? '' }}, {{ $address->address_json['city'] ?? '' }}
      @empty

      No addresses.

      @endforelse
      diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..0d00941e --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1 @@ +

      Overview

      Dashboard

      Add product

      Total sales

      €{{ number_format($sales / 100, 2) }}

      Orders

      {{ $orderCount }}

      Products

      {{ $productCount }}

      Average order value

      €{{ number_format($orderCount ? ($sales / $orderCount) / 100 : 0, 2) }}

      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..44e73cee --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Simplicity is the essence of happiness. - Cedric Bledsoe --}} +
      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..470b084b --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1 @@ +
      ← Discounts

      Create discount

      @if ($message)
      {{ $message }}
      @endif
      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..5d7360e7 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1 @@ +

      Marketing

      Discounts

      Create discount
      @foreach ($discounts as $discount)@endforeach
      CodeValueStatus
      {{ $discount->code }}{{ $discount->value_type->value === 'percent' ? $discount->value_amount.'%' : ($discount->value_type->value === 'fixed' ? '€'.number_format($discount->value_amount / 100, 2) : 'Free shipping') }}{{ $discount->isAvailable() ? 'Active' : 'Expired' }}
      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..ae010909 --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Let all your things have their places; let each part of your business have its time. - Benjamin Franklin --}} +
      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..d70074ae --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Nothing in life is to be feared, it is only to be understood. Now is the time to understand more, so that we may fear less. - Maria Skłodowska-Curie --}} +
      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..5c95a5ff --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1 @@ +

      Commerce

      Orders

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

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

      {{ $order->customer?->name ?? $order->email }}{{ ucfirst($order->status->value) }}{{ ucfirst($order->financial_status->value) }}€{{ number_format($order->total_amount / 100, 2) }}
      No orders found.
      {{ $orders->links() }}
      diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php new file mode 100644 index 00000000..5adbbbd6 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1 @@ +
      ← Orders

      {{ $order->order_number }}

      {{ ucfirst($order->financial_status->value) }} · {{ ucfirst($order->fulfillment_status->value) }}

      @if ($message){{ $message }}@endif

      Line items

      @foreach ($order->lines as $line)
      {{ $line->product_title }} · {{ $line->variant_title }} × {{ $line->quantity }}€{{ number_format($line->line_total_amount / 100, 2) }}
      @endforeach
      Total€{{ number_format($order->total_amount / 100, 2) }}

      Fulfillments

      @forelse ($order->fulfillments as $fulfillment)

      {{ ucfirst($fulfillment->status) }}

      @empty

      No fulfillments yet.

      @endforelse
      diff --git a/resources/views/livewire/admin/pages/create.blade.php b/resources/views/livewire/admin/pages/create.blade.php new file mode 100644 index 00000000..50d23fe5 --- /dev/null +++ b/resources/views/livewire/admin/pages/create.blade.php @@ -0,0 +1,3 @@ +
      + {{-- The whole future lies in uncertainty: live immediately. - Seneca --}} +
      diff --git a/resources/views/livewire/admin/pages/edit.blade.php b/resources/views/livewire/admin/pages/edit.blade.php new file mode 100644 index 00000000..005bffa0 --- /dev/null +++ b/resources/views/livewire/admin/pages/edit.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Do what you can, with what you have, where you are. - Theodore Roosevelt --}} +
      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..7e910999 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Breathing in, I calm body and mind. Breathing out, I smile. - Thich Nhat Hanh --}} +
      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..20925e0c --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1 @@ +
      ← Products

      {{ $product ? 'Edit product' : 'Add product' }}

      @if ($message)
      {{ $message }}
      @endif
      @error('title')

      {{ $message }}

      @enderror
      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..4d88ecee --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1 @@ +

      Catalog

      Products

      Add product
      @if ($message)
      {{ $message }}
      @endif
      @forelse ($products as $product)@empty@endforelse
      ProductStatusPriceActions
      {{ $product->title }}

      {{ $product->vendor }}

      {{ ucfirst($product->status->value) }}€{{ number_format(($product->defaultVariant()?->price_amount ?? 0) / 100, 2) }}@if ($product->status->value !== 'archived')@endif
      No products found.
      {{ $products->links() }}
      diff --git a/resources/views/livewire/admin/search/settings.blade.php b/resources/views/livewire/admin/search/settings.blade.php new file mode 100644 index 00000000..9568d334 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,3 @@ +
      + {{-- We must ship. - Taylor Otwell --}} +
      diff --git a/resources/views/livewire/admin/section.blade.php b/resources/views/livewire/admin/section.blade.php new file mode 100644 index 00000000..e980a493 --- /dev/null +++ b/resources/views/livewire/admin/section.blade.php @@ -0,0 +1 @@ +

      Admin

      {{ $heading }}

      Manage your store’s {{ strtolower($heading) }} from this workspace.

      @if(count($rows) > 0)
      @foreach($rows as $row)

      {{ $row['title'] }}

      {{ $row['subtitle'] }}

      {{ $row['value'] }}
      @endforeach
      @else
      No {{ strtolower($heading) }} records have been added yet.
      @endif
      diff --git a/resources/views/livewire/admin/settings/general.blade.php b/resources/views/livewire/admin/settings/general.blade.php new file mode 100644 index 00000000..8aea02c0 --- /dev/null +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -0,0 +1 @@ +

      Configuration

      Store Settings

      @if ($message)
      {{ $message }}
      @endif
      diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..8a6f5a1b --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1 @@ +
      ← Settings

      Shipping settings

      @if ($message)
      {{ $message }}
      @endif
      @foreach ($zones as $zone)

      {{ $zone->name }}

      @foreach ($zone->rates as $rate)

      {{ $rate->name }} · €{{ number_format($rate->price_amount / 100, 2) }}

      @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..2e634e87 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1 @@ +
      ← Settings

      Tax Settings

      @if ($message)
      {{ $message }}
      @endif

      1900 basis points = 19%.

      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..607bcaf3 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Simplicity is the ultimate sophistication. - Leonardo da Vinci --}} +
      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..c164f2a9 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,3 @@ +
      + {{-- Order your soul. Reduce your wants. - Augustine --}} +
      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..8e5030bb --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1 @@ +
      ← Account

      Your addresses

      Add an address

      @forelse ($addresses as $address)

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

      {{ $address->address_json['address1'] ?? '' }}
      {{ $address->address_json['city'] ?? '' }}, {{ $address->address_json['postal_code'] ?? '' }}
      {{ $address->address_json['country_code'] ?? '' }}

      @empty

      No saved addresses.

      @endforelse
      diff --git a/resources/views/livewire/storefront/account/auth/forgot-password.blade.php b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php new file mode 100644 index 00000000..9ce0c388 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php @@ -0,0 +1 @@ +

      Account

      Forgot your password?

      Enter your email and we’ll send a reset link.

      @error('email')

      {{ $message }}

      @enderror
      @if ($message)

      {{ $message }}

      @endif
      diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php new file mode 100644 index 00000000..85cf2061 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1 @@ +

      Welcome back

      Log in

      @error('email')

      {{ $message }}

      @enderror
      @error('password')

      {{ $message }}

      @enderror

      New here? Create an account

      Forgot password?
      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..3701b11d --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1 @@ +

      Join us

      Create your account

      @error('*')

      {{ $message }}

      @enderror
      diff --git a/resources/views/livewire/storefront/account/auth/reset-password.blade.php b/resources/views/livewire/storefront/account/auth/reset-password.blade.php new file mode 100644 index 00000000..03093fc6 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/reset-password.blade.php @@ -0,0 +1 @@ +

      Account

      Reset your password

      @error('email')

      {{ $message }}

      @enderror
      @error('password')

      {{ $message }}

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

      Account

      Welcome, {{ $customer->first_name }}

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

      Your orders

      @forelse ($orders as $order)@empty@endforelse
      OrderDateStatusTotal
      {{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ ucfirst($order->status->value) }}€{{ number_format($order->total_amount / 100, 2) }}
      You have no orders yet.
      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..8147434f --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1 @@ +
      ← Orders

      {{ $order->order_number }}

      {{ ucfirst($order->financial_status->value) }} · {{ ucfirst($order->fulfillment_status->value) }}

      @foreach ($order->lines as $line)
      {{ $line->product_title }} · {{ $line->variant_title }} × {{ $line->quantity }}€{{ number_format($line->line_total_amount / 100, 2) }}
      @endforeach
      Total€{{ number_format($order->total_amount / 100, 2) }}
      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..46e491ac --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1 @@ +

      Shopping bag

      Your Cart

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

      Your cart is empty

      Add something you love to get started.

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

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

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

      {{ $line->quantity }}

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

      @endforeach
      @endif
      diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php new file mode 100644 index 00000000..9da42b0b --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1 @@ +

      Thank you

      Order confirmed

      Your order number is {{ $order->order_number }}.

      Total€{{ number_format($order->total_amount / 100, 2) }}
      Payment{{ $order->financial_status->value === 'pending' ? 'Bank transfer pending' : 'Paid' }}
      Continue shopping
      diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php new file mode 100644 index 00000000..ecad0ad5 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1 @@ +

      Secure checkout

      Checkout

      1. Contact and shipping address

      2. Shipping method

      @if ($rates->isEmpty())

      Enter an address to see available shipping methods.

      @else
      @foreach ($rates as $rate)@endforeach
      @endif

      3. Payment

      @foreach (['credit_card' => 'Credit card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank transfer'] as $value => $label)@endforeach
      @if ($paymentMethod === 'credit_card')@endif
      @if ($message)

      {{ $message }}

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

      Explore

      Collections

      @foreach ($collections as $collection)

      {{ $collection->title }}

      {{ $collection->description }}

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

      {{ $collection->title }}

      {{ $collection->description }}

      {{ $products->count() }} products
      @forelse ($products as $product)@empty

      No products found

      Try adjusting your filters or browse our full collection.

      @endforelse
      diff --git a/resources/views/livewire/storefront/home-fallback.blade.php b/resources/views/livewire/storefront/home-fallback.blade.php new file mode 100644 index 00000000..cf1f55c4 --- /dev/null +++ b/resources/views/livewire/storefront/home-fallback.blade.php @@ -0,0 +1 @@ +

      Laravel

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

      Acme Fashion

      +

      Everyday pieces, thoughtfully made.

      +

      Timeless wardrobe essentials with an easy, modern fit.

      + Shop new arrivals +
      +
      +
      +
      +

      Explore

      Featured collections

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

      {{ $collection->title }}

      {{ $collection->products_count }} products

      @endforeach
      +
      +

      Curated for you

      Featured products

      @foreach ($products as $product)@endforeach
      +

      Stay in the loop

      Subscribe for exclusive offers and updates.

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

      {{ $page->title }}

      {!! $page->content !!}
      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..549bc3a8 --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,3 @@ +@php($selectedVariant = $product->variants->firstWhere('id', $selectedVariantId)) +@php($soldOut = $selectedVariant?->inventory?->availableQuantity() <= 0 && $selectedVariant?->inventory?->policy?->value !== 'continue') +
      @if ($product->media->first()?->url){{ $product->title }}@else
      @endif
      @foreach ($product->media as $media)@endforeach

      {{ $product->vendor }}

      {{ $product->title }}

      €{{ number_format(($selectedVariant?->price_amount ?? 0) / 100, 2) }}

      {{ $product->description }}

      @foreach ($product->options as $option)
      {{ $option->name }}
      @foreach ($option->values as $value)@endforeach
      @endforeach@if ($soldOut)

      Sold out

      @elseif ($selectedVariant?->inventory?->policy?->value === 'continue' && $selectedVariant->availableQuantity() <= 0)

      Available on backorder

      @endif
      @error('quantity')

      {{ $message }}

      @enderror@if ($message)

      {{ $message }}

      @endif
      diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..9e1af9ad --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1 @@ +

      Search

      @if ($query !== '')

      Results for “{{ $query }}”

      @endif
      @forelse ($products as $product)@empty

      No results

      Try a different search.

      @endforelse
      {{ $products->links() }}
      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..4ba13de6 --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,9 @@ +
      + + +
      diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..280515c0 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,40 @@ +middleware([StartSession::class, 'store.resolve', 'throttle:api.storefront'])->group(function (): void { + Route::post('carts', [StorefrontCartController::class, 'store']); + Route::get('carts/{cartId}', [StorefrontCartController::class, 'show']); + Route::post('carts/{cartId}/lines', [StorefrontCartController::class, 'addLine']); + Route::put('carts/{cartId}/lines/{lineId}', [StorefrontCartController::class, 'updateLine']); + Route::delete('carts/{cartId}/lines/{lineId}', [StorefrontCartController::class, 'removeLine']); + Route::post('checkouts', [StorefrontCheckoutController::class, 'store'])->middleware('throttle:checkout'); + Route::get('checkouts/{checkoutId}', [StorefrontCheckoutController::class, 'show'])->middleware('throttle:checkout'); + Route::put('checkouts/{checkoutId}/address', [StorefrontCheckoutController::class, 'address'])->middleware('throttle:checkout'); + Route::put('checkouts/{checkoutId}/shipping-method', [StorefrontCheckoutController::class, 'shippingMethod'])->middleware('throttle:checkout'); + Route::put('checkouts/{checkoutId}/payment-method', [StorefrontCheckoutController::class, 'paymentMethod'])->middleware('throttle:checkout'); + Route::post('checkouts/{checkoutId}/apply-discount', [StorefrontCheckoutController::class, 'applyDiscount'])->middleware('throttle:checkout'); + Route::post('checkouts/{checkoutId}/pay', [StorefrontCheckoutController::class, 'pay'])->middleware('throttle:checkout'); + Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store']); +}); + +Route::prefix('admin/v1/stores/{storeId}')->middleware([StartSession::class, 'auth', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void { + Route::get('products', [AdminController::class, 'products']); + Route::post('products', [AdminController::class, 'storeProduct'])->middleware('role.check:owner,admin,staff'); + Route::get('products/{productId}', [AdminController::class, 'showProduct']); + Route::put('products/{productId}', [AdminController::class, 'updateProduct'])->middleware('role.check:owner,admin,staff'); + Route::delete('products/{productId}', [AdminController::class, 'deleteProduct'])->middleware('role.check:owner,admin,staff'); + Route::get('collections', [AdminController::class, 'collections']); + Route::post('collections', [AdminController::class, 'storeCollection'])->middleware('role.check:owner,admin,staff'); + Route::put('collections/{collectionId}', [AdminController::class, 'updateCollection'])->middleware('role.check:owner,admin,staff'); + Route::delete('collections/{collectionId}', [AdminController::class, 'deleteCollection'])->middleware('role.check:owner,admin,staff'); + Route::get('orders', [AdminController::class, 'orders']); + Route::get('orders/{orderId}', [AdminController::class, 'showOrder']); + Route::get('customers', [AdminController::class, 'customers']); + Route::get('discounts', [AdminController::class, 'discounts']); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..919e4148 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,12 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; + +Schedule::job(new \App\Jobs\CleanupAbandonedCarts)->daily(); +Schedule::job(new \App\Jobs\ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new \App\Jobs\AggregateAnalytics)->dailyAt('01:00'); +Schedule::job(new \App\Jobs\CancelUnpaidBankTransferOrders)->dailyAt('02:00'); Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); diff --git a/routes/web.php b/routes/web.php index f755f111..225941e2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,132 @@ name('home'); - Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) ->name('dashboard'); require __DIR__.'/settings.php'; + +Route::get('/favicon.ico', fn (): \Symfony\Component\HttpFoundation\Response => response('', 204))->name('favicon'); + +Route::middleware('store.resolve')->group(function (): void { + Route::livewire('/', Home::class)->name('home'); + Route::livewire('/collections', CollectionsIndex::class)->name('collections.index'); + Route::livewire('/collections/{handle}', CollectionShow::class)->name('collection.show'); + Route::livewire('/products/{handle}', ProductShow::class)->name('product.show'); + Route::livewire('/cart', CartShow::class)->name('cart.show'); + Route::livewire('/search', SearchIndex::class)->name('search'); + Route::livewire('/pages/{handle}', PageShow::class)->name('page.show'); + Route::livewire('/checkout/{checkoutId}', CheckoutShow::class)->name('checkout.show'); + Route::livewire('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->name('checkout.confirmation'); + Route::livewire('/account/login', CustomerLogin::class)->middleware('throttle:login')->name('account.login'); + Route::livewire('/account/register', CustomerRegister::class)->name('account.register'); + Route::livewire('/forgot-password', CustomerForgotPassword::class)->name('password.request'); + Route::livewire('/reset-password/{token}', CustomerResetPassword::class)->name('password.reset'); + Route::post('/account/logout', function (): \Illuminate\Http\RedirectResponse { + Auth::guard('customer')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('home'); + })->name('account.logout'); + + Route::middleware('auth:customer')->group(function (): void { + Route::livewire('/account', AccountDashboard::class)->name('account.dashboard'); + Route::livewire('/account/orders', AccountOrders::class)->name('account.orders'); + Route::livewire('/account/orders/{orderNumber}', AccountOrderShow::class)->name('account.order.show'); + Route::livewire('/account/addresses', AccountAddresses::class)->name('account.addresses'); + }); +}); + +Route::livewire('/admin/login', AdminLogin::class)->middleware('throttle:login')->name('admin.login'); +Route::livewire('/admin/forgot-password', AdminForgotPassword::class)->middleware('throttle:login')->name('admin.password.request'); +Route::livewire('/admin/reset-password/{token}', AdminResetPassword::class)->name('admin.password.reset'); +Route::post('/admin/logout', function (): \Illuminate\Http\RedirectResponse { + Auth::guard('web')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('admin.login'); +})->name('admin.logout'); + +Route::prefix('admin')->middleware(['auth', 'verified', 'store.resolve', 'role.check:owner,admin,staff,support'])->group(function (): void { + Route::livewire('/', AdminDashboard::class)->name('admin.dashboard'); + Route::livewire('/products', AdminProductsIndex::class)->name('admin.products.index'); + Route::livewire('/products/create', AdminProductForm::class)->middleware('role.check:owner,admin,staff')->name('admin.products.create'); + Route::livewire('/products/{product}/edit', AdminProductForm::class)->middleware('role.check:owner,admin,staff')->name('admin.products.edit'); + Route::livewire('/orders', AdminOrdersIndex::class)->name('admin.orders.index'); + Route::livewire('/orders/{order}', AdminOrdersShow::class)->name('admin.orders.show'); + Route::livewire('/customers', AdminCustomersIndex::class)->name('admin.customers.index'); + Route::livewire('/customers/{customer}', AdminCustomersShow::class)->name('admin.customers.show'); + Route::livewire('/discounts', AdminDiscountsIndex::class)->name('admin.discounts.index'); + Route::livewire('/discounts/create', AdminDiscountForm::class)->middleware('role.check:owner,admin,staff')->name('admin.discounts.create'); + Route::livewire('/discounts/{discount}/edit', AdminDiscountForm::class)->middleware('role.check:owner,admin,staff')->name('admin.discounts.edit'); + Route::livewire('/settings', AdminSettingsGeneral::class)->middleware('role.check:owner,admin')->name('admin.settings'); + Route::livewire('/settings/shipping', AdminSettingsShipping::class)->middleware('role.check:owner,admin')->name('admin.settings.shipping'); + Route::livewire('/settings/taxes', AdminSettingsTaxes::class)->middleware('role.check:owner,admin')->name('admin.settings.taxes'); + Route::livewire('/inventory', AdminInventoryIndex::class)->name('admin.inventory'); + Route::livewire('/collections', AdminCollectionsIndex::class)->name('admin.collections'); + Route::livewire('/collections/create', AdminCollectionsCreate::class)->middleware('role.check:owner,admin,staff')->name('admin.collections.create'); + Route::livewire('/collections/{collection}/edit', AdminCollectionsEdit::class)->middleware('role.check:owner,admin,staff')->name('admin.collections.edit'); + Route::livewire('/themes', AdminThemesIndex::class)->name('admin.themes'); + Route::livewire('/themes/{theme}/editor', AdminThemesEditor::class)->name('admin.themes.editor'); + Route::livewire('/pages', AdminPagesIndex::class)->name('admin.pages'); + Route::livewire('/pages/create', AdminPagesCreate::class)->middleware('role.check:owner,admin')->name('admin.pages.create'); + Route::livewire('/pages/{page}/edit', AdminPagesEdit::class)->middleware('role.check:owner,admin')->name('admin.pages.edit'); + Route::livewire('/navigation', AdminNavigationIndex::class)->name('admin.navigation'); + Route::livewire('/apps', AdminAppsIndex::class)->name('admin.apps'); + Route::livewire('/apps/{installation}', AdminAppsShow::class)->name('admin.apps.show'); + Route::livewire('/developers', AdminDevelopersIndex::class)->name('admin.developers'); + Route::livewire('/analytics', AdminAnalyticsIndex::class)->name('admin.analytics'); + Route::livewire('/search/settings', AdminSearchSettings::class)->name('admin.search.settings'); +}); diff --git a/specs/progress.md b/specs/progress.md index 6ea3677f..f57398b8 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -1,13 +1,29 @@ # Implementation Progress -## Foundation tenancy slice +The core self-contained shop implementation is in place and verified with Pest and Playwright. -- [x] SQLite, cache, session, and queue defaults configured for the self-contained app. -- [x] Organization, store, domain, store-user, and store-settings schema/model relationships established. -- [x] Store status, domain type, and store-user role enums available. -- [x] `BelongsToStore` and `StoreScope` enforce current-store query and create boundaries. -- [x] `ResolveStore` supports hostname-based storefront resolution and session-based admin resolution. -- [x] Store-role helpers and policy scaffolding added for the specified admin resources. -- [x] Focused Pest coverage added for resolution, tenant isolation, and role authorization. +## Completed -Catalog, cart, checkout, order, and storefront UI implementation remains outside this bounded slice. +- [x] Foundation: SQLite configuration, tenant schema/models, store resolution, global tenant scope, roles, and policies. +- [x] Catalog: products, variants, options, inventory, collections, media, product status transitions, and seeded demo data. +- [x] Storefront: home, collections, product detail, variant selection, cart, search, static pages, responsive layouts, and theme scaffolding. +- [x] Cart and checkout: session/customer carts, optimistic cart versions, discount codes, addresses, shipping rates, tax calculation, and checkout expiry. +- [x] Payments and orders: mock card/PayPal/bank-transfer PSP, idempotent payment handling, inventory reservations, order snapshots, confirmation, cancellation, refunds, and fulfillment guards. +- [x] Authentication: Fortify admin authentication plus separate tenant-scoped customer authentication, registration, password reset, email verification, and 2FA support. +- [x] Admin: dashboard, product/order/customer/discount/settings screens, role middleware, resource-aware inventory/collection/theme/page/navigation/app/developer/analytics/search sections, and versioned session-authenticated catalog/collection/order/customer/discount API endpoints. +- [x] Search, analytics, apps, and webhooks: SQLite FTS5 indexing, query logging, analytics aggregation, signed webhook delivery, retries, and subscription pausing. +- [x] Automated coverage: unit and feature tests for pricing, tenancy, authentication, commerce flows, search, analytics, webhooks, and customer sessions. +- [x] Browser acceptance: storefront browsing, product add-to-cart, discount application, address/shipping/payment checkout, order confirmation, customer account, admin login, and admin section smoke checks. + +## Verification + +- `php artisan test`: 67 passing tests, 175 assertions. +- `npm run build`: passing. +- `vendor/bin/pint --dirty --format agent`: run after the final PHP changes. +- Playwright MCP browser checks: no storefront/admin page errors in the completed smoke paths. + +## Remaining hardening + +- Expand the resource-aware admin sections into full CRUD editors and add token-authenticated admin API coverage when the API authentication dependency is approved for production use. +- Add stronger opaque guest checkout tokens and broader resource-level API ownership tests. +- Add broader browser coverage for refund/fulfillment actions, customer registration/reset flows, and mobile interaction states. diff --git a/tests/Feature/AdminApiTest.php b/tests/Feature/AdminApiTest.php new file mode 100644 index 00000000..b7e42012 --- /dev/null +++ b/tests/Feature/AdminApiTest.php @@ -0,0 +1,38 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail(); +}); + +test('store members can use the versioned admin catalog API', function (): void { + $this->actingAs($this->admin) + ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products") + ->assertOk() + ->assertJsonPath('meta.total', 5); + + $this->actingAs($this->admin) + ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", [ + 'title' => 'API Collection', + 'product_ids' => [], + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'API Collection'); +}); + +test('admin API rejects a store id outside the current tenant', function (): void { + $otherStore = Store::factory()->create(); + + $this->actingAs($this->admin) + ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products") + ->assertNotFound(); +}); diff --git a/tests/Feature/CommerceFlowTest.php b/tests/Feature/CommerceFlowTest.php new file mode 100644 index 00000000..c0c89ba2 --- /dev/null +++ b/tests/Feature/CommerceFlowTest.php @@ -0,0 +1,141 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $this->store); +}); + +test('guest cart and card checkout create a paid order', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR']) + ->assertCreated() + ->json(); + + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + 'cart_version' => 1, + ])->assertCreated(); + + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart['id'], + 'email' => 'flow@example.test', + ])->assertCreated()->json(); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [ + 'shipping_address' => [ + 'first_name' => 'Flow', + 'last_name' => 'Tester', + 'address1' => '1 Test Street', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + ])->assertOk()->assertJsonPath('status', 'addressed'); + + $rate = ShippingRate::query()->firstOrFail(); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", [ + 'shipping_method_id' => $rate->getKey(), + ])->assertOk()->assertJsonPath('status', 'shipping_selected'); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + ])->assertOk()->assertJsonPath('order.financial_status', 'paid'); + + $order = Order::query()->latest('id')->firstOrFail(); + + expect($order->payment_method)->toBe('credit_card') + ->and($order->financial_status->value)->toBe('paid') + ->and($order->payments()->firstOrFail()->status)->toBe(PaymentStatus::Captured) + ->and($order->checkout->status->value)->toBe('completed'); +}); + +test('declined payments release the reservation and do not create an order', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $inventoryBefore = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved; + + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]); + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json(); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Declined', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]); + $rate = ShippingRate::query()->firstOrFail(); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'credit_card', 'card_number' => '4000000000000002'])->assertUnprocessable(); + + expect(InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe($inventoryBefore) + ->and(Order::query()->where('email', 'declined@example.test')->exists())->toBeFalse(); +}); + +test('stale cart versions return a conflict response', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); + + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertConflict(); +}); + +test('guest cart API resources are bound to the current session', function (): void { + $firstCart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); + $secondCart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); + + $this->getJson("http://shop.test/api/storefront/v1/carts/{$firstCart['id']}")->assertNotFound(); + $this->getJson("http://shop.test/api/storefront/v1/carts/{$secondCart['id']}")->assertOk(); +}); + +test('cart and checkout APIs return domain errors as unprocessable responses', function (): void { + $soldOutVariant = Product::query()->where('handle', 'sold-out-limited-tee')->firstOrFail()->variants()->firstOrFail(); + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); + + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [ + 'variant_id' => $soldOutVariant->getKey(), + 'quantity' => 1, + 'cart_version' => 1, + ])->assertUnprocessable()->assertJsonPath('code', 'insufficient_inventory'); + + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated(); + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json(); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/apply-discount", ['code' => 'MISSING']) + ->assertUnprocessable() + ->assertJsonPath('code', 'discount_not_found'); +}); + +test('bank transfer keeps inventory reserved until admin confirmation', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]); + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json(); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Bank', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]); + $rate = ShippingRate::query()->firstOrFail(); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]); + + $response = $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'bank_transfer'])->assertOk()->json(); + $order = Order::query()->whereKey($response['order']['id'])->firstOrFail(); + $inventory = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail(); + + expect($order->financial_status->value)->toBe('pending')->and($inventory->quantity_reserved)->toBe(1); + + app(OrderService::class)->confirmPayment($order); + $inventory = $inventory->refresh(); + + expect($order->refresh()->financial_status->value)->toBe('paid') + ->and($inventory->quantity_on_hand)->toBe(79) + ->and($inventory->quantity_reserved)->toBe(0); +}); diff --git a/tests/Feature/SearchAnalyticsWebhookTest.php b/tests/Feature/SearchAnalyticsWebhookTest.php new file mode 100644 index 00000000..1df44515 --- /dev/null +++ b/tests/Feature/SearchAnalyticsWebhookTest.php @@ -0,0 +1,79 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $this->store); +}); + +test('search is tenant scoped, paginated, and logs the query', function (): void { + $search = new SearchService; + $results = $search->search($this->store, 'classic', [], 12); + + expect($results->total())->toBe(1) + ->and($results->first()->title)->toBe('Classic Cotton T-Shirt') + ->and(SearchQuery::query()->where('query', 'classic')->count())->toBe(1); +}); + +test('product changes are synchronized to the FTS index and autocomplete', function (): void { + $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + $product->update(['title' => 'Classic Cotton Tee']); + + expect(DB::table('products_fts')->where('product_id', $product->getKey())->value('title'))->toBe('Classic Cotton Tee') + ->and((new SearchService)->autocomplete($this->store, 'Classic')->first()->title)->toBe('Classic Cotton Tee'); +}); + +test('analytics events aggregate idempotently into daily metrics', function (): void { + $date = CarbonImmutable::yesterday(); + $analytics = new AnalyticsService; + + foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) { + $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1'); + $event->forceFill(['created_at' => $date])->save(); + } + + AggregateAnalytics::dispatchSync($this->store, $date->toDateString()); + AggregateAnalytics::dispatchSync($this->store, $date->toDateString()); + + $daily = AnalyticsDaily::query()->whereDate('date', $date)->firstOrFail(); + + expect($daily->visits_count)->toBe(2) + ->and($daily->add_to_cart_count)->toBe(1) + ->and($daily->checkout_started_count)->toBe(1) + ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(4); +}); + +test('webhooks are signed and delivered with platform headers', function (): void { + Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]); + $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'secret_encrypted' => 'test-secret', 'status' => 'active']); + + (new WebhookService)->dispatch($this->store, 'order.created', ['order_id' => 1001]); + + Http::assertSent(function ($request): bool { + return $request->hasHeader('X-Platform-Signature') + && $request->header('X-Platform-Event')[0] === 'order.created' + && $request->header('X-Platform-Delivery-Id') !== null; + }); + + expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered') + ->and((new WebhookService)->verify('{"order_id":1001}', (new WebhookService)->sign('{"order_id":1001}', 'test-secret'), 'test-secret'))->toBeTrue(); +}); diff --git a/tests/Feature/Storefront/CustomerAuthenticationTest.php b/tests/Feature/Storefront/CustomerAuthenticationTest.php new file mode 100644 index 00000000..900efb7b --- /dev/null +++ b/tests/Feature/Storefront/CustomerAuthenticationTest.php @@ -0,0 +1,41 @@ +create(); + StoreDomain::factory()->create(['store_id' => $store->getKey(), 'hostname' => 'shop.test']); + $customer = Customer::factory()->create(['store_id' => $store->getKey(), 'email' => 'customer@example.test']); + + app()->instance('current_store', $store); + + Livewire::test(Login::class) + ->set('email', $customer->email) + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('account.dashboard')); + + expect(auth('customer')->check())->toBeTrue(); + + $this->get('http://shop.test/account')->assertOk(); +}); + +test('customer sessions are available to storefront API requests', function () { + $store = Store::factory()->create(); + StoreDomain::factory()->create(['store_id' => $store->getKey(), 'hostname' => 'shop.test']); + $customer = Customer::factory()->create(['store_id' => $store->getKey(), 'email' => 'api-customer@example.test']); + + app()->instance('current_store', $store); + + $this->actingAs($customer, 'customer') + ->postJson('http://shop.test/api/storefront/v1/carts') + ->assertCreated() + ->assertJsonPath('customer_id', $customer->getKey()); +}); diff --git a/tests/Unit/DomainServicesTest.php b/tests/Unit/DomainServicesTest.php new file mode 100644 index 00000000..91fd730c --- /dev/null +++ b/tests/Unit/DomainServicesTest.php @@ -0,0 +1,107 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $this->store); +}); + +test('guest cart merges duplicate variants using the higher quantity and clears the session cart', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $customer = \App\Models\Customer::query()->where('email', 'customer@acme.test')->firstOrFail(); + $service = app(CartService::class); + $guest = $service->create($this->store); + $customerCart = $service->create($this->store, $customer); + + $service->addLine($guest, $variant->getKey(), 2); + $service->addLine($customerCart, $variant->getKey(), 1); + session(['cart_id_'.$this->store->getKey() => $guest->getKey()]); + + $merged = $service->mergeOnLogin($guest, $customerCart); + + expect($merged->lines->firstWhere('variant_id', $variant->getKey())->quantity)->toBe(2) + ->and($guest->refresh()->status->value)->toBe('abandoned') + ->and(session()->has('cart_id_'.$this->store->getKey()))->toBeFalse(); +}); + +test('discount validation and allocation honor product restrictions', function (): void { + $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + $variant = $product->variants()->firstOrFail(); + $cart = Cart::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'currency' => 'EUR', 'cart_version' => 1, 'status' => 'active']); + $cart->lines()->create(['variant_id' => $variant->getKey(), 'quantity' => 1, 'unit_price_amount' => $variant->price_amount, 'line_subtotal_amount' => $variant->price_amount, 'line_total_amount' => $variant->price_amount]); + $discount = Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => 'PRODUCT10', 'type' => 'code', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'status' => 'active', 'starts_at' => now()->subDay(), 'ends_at' => now()->addDay(), 'rules_json' => ['applicable_product_ids' => [$product->getKey()]]]); + + expect(app(DiscountService::class)->validate($discount->code, $this->store, $cart)->is($discount))->toBeTrue() + ->and(app(DiscountService::class)->calculate($discount, $variant->price_amount, [ + ['line_id' => 1, 'product_id' => $product->getKey(), 'amount' => 1000], + ['line_id' => 2, 'product_id' => $product->getKey() + 999, 'amount' => 1000], + ])->allocations)->toBe([1 => 100]); +}); + +test('discount validation returns the documented usage limit error code', function (): void { + $discount = Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => 'MAXED-TEST', 'type' => 'code', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'status' => 'active', 'usage_limit' => 1, 'usage_count' => 1, 'starts_at' => now()->subDay(), 'ends_at' => now()->addDay(), 'rules_json' => []]); + $cart = Cart::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'currency' => 'EUR', 'cart_version' => 1, 'status' => 'active']); + + try { + app(DiscountService::class)->validate($discount->code, $this->store, $cart); + expect(false)->toBeTrue(); + } catch (InvalidDiscountException $exception) { + expect($exception->reason)->toBe('discount_usage_limit_reached'); + } +}); + +test('line refunds restock only the refunded quantity', function (): void { + $order = Order::withoutGlobalScopes()->where('order_number', '#1001')->firstOrFail(); + $payment = $order->payments()->firstOrFail(); + $line = $order->lines()->firstOrFail(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $line->variant_id)->firstOrFail(); + $before = $inventory->quantity_on_hand; + + $refund = app(RefundService::class)->create($order, $payment, [$line->getKey() => 1], 'Damaged item', true); + + expect($refund->amount)->toBe($line->line_total_amount) + ->and($inventory->refresh()->quantity_on_hand)->toBe($before + 1) + ->and($order->refresh()->financial_status->value)->toBe('partially_refunded'); +}); + +test('expiring a payment-selected checkout releases reserved inventory', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail(); + $cart = app(CartService::class)->create($this->store); + app(CartService::class)->addLine($cart, $variant->getKey(), 1); + $checkout = Checkout::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'cart_id' => $cart->getKey(), 'email' => 'expire@example.test', 'status' => CheckoutStatus::PaymentSelected, 'expires_at' => now()->subMinute()]); + app(\App\Services\InventoryService::class)->reserve($inventory, 1); + + app(CheckoutService::class)->expireCheckout($checkout); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Expired) + ->and($inventory->refresh()->quantity_reserved)->toBe(0); +}); + +test('cart additions reject archived variants', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + $variant->update(['status' => VariantStatus::Archived]); + + expect(fn (): mixed => app(CartService::class)->addLine(app(CartService::class)->create($this->store), $variant->getKey(), 1)) + ->toThrow(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); +}); diff --git a/tests/Unit/PricingEngineTest.php b/tests/Unit/PricingEngineTest.php new file mode 100644 index 00000000..3d8d3415 --- /dev/null +++ b/tests/Unit/PricingEngineTest.php @@ -0,0 +1,53 @@ +addExclusive(2499, 1900))->toBe(474) + ->and($calculator->extractInclusive(2973, 1900))->toBe(474); +}); + +test('discount calculations cap fixed discounts and allocate percentages', function (): void { + $service = new DiscountService; + $discount = new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => 10]); + + $result = $service->calculate($discount, 10000, [ + ['line_id' => 1, 'amount' => 6000], + ['line_id' => 2, 'amount' => 4000], + ]); + + expect($result->amount)->toBe(1000) + ->and($result->allocations)->toBe([1 => 600, 2 => 400]); + + $fixed = new Discount(['value_type' => DiscountValueType::Fixed, 'value_amount' => 5000]); + + expect($service->calculate($fixed, 300, [['line_id' => 1, 'amount' => 300]])->amount)->toBe(300); +}); + +test('free shipping discounts do not reduce merchandise totals', function (): void { + $discount = new Discount(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0]); + $result = (new DiscountService)->calculate($discount, 2500, [['line_id' => 1, 'amount' => 2500]]); + + expect($result->amount)->toBe(0)->and($result->freeShipping)->toBeTrue(); +}); + +test('mock payment provider supports the documented magic cards and deferred transfers', function (): void { + $provider = new MockPaymentProvider; + $checkout = new Checkout; + + expect($provider->charge($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242'])->status) + ->toBe(PaymentStatus::Captured) + ->and($provider->charge($checkout, PaymentMethod::CreditCard, ['card_number' => '4000000000000002'])->status) + ->toBe(PaymentStatus::Failed) + ->and($provider->charge($checkout, PaymentMethod::BankTransfer, [])->status) + ->toBe(PaymentStatus::Pending); +}); From d3efee4e13526d1f4a6e1541dacdda1c03fbb316 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 21 Aug 2026 00:33:34 +0200 Subject: [PATCH 5/9] Align domain models with shop schema contract --- app/Jobs/AggregateAnalytics.php | 1 + app/Models/AnalyticsDaily.php | 2 +- app/Models/AnalyticsEvent.php | 11 +- app/Models/NavigationItem.php | 2 +- app/Models/OrderLine.php | 12 +- app/Models/Page.php | 6 +- app/Models/Payment.php | 2 +- app/Models/ProductMedia.php | 2 +- app/Models/SearchSetting.php | 12 +- app/Models/User.php | 13 ++ app/Models/WebhookDelivery.php | 13 +- app/Models/WebhookSubscription.php | 9 +- app/Services/AnalyticsService.php | 2 +- app/Services/PaymentService.php | 2 +- app/Services/WebhookService.php | 6 +- ..._add_remaining_schema_contract_columns.php | 121 ++++++++++++++++++ 16 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php index daecec4e..abb095f8 100644 --- a/app/Jobs/AggregateAnalytics.php +++ b/app/Jobs/AggregateAnalytics.php @@ -37,6 +37,7 @@ public function handle(): void 'visits_count' => $events->where('type', 'page_view')->count(), 'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(), 'checkout_started_count' => $events->where('type', 'checkout_started')->count(), + 'checkout_completed_count' => $events->where('type', 'checkout_completed')->count(), ], ); } diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php index b667a141..c99ca464 100644 --- a/app/Models/AnalyticsDaily.php +++ b/app/Models/AnalyticsDaily.php @@ -11,7 +11,7 @@ class AnalyticsDaily extends Model protected $table = 'analytics_daily'; - protected $fillable = ['store_id', 'date', 'orders_count', 'revenue_amount', 'aov_amount', 'visits_count', 'add_to_cart_count', 'checkout_started_count']; + protected $fillable = ['store_id', 'date', 'orders_count', 'revenue_amount', 'aov_amount', 'visits_count', 'add_to_cart_count', 'checkout_started_count', 'checkout_completed_count']; public $incrementing = false; diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php index c278c83b..5c60691e 100644 --- a/app/Models/AnalyticsEvent.php +++ b/app/Models/AnalyticsEvent.php @@ -9,10 +9,17 @@ class AnalyticsEvent extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'type', 'session_id', 'customer_id', 'client_event_id', 'payload']; + protected $fillable = ['store_id', 'type', 'session_id', 'customer_id', 'client_event_id', 'payload', 'properties_json', 'occurred_at']; protected function casts(): array { - return ['payload' => 'array']; + return ['payload' => 'array', 'properties_json' => 'array', 'occurred_at' => 'datetime']; + } + + protected static function booted(): void + { + static::saving(function (AnalyticsEvent $event): void { + $event->properties_json = $event->payload ?? []; + }); } } diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php index c2014d74..67f3a074 100644 --- a/app/Models/NavigationItem.php +++ b/app/Models/NavigationItem.php @@ -7,7 +7,7 @@ class NavigationItem extends Model { - protected $fillable = ['navigation_menu_id', 'label', 'type', 'url', 'resource_id', 'position', 'parent_id']; + protected $fillable = ['navigation_menu_id', 'menu_id', 'label', 'type', 'url', 'resource_id', 'position', 'parent_id']; public function menu(): BelongsTo { diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php index 20aa2ff0..9024ea89 100644 --- a/app/Models/OrderLine.php +++ b/app/Models/OrderLine.php @@ -7,13 +7,23 @@ class OrderLine extends Model { - protected $fillable = ['order_id', 'product_id', 'variant_id', 'product_title', 'title_snapshot', 'variant_title', 'sku', 'sku_snapshot', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount', 'tax_lines_json', 'discount_allocations_json']; + protected $fillable = ['order_id', 'product_id', 'variant_id', 'product_title', 'title_snapshot', 'variant_title', 'sku', 'sku_snapshot', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount', 'total_amount', 'tax_lines_json', 'discount_allocations_json']; protected function casts(): array { return ['tax_lines_json' => 'array', 'discount_allocations_json' => 'array']; } + protected static function booted(): void + { + static::saving(function (OrderLine $line): void { + if ($line->isDirty('total_amount') && ! $line->isDirty('line_total_amount')) { + $line->line_total_amount = $line->total_amount; + } + $line->total_amount = $line->line_total_amount; + }); + } + public function order(): BelongsTo { return $this->belongsTo(Order::class); diff --git a/app/Models/Page.php b/app/Models/Page.php index 86ae5d5d..34192256 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -11,7 +11,7 @@ class Page extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'title', 'handle', 'content', 'status', 'published_at']; + protected $fillable = ['store_id', 'title', 'handle', 'content', 'body_html', 'status', 'published_at']; protected function casts(): array { @@ -21,7 +21,11 @@ protected function casts(): array protected static function booted(): void { static::saving(function (Page $page): void { + if ($page->isDirty('body_html') && ! $page->isDirty('content')) { + $page->content = $page->body_html; + } $page->content = app(HtmlSanitizer::class)->sanitize($page->content); + $page->body_html = $page->content; }); } } diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 489f2eb1..aa567ece 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -9,7 +9,7 @@ class Payment extends Model { - protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'raw_json_encrypted']; + protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'currency', 'raw_json_encrypted']; protected $hidden = ['raw_json_encrypted']; diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php index bab6fcb8..34566bee 100644 --- a/app/Models/ProductMedia.php +++ b/app/Models/ProductMedia.php @@ -7,7 +7,7 @@ class ProductMedia extends Model { - protected $fillable = ['product_id', 'type', 'path', 'storage_key', 'url', 'alt_text', 'mime_type', 'byte_size', 'checksum', 'status', 'position', 'metadata']; + protected $fillable = ['product_id', 'type', 'path', 'storage_key', 'url', 'alt_text', 'width', 'height', 'mime_type', 'byte_size', 'checksum', 'status', 'position', 'metadata']; protected function casts(): array { diff --git a/app/Models/SearchSetting.php b/app/Models/SearchSetting.php index 3e22fff6..84babab9 100644 --- a/app/Models/SearchSetting.php +++ b/app/Models/SearchSetting.php @@ -13,10 +13,18 @@ class SearchSetting extends Model public $incrementing = false; - protected $fillable = ['store_id', 'synonyms', 'stopwords', 'enabled']; + protected $fillable = ['store_id', 'synonyms', 'stopwords', 'synonyms_json', 'stop_words_json', 'enabled']; protected function casts(): array { - return ['synonyms' => 'array', 'stopwords' => 'array', 'enabled' => 'boolean']; + return ['synonyms' => 'array', 'stopwords' => 'array', 'synonyms_json' => 'array', 'stop_words_json' => 'array', 'enabled' => 'boolean']; + } + + protected static function booted(): void + { + static::saving(function (SearchSetting $settings): void { + $settings->synonyms_json = $settings->synonyms ?? []; + $settings->stop_words_json = $settings->stopwords ?? []; + }); } } diff --git a/app/Models/User.php b/app/Models/User.php index 0bcfb2fc..8b2d453d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'password_hash', 'status', 'last_login_at', ]; @@ -36,6 +37,7 @@ class User extends Authenticatable */ protected $hidden = [ 'password', + 'password_hash', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', @@ -55,6 +57,17 @@ protected function casts(): array ]; } + protected static function booted(): void + { + static::saving(function (User $user): void { + if ($user->isDirty('password')) { + $user->password_hash = $user->password; + } elseif ($user->isDirty('password_hash')) { + $user->password = $user->password_hash; + } + }); + } + public function stores(): BelongsToMany { return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps(); diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php index efe657b9..e3c2e129 100644 --- a/app/Models/WebhookDelivery.php +++ b/app/Models/WebhookDelivery.php @@ -7,11 +7,20 @@ class WebhookDelivery extends Model { - protected $fillable = ['webhook_subscription_id', 'event', 'payload', 'status', 'attempts', 'response_status', 'response_body', 'delivered_at', 'next_attempt_at']; + protected $fillable = ['webhook_subscription_id', 'subscription_id', 'event', 'event_id', 'payload', 'status', 'attempts', 'attempt_count', 'response_status', 'response_code', 'response_body', 'response_body_snippet', 'delivered_at', 'last_attempt_at', 'next_attempt_at']; protected function casts(): array { - return ['payload' => 'array', 'delivered_at' => 'datetime', 'next_attempt_at' => 'datetime']; + return ['payload' => 'array', 'delivered_at' => 'datetime', 'last_attempt_at' => 'datetime', 'next_attempt_at' => 'datetime']; + } + + protected static function booted(): void + { + static::creating(function (WebhookDelivery $delivery): void { + $delivery->subscription_id ??= $delivery->webhook_subscription_id; + $delivery->attempt_count ??= $delivery->attempts ?? 0; + $delivery->event_id ??= (string) str()->uuid(); + }); } public function subscription(): BelongsTo diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php index 50b53c56..0837f11c 100644 --- a/app/Models/WebhookSubscription.php +++ b/app/Models/WebhookSubscription.php @@ -10,7 +10,7 @@ class WebhookSubscription extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'event', 'target_url', 'secret_encrypted', 'status', 'consecutive_failures']; + protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'secret_encrypted', 'status', 'consecutive_failures']; protected $hidden = ['secret_encrypted']; @@ -19,6 +19,13 @@ protected function casts(): array return ['secret_encrypted' => 'encrypted']; } + protected static function booted(): void + { + static::saving(function (WebhookSubscription $subscription): void { + $subscription->event_type = $subscription->event; + }); + } + public function deliveries(): HasMany { return $this->hasMany(WebhookDelivery::class); diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php index b43e7742..b867d563 100644 --- a/app/Services/AnalyticsService.php +++ b/app/Services/AnalyticsService.php @@ -25,7 +25,7 @@ public function track(Store $store, string $type, array $properties = [], ?strin } } - return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties]); + return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties, 'properties_json' => $properties, 'occurred_at' => now()]); } public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 7774df39..97049381 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -48,7 +48,7 @@ public function pay(Checkout $checkout, PaymentMethod $method, array $details = } $order = $this->orders->createFromCheckout($checkout, $result); - Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]); + Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]); if ($checkout->discount_code !== null) { Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->where('code', $checkout->discount_code)->increment('usage_count'); diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php index 2b1498b8..48fc6532 100644 --- a/app/Services/WebhookService.php +++ b/app/Services/WebhookService.php @@ -12,11 +12,13 @@ public function dispatch(Store $store, string $eventType, array $payload): void { WebhookSubscription::withoutGlobalScopes() ->where('store_id', $store->getKey()) - ->where('event', $eventType) + ->where(function ($query) use ($eventType): void { + $query->where('event', $eventType)->orWhere('event_type', $eventType); + }) ->where('status', 'active') ->get() ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { - $delivery = $subscription->deliveries()->create(['event' => $eventType, 'payload' => $payload, 'attempts' => 0, 'next_attempt_at' => now()]); + $delivery = $subscription->deliveries()->create(['event' => $eventType, 'event_id' => (string) str()->uuid(), 'payload' => $payload, 'attempts' => 0, 'attempt_count' => 0, 'next_attempt_at' => now()]); DeliverWebhook::dispatch($delivery); }); } diff --git a/database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php b/database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php new file mode 100644 index 00000000..cdef384f --- /dev/null +++ b/database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php @@ -0,0 +1,121 @@ +text('password_hash')->nullable(); + } + }); + Schema::table('customer_password_reset_tokens', function (Blueprint $table): void { + if (! Schema::hasColumn('customer_password_reset_tokens', 'store_id')) { + $table->foreignId('store_id')->nullable()->constrained('stores')->nullOnDelete(); + } + }); + Schema::table('product_media', function (Blueprint $table): void { + if (! Schema::hasColumn('product_media', 'width')) { + $table->unsignedInteger('width')->nullable(); + } + if (! Schema::hasColumn('product_media', 'height')) { + $table->unsignedInteger('height')->nullable(); + } + }); + Schema::table('theme_files', function (Blueprint $table): void { + if (! Schema::hasColumn('theme_files', 'storage_key')) { + $table->string('storage_key')->nullable(); + } + if (! Schema::hasColumn('theme_files', 'sha256')) { + $table->string('sha256')->nullable(); + } + if (! Schema::hasColumn('theme_files', 'byte_size')) { + $table->unsignedBigInteger('byte_size')->default(0); + } + }); + Schema::table('pages', function (Blueprint $table): void { + if (! Schema::hasColumn('pages', 'body_html')) { + $table->longText('body_html')->nullable(); + } + }); + Schema::table('navigation_items', function (Blueprint $table): void { + if (! Schema::hasColumn('navigation_items', 'menu_id')) { + $table->unsignedBigInteger('menu_id')->nullable()->index(); + } + }); + Schema::table('search_settings', function (Blueprint $table): void { + if (! Schema::hasColumn('search_settings', 'synonyms_json')) { + $table->json('synonyms_json')->nullable(); + } + if (! Schema::hasColumn('search_settings', 'stop_words_json')) { + $table->json('stop_words_json')->nullable(); + } + }); + Schema::table('order_lines', function (Blueprint $table): void { + if (! Schema::hasColumn('order_lines', 'total_amount')) { + $table->unsignedInteger('total_amount')->nullable(); + } + }); + Schema::table('payments', function (Blueprint $table): void { + if (! Schema::hasColumn('payments', 'currency')) { + $table->string('currency', 3)->default('USD'); + } + }); + Schema::table('analytics_events', function (Blueprint $table): void { + if (! Schema::hasColumn('analytics_events', 'properties_json')) { + $table->json('properties_json')->nullable(); + } + if (! Schema::hasColumn('analytics_events', 'occurred_at')) { + $table->timestamp('occurred_at')->nullable(); + } + }); + Schema::table('analytics_daily', function (Blueprint $table): void { + if (! Schema::hasColumn('analytics_daily', 'checkout_completed_count')) { + $table->unsignedInteger('checkout_completed_count')->default(0); + } + }); + Schema::table('webhook_subscriptions', function (Blueprint $table): void { + if (! Schema::hasColumn('webhook_subscriptions', 'event_type')) { + $table->string('event_type')->nullable()->index(); + } + if (! Schema::hasColumn('webhook_subscriptions', 'app_installation_id')) { + $table->unsignedBigInteger('app_installation_id')->nullable()->index(); + } + }); + Schema::table('webhook_deliveries', function (Blueprint $table): void { + if (! Schema::hasColumn('webhook_deliveries', 'subscription_id')) { + $table->unsignedBigInteger('subscription_id')->nullable()->index(); + } + if (! Schema::hasColumn('webhook_deliveries', 'event_id')) { + $table->string('event_id')->nullable()->index(); + } + if (! Schema::hasColumn('webhook_deliveries', 'attempt_count')) { + $table->unsignedInteger('attempt_count')->default(0); + } + if (! Schema::hasColumn('webhook_deliveries', 'last_attempt_at')) { + $table->timestamp('last_attempt_at')->nullable(); + } + if (! Schema::hasColumn('webhook_deliveries', 'response_code')) { + $table->unsignedSmallInteger('response_code')->nullable(); + } + if (! Schema::hasColumn('webhook_deliveries', 'response_body_snippet')) { + $table->text('response_body_snippet')->nullable(); + } + }); + } + + public function down(): void + { + foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) { + if (Schema::hasColumn($tableName, $column)) { + Schema::table($tableName, function (Blueprint $table) use ($column): void { + $table->dropColumn($column); + }); + } + } + } +}; From fb83f734f12c3f516e6a111c6e71baa1154fc996 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 21 Aug 2026 02:17:00 +0200 Subject: [PATCH 6/9] Complete shop platform implementation and hardening --- .env.example | 2 +- app/Auth/StoreScopedPasswordBrokerManager.php | 39 ++ app/Auth/StoreScopedTokenRepository.php | 72 ++++ app/Contracts/TaxProvider.php | 11 + app/Http/Controllers/Api/AdminController.php | 235 +++++++++- .../Controllers/Api/PlatformController.php | 146 +++++++ .../Api/StorefrontAnalyticsController.php | 18 +- .../Api/StorefrontCartController.php | 8 +- .../Api/StorefrontCheckoutController.php | 38 +- .../Api/StorefrontOrderController.php | 22 + .../Api/StorefrontSearchController.php | 29 ++ app/Http/Middleware/EnsureApiAbility.php | 66 +++ app/Http/Middleware/ResolveStore.php | 31 +- app/Http/Requests/ApplyDiscountRequest.php | 26 ++ .../Requests/CreateFulfillmentRequest.php | 35 ++ .../Requests/CreateOrganizationRequest.php | 29 ++ .../Requests/CreatePlatformStoreRequest.php | 34 ++ app/Http/Requests/CreateRefundRequest.php | 36 ++ app/Http/Requests/InviteStaffRequest.php | 30 ++ app/Http/Requests/LoginRequest.php | 29 ++ .../Requests/PresignMediaUploadRequest.php | 33 ++ app/Http/Requests/RegisterCustomerRequest.php | 30 ++ .../Requests/SetCheckoutAddressRequest.php | 76 ++++ app/Http/Requests/StoreCollectionRequest.php | 36 ++ app/Http/Requests/StoreDiscountRequest.php | 45 ++ app/Http/Requests/StoreInvitationRequest.php | 29 ++ app/Http/Requests/StorePageRequest.php | 27 ++ app/Http/Requests/StoreProductRequest.php | 67 +++ .../Requests/StoreShippingRateRequest.php | 27 ++ .../Requests/StoreShippingZoneRequest.php | 27 ++ app/Http/Requests/StoreThemeRequest.php | 27 ++ app/Http/Requests/UpdateCollectionRequest.php | 36 ++ app/Http/Requests/UpdateDiscountRequest.php | 45 ++ app/Http/Requests/UpdatePageRequest.php | 27 ++ app/Http/Requests/UpdateProductRequest.php | 70 +++ .../Requests/UpdateShippingZoneRequest.php | 27 ++ .../Requests/UpdateStoreSettingsRequest.php | 32 ++ .../Requests/UpdateTaxSettingsRequest.php | 27 ++ .../Requests/UpdateThemeSettingsRequest.php | 27 ++ app/Jobs/CleanupAbandonedCarts.php | 29 +- app/Jobs/DeliverWebhook.php | 15 +- app/Jobs/ProcessMediaUpload.php | 116 ++++- app/Livewire/Admin/Analytics/Index.php | 24 +- app/Livewire/Admin/Apps/Index.php | 18 +- app/Livewire/Admin/Auth/Login.php | 15 +- app/Livewire/Admin/Collections/Create.php | 50 ++- app/Livewire/Admin/Collections/Edit.php | 56 ++- app/Livewire/Admin/Collections/Form.php | 13 + app/Livewire/Admin/Collections/Index.php | 47 +- app/Livewire/Admin/Customers/Index.php | 28 +- app/Livewire/Admin/Customers/Show.php | 160 ++++++- app/Livewire/Admin/Dashboard.php | 17 +- app/Livewire/Admin/Developers/Index.php | 63 ++- app/Livewire/Admin/Discounts/Form.php | 159 ++++++- app/Livewire/Admin/Discounts/Index.php | 52 ++- app/Livewire/Admin/Inventory/Index.php | 61 ++- app/Livewire/Admin/Navigation/Index.php | 68 ++- app/Livewire/Admin/Orders/Index.php | 75 +++- app/Livewire/Admin/Orders/Show.php | 204 ++++++++- app/Livewire/Admin/Pages/Create.php | 39 +- app/Livewire/Admin/Pages/Edit.php | 50 ++- app/Livewire/Admin/Pages/Form.php | 13 + app/Livewire/Admin/Pages/Index.php | 46 +- app/Livewire/Admin/Products/Index.php | 38 +- app/Livewire/Admin/Search/Settings.php | 50 ++- app/Livewire/Admin/Settings/Domains.php | 74 ++++ app/Livewire/Admin/Settings/General.php | 48 ++- app/Livewire/Admin/Settings/Shipping.php | 197 ++++++++- app/Livewire/Admin/Settings/Taxes.php | 81 +++- app/Livewire/Admin/Themes/Editor.php | 33 +- app/Livewire/Admin/Themes/Index.php | 45 +- .../Storefront/Account/Auth/Login.php | 15 +- .../Storefront/Account/Auth/Register.php | 6 +- app/Livewire/Storefront/Cart/Show.php | 93 +++- app/Livewire/Storefront/CartDrawer.php | 44 ++ .../Storefront/Checkout/Confirmation.php | 52 ++- app/Livewire/Storefront/Checkout/Show.php | 366 +++++++++++++++- app/Livewire/Storefront/Products/Show.php | 26 +- app/Models/Collection.php | 2 +- app/Models/Customer.php | 10 +- app/Models/NavigationMenu.php | 2 +- app/Models/ProductMedia.php | 13 + app/Models/Refund.php | 4 +- app/Models/SearchQuery.php | 7 +- app/Models/StoreInvitation.php | 24 ++ app/Models/Theme.php | 23 +- app/Models/ThemeSetting.php | 18 +- app/Models/User.php | 3 +- app/Models/WebhookSubscription.php | 6 +- app/Providers/AppServiceProvider.php | 8 + app/Services/AnalyticsService.php | 4 +- app/Services/AuditLogger.php | 22 + app/Services/CartService.php | 2 +- app/Services/FulfillmentService.php | 7 +- app/Services/OrderService.php | 65 ++- app/Services/PaymentService.php | 2 +- app/Services/ProductService.php | 204 ++++++++- app/Services/RefundService.php | 23 +- app/Services/ShippingCalculator.php | 30 +- app/Services/Tax/ManualTaxProvider.php | 51 +++ app/Services/Tax/StripeTaxProvider.php | 21 + app/Services/TaxCalculator.php | 11 +- app/Services/VariantMatrixService.php | 4 + app/ValueObjects/TaxCalculationRequest.php | 18 + bootstrap/app.php | 1 + config/auth.php | 4 + config/cors.php | 12 + config/logging.php | 8 + config/sanctum.php | 87 ++++ config/session.php | 6 +- config/shop.php | 1 + database/factories/AnalyticsDailyFactory.php | 35 ++ database/factories/AnalyticsEventFactory.php | 49 +++ database/factories/CartFactory.php | 19 +- database/factories/CartLineFactory.php | 40 ++ database/factories/CheckoutFactory.php | 66 +++ database/factories/CollectionFactory.php | 18 +- database/factories/CustomerAddressFactory.php | 35 ++ database/factories/CustomerFactory.php | 13 +- database/factories/DiscountFactory.php | 39 +- database/factories/FulfillmentFactory.php | 53 +++ database/factories/FulfillmentLineFactory.php | 28 ++ database/factories/InventoryItemFactory.php | 18 +- database/factories/NavigationItemFactory.php | 48 +++ database/factories/NavigationMenuFactory.php | 29 ++ database/factories/OrderFactory.php | 56 ++- database/factories/OrderLineFactory.php | 50 +++ database/factories/PageFactory.php | 46 ++ database/factories/PaymentFactory.php | 65 +++ database/factories/ProductFactory.php | 40 +- database/factories/ProductMediaFactory.php | 41 ++ database/factories/ProductOptionFactory.php | 28 ++ .../factories/ProductOptionValueFactory.php | 28 ++ database/factories/ProductVariantFactory.php | 45 +- database/factories/RefundFactory.php | 44 ++ database/factories/SearchSettingFactory.php | 32 ++ database/factories/ShippingRateFactory.php | 44 ++ database/factories/ShippingZoneFactory.php | 30 ++ database/factories/StoreDomainFactory.php | 17 +- database/factories/StoreFactory.php | 12 +- database/factories/StoreInvitationFactory.php | 29 ++ database/factories/TaxSettingsFactory.php | 34 ++ database/factories/ThemeFactory.php | 36 ++ database/factories/ThemeSettingFactory.php | 27 ++ database/factories/UserFactory.php | 12 +- ..._add_line_allocations_to_refunds_table.php | 28 ++ ...144_add_remaining_spec_contract_fields.php | 123 ++++++ ...ustomer_password_reset_tokens_by_store.php | 52 +++ ...s_password_reset_and_webhook_contracts.php | 269 ++++++++++++ ...move_legacy_settings_from_themes_table.php | 52 +++ ..._000335_create_store_invitations_table.php | 35 ++ database/seeders/AnalyticsSeeder.php | 16 + database/seeders/CollectionSeeder.php | 32 ++ database/seeders/CustomerSeeder.php | 78 ++++ database/seeders/DatabaseSeeder.php | 21 +- database/seeders/DiscountSeeder.php | 33 ++ database/seeders/NavigationSeeder.php | 16 + database/seeders/OrderSeeder.php | 137 ++++++ database/seeders/OrganizationSeeder.php | 6 +- database/seeders/PageSeeder.php | 16 + database/seeders/ProductSeeder.php | 159 +++++++ database/seeders/SearchSettingsSeeder.php | 16 + database/seeders/ShippingSeeder.php | 51 +++ database/seeders/ShopSeeder.php | 138 ++---- database/seeders/StoreDomainSeeder.php | 16 +- database/seeders/StoreSeeder.php | 14 +- database/seeders/StoreSettingsSeeder.php | 15 +- database/seeders/StoreUserSeeder.php | 34 ++ database/seeders/TaxSettingsSeeder.php | 23 + database/seeders/ThemeSeeder.php | 28 ++ database/seeders/UserSeeder.php | 31 ++ resources/views/errors/404.blade.php | 2 + resources/views/errors/503.blade.php | 2 + resources/views/layouts/admin.blade.php | 19 +- resources/views/layouts/auth.blade.php | 2 +- resources/views/layouts/storefront.blade.php | 14 +- .../livewire/admin/analytics/index.blade.php | 4 +- .../views/livewire/admin/apps/index.blade.php | 4 +- .../views/livewire/admin/auth/login.blade.php | 13 +- .../admin/collections/create.blade.php | 4 +- .../livewire/admin/collections/edit.blade.php | 4 +- .../livewire/admin/collections/form.blade.php | 10 + .../admin/collections/index.blade.php | 21 +- .../livewire/admin/customers/index.blade.php | 7 +- .../livewire/admin/customers/show.blade.php | 13 +- .../views/livewire/admin/dashboard.blade.php | 7 +- .../livewire/admin/developers/index.blade.php | 25 +- .../livewire/admin/discounts/form.blade.php | 12 +- .../livewire/admin/discounts/index.blade.php | 7 +- .../livewire/admin/inventory/index.blade.php | 4 +- .../livewire/admin/navigation/index.blade.php | 4 +- .../livewire/admin/orders/index.blade.php | 58 ++- .../livewire/admin/orders/show.blade.php | 63 ++- .../livewire/admin/pages/create.blade.php | 4 +- .../views/livewire/admin/pages/edit.blade.php | 4 +- .../views/livewire/admin/pages/form.blade.php | 1 + .../livewire/admin/pages/index.blade.php | 6 +- .../livewire/admin/products/index.blade.php | 2 +- .../livewire/admin/search/settings.blade.php | 4 +- .../livewire/admin/settings/domains.blade.php | 3 + .../livewire/admin/settings/general.blade.php | 12 +- .../admin/settings/shipping.blade.php | 9 +- .../livewire/admin/settings/taxes.blade.php | 11 +- .../livewire/admin/themes/editor.blade.php | 4 +- .../livewire/admin/themes/index.blade.php | 4 +- .../storefront/account/auth/login.blade.php | 13 +- .../account/auth/register.blade.php | 17 +- .../livewire/storefront/cart-drawer.blade.php | 10 + .../livewire/storefront/cart/show.blade.php | 166 +++++++- .../checkout/confirmation.blade.php | 99 ++++- .../storefront/checkout/show.blade.php | 401 +++++++++++++++++- .../storefront/products/show.blade.php | 2 +- routes/api.php | 44 +- routes/web.php | 20 +- specs/progress.md | 37 +- tests/Feature/AdminApiTest.php | 14 +- tests/Feature/ApiTokenTest.php | 51 +++ tests/Feature/CommerceFlowTest.php | 37 +- .../CommerceValidationAndCleanupTest.php | 151 +++++++ tests/Feature/ContractBehaviorTest.php | 117 +++++ tests/Feature/PlatformApiAndMediaTest.php | 138 ++++++ tests/Feature/SearchAnalyticsWebhookTest.php | 2 +- tests/Feature/SeedDataTest.php | 95 +++++ tests/Unit/DomainServicesTest.php | 2 +- 224 files changed, 8895 insertions(+), 469 deletions(-) create mode 100644 app/Auth/StoreScopedPasswordBrokerManager.php create mode 100644 app/Auth/StoreScopedTokenRepository.php create mode 100644 app/Contracts/TaxProvider.php create mode 100644 app/Http/Controllers/Api/PlatformController.php create mode 100644 app/Http/Controllers/Api/StorefrontOrderController.php create mode 100644 app/Http/Controllers/Api/StorefrontSearchController.php create mode 100644 app/Http/Middleware/EnsureApiAbility.php create mode 100644 app/Http/Requests/ApplyDiscountRequest.php create mode 100644 app/Http/Requests/CreateFulfillmentRequest.php create mode 100644 app/Http/Requests/CreateOrganizationRequest.php create mode 100644 app/Http/Requests/CreatePlatformStoreRequest.php create mode 100644 app/Http/Requests/CreateRefundRequest.php create mode 100644 app/Http/Requests/InviteStaffRequest.php create mode 100644 app/Http/Requests/LoginRequest.php create mode 100644 app/Http/Requests/PresignMediaUploadRequest.php create mode 100644 app/Http/Requests/RegisterCustomerRequest.php create mode 100644 app/Http/Requests/SetCheckoutAddressRequest.php create mode 100644 app/Http/Requests/StoreCollectionRequest.php create mode 100644 app/Http/Requests/StoreDiscountRequest.php create mode 100644 app/Http/Requests/StoreInvitationRequest.php create mode 100644 app/Http/Requests/StorePageRequest.php create mode 100644 app/Http/Requests/StoreProductRequest.php create mode 100644 app/Http/Requests/StoreShippingRateRequest.php create mode 100644 app/Http/Requests/StoreShippingZoneRequest.php create mode 100644 app/Http/Requests/StoreThemeRequest.php create mode 100644 app/Http/Requests/UpdateCollectionRequest.php create mode 100644 app/Http/Requests/UpdateDiscountRequest.php create mode 100644 app/Http/Requests/UpdatePageRequest.php create mode 100644 app/Http/Requests/UpdateProductRequest.php create mode 100644 app/Http/Requests/UpdateShippingZoneRequest.php create mode 100644 app/Http/Requests/UpdateStoreSettingsRequest.php create mode 100644 app/Http/Requests/UpdateTaxSettingsRequest.php create mode 100644 app/Http/Requests/UpdateThemeSettingsRequest.php create mode 100644 app/Livewire/Admin/Collections/Form.php create mode 100644 app/Livewire/Admin/Pages/Form.php create mode 100644 app/Livewire/Admin/Settings/Domains.php create mode 100644 app/Livewire/Storefront/CartDrawer.php create mode 100644 app/Models/StoreInvitation.php create mode 100644 app/Services/AuditLogger.php create mode 100644 app/Services/Tax/ManualTaxProvider.php create mode 100644 app/Services/Tax/StripeTaxProvider.php create mode 100644 app/ValueObjects/TaxCalculationRequest.php create mode 100644 config/cors.php create mode 100644 config/sanctum.php create mode 100644 database/factories/AnalyticsDailyFactory.php create mode 100644 database/factories/AnalyticsEventFactory.php create mode 100644 database/factories/CartLineFactory.php create mode 100644 database/factories/CheckoutFactory.php create mode 100644 database/factories/CustomerAddressFactory.php create mode 100644 database/factories/FulfillmentFactory.php create mode 100644 database/factories/FulfillmentLineFactory.php create mode 100644 database/factories/NavigationItemFactory.php create mode 100644 database/factories/NavigationMenuFactory.php create mode 100644 database/factories/OrderLineFactory.php create mode 100644 database/factories/PageFactory.php create mode 100644 database/factories/PaymentFactory.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/RefundFactory.php create mode 100644 database/factories/SearchSettingFactory.php create mode 100644 database/factories/ShippingRateFactory.php create mode 100644 database/factories/ShippingZoneFactory.php create mode 100644 database/factories/StoreInvitationFactory.php create mode 100644 database/factories/TaxSettingsFactory.php create mode 100644 database/factories/ThemeFactory.php create mode 100644 database/factories/ThemeSettingFactory.php create mode 100644 database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php create mode 100644 database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php create mode 100644 database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php create mode 100644 database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php create mode 100644 database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php create mode 100644 database/migrations/2026_08_21_000335_create_store_invitations_table.php create mode 100644 database/seeders/AnalyticsSeeder.php create mode 100644 database/seeders/CollectionSeeder.php create mode 100644 database/seeders/CustomerSeeder.php create mode 100644 database/seeders/DiscountSeeder.php create mode 100644 database/seeders/NavigationSeeder.php create mode 100644 database/seeders/OrderSeeder.php create mode 100644 database/seeders/PageSeeder.php create mode 100644 database/seeders/ProductSeeder.php create mode 100644 database/seeders/SearchSettingsSeeder.php create mode 100644 database/seeders/ShippingSeeder.php create mode 100644 database/seeders/StoreUserSeeder.php create mode 100644 database/seeders/TaxSettingsSeeder.php create mode 100644 database/seeders/ThemeSeeder.php create mode 100644 database/seeders/UserSeeder.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 resources/views/livewire/admin/collections/form.blade.php create mode 100644 resources/views/livewire/admin/pages/form.blade.php create mode 100644 resources/views/livewire/admin/settings/domains.blade.php create mode 100644 resources/views/livewire/storefront/cart-drawer.blade.php create mode 100644 tests/Feature/ApiTokenTest.php create mode 100644 tests/Feature/CommerceValidationAndCleanupTest.php create mode 100644 tests/Feature/ContractBehaviorTest.php create mode 100644 tests/Feature/PlatformApiAndMediaTest.php create mode 100644 tests/Feature/SeedDataTest.php diff --git a/.env.example b/.env.example index 9c450749..3ba18ff7 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,7 @@ DB_CONNECTION=sqlite SESSION_DRIVER=file SESSION_LIFETIME=120 -SESSION_ENCRYPT=false +SESSION_ENCRYPT=true SESSION_PATH=/ SESSION_DOMAIN=null diff --git a/app/Auth/StoreScopedPasswordBrokerManager.php b/app/Auth/StoreScopedPasswordBrokerManager.php new file mode 100644 index 00000000..8de9c786 --- /dev/null +++ b/app/Auth/StoreScopedPasswordBrokerManager.php @@ -0,0 +1,39 @@ +app['config']['app.key']; + + if (str_starts_with($key, 'base64:')) { + $key = base64_decode(substr($key, 7)); + } + + if (($config['table'] ?? null) === 'customer_password_reset_tokens') { + return new StoreScopedTokenRepository( + $this->app['db']->connection($config['connection'] ?? null), + $this->app['hash'], + $config['table'], + $key, + ($config['expire'] ?? 60) * 60, + $config['throttle'] ?? 0, + ); + } + + return new DatabaseTokenRepository( + $this->app['db']->connection($config['connection'] ?? null), + $this->app['hash'], + $config['table'], + $key, + ($config['expire'] ?? 60) * 60, + $config['throttle'] ?? 0, + ); + } +} diff --git a/app/Auth/StoreScopedTokenRepository.php b/app/Auth/StoreScopedTokenRepository.php new file mode 100644 index 00000000..54a7acd9 --- /dev/null +++ b/app/Auth/StoreScopedTokenRepository.php @@ -0,0 +1,72 @@ +getEmailForPasswordReset(); + $this->deleteExisting($user); + $token = $this->createNewToken(); + $this->scopedTable()->insert($this->getPayload($email, $token)); + + return $token; + } + + public function exists(CanResetPasswordContract $user, $token): bool + { + $record = (array) $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->first(); + + return $record !== [] + && ! $this->tokenExpired($record['created_at']) + && $this->getHasher()->check($token, $record['token']); + } + + public function recentlyCreatedToken(CanResetPasswordContract $user): bool + { + $record = (array) $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->first(); + + return $record !== [] && $this->tokenRecentlyCreated($record['created_at']); + } + + public function delete(CanResetPasswordContract $user): void + { + $this->deleteExisting($user); + } + + public function deleteExpired(): void + { + $expiredAt = Carbon::now()->subSeconds($this->expires); + $this->getTable()->where('created_at', '<', $expiredAt)->delete(); + } + + protected function deleteExisting(CanResetPasswordContract $user): int + { + return $this->scopedTable()->where('email', $user->getEmailForPasswordReset())->delete(); + } + + protected function getPayload($email, #[\SensitiveParameter] $token): array + { + return array_merge(parent::getPayload($email, $token), ['store_id' => $this->currentStoreId()]); + } + + private function scopedTable(): \Illuminate\Database\Query\Builder + { + return $this->getTable()->where('store_id', $this->currentStoreId()); + } + + private function currentStoreId(): int + { + if (! app()->bound('current_store') || ! app('current_store') instanceof Store) { + throw new \LogicException('A current store is required for customer password reset tokens.'); + } + + return (int) app('current_store')->getKey(); + } +} diff --git a/app/Contracts/TaxProvider.php b/app/Contracts/TaxProvider.php new file mode 100644 index 00000000..e6e7dd01 --- /dev/null +++ b/app/Contracts/TaxProvider.php @@ -0,0 +1,11 @@ +paginated($products); } - public function storeProduct(Request $request, int $storeId, ProductService $products): JsonResponse + public function storeProduct(StoreProductRequest $request, int $storeId, ProductService $products): JsonResponse { $this->assertStore($storeId); - $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'vendor' => ['nullable', 'string', 'max:255'], 'product_type' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'in:draft,active,archived'], 'variants' => ['nullable', 'array']]); + $data = $request->validated(); $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]); - return response()->json(['data' => $product->load('variants')->toArray()], 201); + return response()->json(['data' => $product->load(['variants.inventory', 'variants.optionValues.option', 'options.values', 'media', 'collections'])->toArray()], 201); } public function showProduct(int $storeId, int $productId): JsonResponse { $this->assertStore($storeId); - $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'options.values', 'media', 'collections'])->findOrFail($productId); + $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'variants.optionValues.option', 'options.values', 'media', 'collections'])->findOrFail($productId); return response()->json(['data' => $product->toArray()]); } - public function updateProduct(Request $request, int $storeId, int $productId, ProductService $products): JsonResponse + public function updateProduct(UpdateProductRequest $request, int $storeId, int $productId, ProductService $products): JsonResponse { $this->assertStore($storeId); $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId); - $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description' => ['sometimes', 'nullable', 'string'], 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], 'status' => ['sometimes', 'in:draft,active,archived']]); + $data = $request->validated(); - return response()->json(['data' => $products->update($product, $data)->toArray()]); + return response()->json(['data' => $products->update($product, $data)->load(['variants.inventory', 'variants.optionValues.option', 'options.values', 'media', 'collections'])->toArray()]); } public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse @@ -71,21 +96,21 @@ public function collections(Request $request, int $storeId): JsonResponse return $this->paginated($collections); } - public function storeCollection(Request $request, int $storeId): JsonResponse + public function storeCollection(StoreCollectionRequest $request, int $storeId): JsonResponse { $this->assertStore($storeId); - $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'status' => ['nullable', 'in:draft,active,archived'], 'product_ids' => ['nullable', 'array']]); + $data = $request->validated(); $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']); $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId); return response()->json(['data' => $collection->load('products')->toArray()], 201); } - public function updateCollection(Request $request, int $storeId, int $collectionId): JsonResponse + public function updateCollection(UpdateCollectionRequest $request, int $storeId, int $collectionId): JsonResponse { $this->assertStore($storeId); $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId); - $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description_html' => ['sometimes', 'nullable', 'string'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array']]); + $data = $request->validated(); $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null)); if (array_key_exists('product_ids', $data)) { @@ -138,6 +163,192 @@ public function discounts(Request $request, int $storeId): JsonResponse return $this->paginated(Discount::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25))); } + public function storeDiscount(StoreDiscountRequest $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validated(); + $discount = Discount::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => $data['status'] ?? 'active', 'usage_count' => 0]); + + return response()->json(['data' => $discount], 201); + } + + public function updateDiscount(UpdateDiscountRequest $request, int $storeId, int $discountId): JsonResponse + { + $this->assertStore($storeId); + $discount = Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId); + $discount->update($request->validated()); + + return response()->json(['data' => $discount->refresh()]); + } + + public function deleteDiscount(int $storeId, int $discountId): JsonResponse + { + $this->assertStore($storeId); + Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId)->delete(); + + return response()->json(['message' => 'Discount deleted.']); + } + + public function fulfillOrder(CreateFulfillmentRequest $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse + { + $this->assertStore($storeId); + $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($orderId); + $data = $request->validated(); + $fulfillment = $fulfillments->create($order, $data['lines'], array_filter(['tracking_company' => $data['tracking_company'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, 'tracking_url' => $data['tracking_url'] ?? null])); + + return response()->json(['data' => $fulfillment->toArray()], 201); + } + + public function refundOrder(CreateRefundRequest $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse + { + $this->assertStore($storeId); + $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('payments')->findOrFail($orderId); + $data = $request->validated(); + $payment = $order->payments->firstWhere('id', $data['payment_id'] ?? null) ?? $order->payments->firstWhere('status', 'captured'); + abort_unless($payment !== null, 422, 'No captured payment is available.'); + $refund = $refunds->create($order, $payment, $data['lines'] ?? $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? []); + + return response()->json(['data' => $refund->toArray()], 201); + } + + public function shippingZones(int $storeId): JsonResponse + { + $this->assertStore($storeId); + + return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]); + } + + public function storeShippingZone(StoreShippingZoneRequest $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validated(); + + return response()->json(['data' => ShippingZone::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId])], 201); + } + + public function updateShippingZone(UpdateShippingZoneRequest $request, int $storeId, int $zoneId): JsonResponse + { + $this->assertStore($storeId); + $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId); + $zone->update($request->validated()); + + return response()->json(['data' => $zone->refresh()]); + } + + public function storeShippingRate(StoreShippingRateRequest $request, int $storeId, int $zoneId): JsonResponse + { + $this->assertStore($storeId); + ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId); + $data = $request->validated(); + + return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'is_active' => $data['is_active'] ?? true])], 201); + } + + public function taxSettings(int $storeId): JsonResponse + { + $this->assertStore($storeId); + + return response()->json(['data' => TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $storeId])]); + } + + public function updateTaxSettings(UpdateTaxSettingsRequest $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validated(); + $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data); + + return response()->json(['data' => $settings]); + } + + public function pages(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + + return $this->paginated(Page::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25))); + } + + public function storePage(StorePageRequest $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validated(); + $page = Page::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'handle' => $data['handle'] ?? Str::slug($data['title']), 'published_at' => $data['status'] === 'published' ? now() : null]); + + return response()->json(['data' => $page], 201); + } + + public function updatePage(UpdatePageRequest $request, int $storeId, int $pageId): JsonResponse + { + $this->assertStore($storeId); + $page = Page::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($pageId); + $data = $request->validated(); + if (($data['status'] ?? null) === 'published') { + $data['published_at'] = $page->published_at ?? now(); + } + $page->update($data); + + return response()->json(['data' => $page->refresh()]); + } + + public function deletePage(int $storeId, int $pageId): JsonResponse + { + $this->assertStore($storeId); + Page::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($pageId)->delete(); + + return response()->json(['message' => 'Page deleted.']); + } + + public function storeTheme(StoreThemeRequest $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validated(); + $theme = Theme::withoutGlobalScopes()->create(['store_id' => $storeId, 'name' => $data['name'], 'version' => $data['version'] ?? null, 'status' => 'draft']); + $theme->settings()->create(['settings_json' => $data['settings'] ?? []]); + + return response()->json(['data' => $theme->load('settings')], 201); + } + + public function publishTheme(int $storeId, int $themeId): JsonResponse + { + $this->assertStore($storeId); + $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); + Theme::withoutGlobalScopes()->where('store_id', $storeId)->update(['status' => 'draft']); + $theme->update(['status' => 'published']); + + return response()->json(['data' => $theme->refresh()]); + } + + public function updateThemeSettings(UpdateThemeSettingsRequest $request, int $storeId, int $themeId): JsonResponse + { + $this->assertStore($storeId); + $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); + $theme->settings()->updateOrCreate(['theme_id' => $theme->getKey()], ['settings_json' => $request->validated()['settings']]); + + return response()->json(['data' => $theme->refresh()->load('settings')]); + } + + public function reindex(SearchService $search, int $storeId): JsonResponse + { + $this->assertStore($storeId); + Product::withoutGlobalScopes()->where('store_id', $storeId)->each(fn (Product $product) => $search->syncProduct($product)); + + return response()->json(['status' => 'completed']); + } + + public function searchStatus(int $storeId): JsonResponse + { + $this->assertStore($storeId); + + return response()->json(['status' => 'ready', 'product_count' => Product::withoutGlobalScopes()->where('store_id', $storeId)->count()]); + } + + public function analyticsSummary(Request $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $days = \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $storeId)->whereBetween('date', [$request->input('from', now()->subDays(29)->toDateString()), $request->input('to', now()->toDateString())])->get(); + + return response()->json(['data' => ['visits' => (int) $days->sum('visits_count'), 'orders' => (int) $days->sum('orders_count'), 'revenue_amount' => (int) $days->sum('revenue_amount'), 'checkout_completed' => (int) $days->sum('checkout_completed_count'), 'days' => $days]]); + } + private function assertStore(int $storeId): void { abort_unless((int) app('current_store')->getKey() === $storeId, 404); @@ -149,7 +360,7 @@ private function syncCollectionProducts(Collection $collection, array $productId $collection->products()->sync(array_fill_keys($validIds, ['position' => 0])); } - private function paginated($paginator): JsonResponse + private function paginated(LengthAwarePaginator $paginator): JsonResponse { return response()->json(['data' => $paginator->items(), 'meta' => ['current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'last_page' => $paginator->lastPage()]]); } diff --git a/app/Http/Controllers/Api/PlatformController.php b/app/Http/Controllers/Api/PlatformController.php new file mode 100644 index 00000000..6ae57c23 --- /dev/null +++ b/app/Http/Controllers/Api/PlatformController.php @@ -0,0 +1,146 @@ +validated(); + $slug = Str::slug($data['name']); + $suffix = 1; + while (Organization::query()->where('slug', $slug)->exists()) { + $slug = Str::slug($data['name']).'-'.$suffix++; + } + + return response()->json(['data' => Organization::create([...$data, 'slug' => $slug, 'status' => 'active'])], 201); + } + + public function storeStore(CreatePlatformStoreRequest $request): JsonResponse + { + $data = $request->validated(); + $store = Store::create([...$data, 'status' => 'active']); + $store->users()->syncWithoutDetaching([ + request()->user('sanctum')->getKey() => ['role' => 'owner'], + ]); + + return response()->json(['data' => $store], 201); + } + + public function invite(StoreInvitationRequest $request, int $storeId): JsonResponse + { + $store = $this->store($storeId); + $data = $request->validated(); + $user = User::query()->where('email', $data['email'])->first(); + + if ($user !== null && $store->users()->whereKey($user->getKey())->exists()) { + return response()->json(['message' => 'The user is already a member of this store.'], 409); + } + + $invitation = StoreInvitation::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'email' => $data['email']], + ['role' => $data['role'], 'invited_at' => now(), 'expires_at' => now()->addDays(7), 'accepted_at' => null], + ); + + return response()->json(['data' => ['email' => $invitation->email, 'role' => $invitation->role, 'invited_at' => $invitation->invited_at, 'expires_at' => $invitation->expires_at]], 201); + } + + public function me(int $storeId): JsonResponse + { + $store = $this->store($storeId); + $user = request()->user('sanctum'); + $membership = $user?->stores()->whereKey($store->getKey())->first(); + abort_unless($membership !== null, 403); + + return response()->json(['data' => [ + 'user_id' => $user->getKey(), + 'store_id' => $store->getKey(), + 'role' => $membership->pivot->role->value ?? $membership->pivot->role, + 'email' => $user->email, + 'name' => $user->name, + 'permissions' => $this->permissionsFor($membership->pivot->role->value ?? (string) $membership->pivot->role), + ]]); + } + + public function presignMediaUpload(PresignMediaUploadRequest $request, int $storeId, int $productId): JsonResponse + { + $store = $this->store($storeId); + $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->findOrFail($productId); + $data = $request->validated(); + $extension = strtolower(pathinfo($data['filename'], PATHINFO_EXTENSION)); + $storageKey = 'stores/'.$store->getKey().'/products/'.$product->getKey().'/media/'.Str::uuid().'.'.$extension; + $disk = Storage::disk('public'); + $media = ProductMedia::query()->create([ + 'product_id' => $product->getKey(), + 'type' => str_starts_with($data['content_type'], 'video/') ? 'video' : 'image', + 'path' => $storageKey, + 'storage_key' => $storageKey, + 'url' => $disk->url($storageKey), + 'mime_type' => $data['content_type'], + 'byte_size' => $data['byte_size'], + 'status' => 'processing', + 'position' => (int) $product->media()->max('position') + 1, + ]); + + $uploadUrl = $disk->url($storageKey); + if (method_exists($disk, 'temporaryUploadUrl')) { + try { + $uploadUrl = $disk->temporaryUploadUrl($storageKey, now()->addMinutes(10), ['ContentType' => $data['content_type']]); + } catch (Throwable) { + // Local development disks do not support presigned uploads. + } + } + + if (config('queue.default') !== 'sync') { + ProcessMediaUpload::dispatch($media)->delay(now()->addMinutes(10)); + } + + return response()->json(['upload_url' => $uploadUrl, 'method' => 'PUT', 'headers' => ['Content-Type' => $data['content_type']], 'storage_key' => $storageKey, 'media_id' => $media->getKey(), 'expires_at' => now()->addMinutes(10)], 201); + } + + public function completeMediaUpload(int $storeId, int $productId, int $mediaId): JsonResponse + { + $store = $this->store($storeId); + $product = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->findOrFail($productId); + $media = $product->media()->whereKey($mediaId)->firstOrFail(); + $disk = Storage::disk('public'); + $storageKey = $media->storage_key ?: $media->path; + + abort_unless($storageKey !== null && $disk->exists($storageKey), 409, 'The media upload has not completed.'); + ProcessMediaUpload::dispatch($media); + + return response()->json(['data' => $media->refresh()], 202); + } + + private function store(int $storeId): Store + { + return Store::query()->findOrFail($storeId); + } + + /** @return array */ + private function permissionsFor(string $role): array + { + return match ($role) { + 'owner', 'admin' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'write-settings', 'read-customers', 'write-customers', 'read-analytics', 'read-content', 'write-content'], + 'staff' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'read-customers'], + default => ['read-products', 'read-orders', 'read-customers'], + }; + } +} diff --git a/app/Http/Controllers/Api/StorefrontAnalyticsController.php b/app/Http/Controllers/Api/StorefrontAnalyticsController.php index 47182ce1..da64edfd 100644 --- a/app/Http/Controllers/Api/StorefrontAnalyticsController.php +++ b/app/Http/Controllers/Api/StorefrontAnalyticsController.php @@ -14,12 +14,24 @@ public function __construct(private readonly AnalyticsService $analytics) {} public function store(Request $request): JsonResponse { $data = $request->validate([ - 'type' => ['required', 'string'], + 'events' => ['sometimes', 'array', 'min:1', 'max:100'], + 'events.*.type' => ['required_with:events', 'string'], + 'events.*.properties' => ['nullable', 'array'], + 'events.*.session_id' => ['nullable', 'string', 'max:255'], + 'events.*.client_event_id' => ['nullable', 'string', 'max:255'], + 'events.*.occurred_at' => ['nullable', 'date'], + 'type' => ['required_without:events', 'string'], 'properties' => ['nullable', 'array'], + 'session_id' => ['nullable', 'string', 'max:255'], 'client_event_id' => ['nullable', 'string', 'max:255'], + 'occurred_at' => ['nullable', 'date'], ]); - $event = $this->analytics->track(app('current_store'), $data['type'], $data['properties'] ?? [], $request->hasSession() ? $request->session()->getId() : null, $request->user('customer')?->getKey(), $data['client_event_id'] ?? null); + $events = $data['events'] ?? [$data]; + $stored = []; + foreach ($events as $event) { + $stored[] = $this->analytics->track(app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'] ?? ($request->hasSession() ? $request->session()->getId() : null), $request->user('customer')?->getKey(), $event['client_event_id'] ?? null, isset($event['occurred_at']) ? new \DateTimeImmutable($event['occurred_at']) : null); + } - return response()->json(['id' => $event->getKey(), 'status' => 'accepted'], 202); + return response()->json(['ids' => collect($stored)->map->getKey()->all(), 'accepted' => count($stored)], 202); } } diff --git a/app/Http/Controllers/Api/StorefrontCartController.php b/app/Http/Controllers/Api/StorefrontCartController.php index 8194fedc..44e7eccd 100644 --- a/app/Http/Controllers/Api/StorefrontCartController.php +++ b/app/Http/Controllers/Api/StorefrontCartController.php @@ -84,14 +84,14 @@ public function removeLine(Request $request, int $cartId, int $lineId): JsonResp private function cart(int $cartId): Cart { - $cart = Cart::query()->with(['lines.variant.product.media', 'lines.variant.inventory'])->findOrFail($cartId); + $cart = Cart::query() + ->where('store_id', app('current_store')->getKey()) + ->with(['lines.variant.product.media', 'lines.variant.inventory']) + ->findOrFail($cartId); $customerId = request()->user('customer')?->getKey(); if ($customerId !== null) { abort_unless((int) $cart->customer_id === (int) $customerId, 404); - } else { - $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]); - abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404); } return $cart; diff --git a/app/Http/Controllers/Api/StorefrontCheckoutController.php b/app/Http/Controllers/Api/StorefrontCheckoutController.php index 8e346588..7e7707ac 100644 --- a/app/Http/Controllers/Api/StorefrontCheckoutController.php +++ b/app/Http/Controllers/Api/StorefrontCheckoutController.php @@ -6,6 +6,8 @@ use App\Exceptions\InsufficientInventoryException; use App\Exceptions\InvalidDiscountException; use App\Http\Controllers\Controller; +use App\Http\Requests\ApplyDiscountRequest; +use App\Http\Requests\SetCheckoutAddressRequest; use App\Models\Cart; use App\Models\Checkout; use App\Services\CheckoutService; @@ -24,6 +26,13 @@ public function store(Request $request): JsonResponse { $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]); $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']); + $customerId = $request->user('customer')?->getKey(); + if ($customerId !== null) { + abort_unless((int) $cart->customer_id === (int) $customerId, 404); + } else { + $sessionCartIds = array_filter([$request->session()->get('cart_id'), $request->session()->get('cart_id_'.app('current_store')->getKey())]); + abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404); + } $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer')); $this->pricing->calculate($checkout); @@ -41,11 +50,13 @@ public function show(int $checkoutId): JsonResponse return response()->json($this->payload($checkout)); } - public function address(Request $request, int $checkoutId): JsonResponse + public function address(SetCheckoutAddressRequest $request, int $checkoutId): JsonResponse { - $data = $request->validate(['shipping_address' => ['required', 'array'], 'billing_address' => ['nullable', 'array'], 'use_shipping_as_billing' => ['nullable', 'boolean']]); + $checkout = $this->checkout($checkoutId); + $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true); + $data = $request->validated(); try { - $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true); + $checkout = $this->checkouts->setAddress($checkout, $data['shipping_address'] ?? [], $data['billing_address'] ?? null, $useShippingAsBilling); } catch (\LogicException $exception) { return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); } @@ -65,9 +76,9 @@ public function shippingMethod(Request $request, int $checkoutId): JsonResponse return response()->json($this->payload($checkout)); } - public function applyDiscount(Request $request, int $checkoutId): JsonResponse + public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId): JsonResponse { - $data = $request->validate(['code' => ['required', 'string', 'max:64']]); + $data = $request->validated(); $checkout = $this->checkout($checkoutId); try { $discount = $this->discounts->validate($data['code'], app('current_store'), $checkout->cart); @@ -80,9 +91,24 @@ public function applyDiscount(Request $request, int $checkoutId): JsonResponse return response()->json($this->payload($checkout->refresh())); } + public function removeDiscount(int $checkoutId): JsonResponse + { + $checkout = $this->checkout($checkoutId); + $checkout->update(['discount_code' => null]); + $this->pricing->calculate($checkout->refresh()); + + return response()->json($this->payload($checkout->refresh())); + } + public function pay(Request $request, int $checkoutId): JsonResponse { - $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], 'card_number' => ['nullable', 'string'], 'card_expiry' => ['nullable', 'string'], 'card_cvc' => ['nullable', 'string']]); + $data = $request->validate([ + 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'], + 'card_number' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(?=.*\d)[0-9 ]+$/'], + 'card_expiry' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'], + 'card_cvc' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^\d{3,4}$/'], + 'card_holder' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'max:255'], + ]); try { $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']); $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data); diff --git a/app/Http/Controllers/Api/StorefrontOrderController.php b/app/Http/Controllers/Api/StorefrontOrderController.php new file mode 100644 index 00000000..df7f874b --- /dev/null +++ b/app/Http/Controllers/Api/StorefrontOrderController.php @@ -0,0 +1,22 @@ +with(['lines', 'payments', 'fulfillments.lines'])->where('order_number', $orderNumber)->firstOrFail(); + $customerId = $request->user('customer')?->getKey(); + $token = (string) $request->query('token', ''); + $expected = hash_hmac('sha256', $order->order_number, (string) config('app.key')); + abort_unless(($customerId !== null && (int) $order->customer_id === (int) $customerId) || ($token !== '' && hash_equals($expected, $token)), 404); + + return response()->json(['data' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'fulfillment_status' => $order->fulfillment_status, 'currency' => $order->currency, 'email' => $order->email, '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, 'lines' => $order->lines->map(fn ($line): array => ['id' => $line->id, 'title' => $line->title_snapshot, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'total_amount' => $line->line_total_amount])->all(), 'payments' => $order->payments->map(fn ($payment): array => ['method' => $payment->method, 'status' => $payment->status, 'amount' => $payment->amount])->all(), 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => ['id' => $fulfillment->id, 'status' => $fulfillment->status, 'tracking_number' => $fulfillment->tracking_number])->all()]]); + } +} diff --git a/app/Http/Controllers/Api/StorefrontSearchController.php b/app/Http/Controllers/Api/StorefrontSearchController.php new file mode 100644 index 00000000..dc3ec092 --- /dev/null +++ b/app/Http/Controllers/Api/StorefrontSearchController.php @@ -0,0 +1,29 @@ +validate(['q' => ['nullable', 'string', 'max:200'], 'query' => ['nullable', 'string', 'max:200'], 'vendor' => ['nullable', 'string'], 'min_price' => ['nullable', 'integer', 'min:0'], 'max_price' => ['nullable', 'integer', 'min:0'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:50']]); + $query = $data['q'] ?? $data['query'] ?? ''; + $results = $this->search->search(app('current_store'), $query, array_filter(['vendor' => $data['vendor'] ?? null, 'min_price' => $data['min_price'] ?? null, 'max_price' => $data['max_price'] ?? null], fn ($value): bool => $value !== null), $data['per_page'] ?? 12); + + return response()->json(['data' => collect($results->items())->map(fn ($product): array => ['id' => $product->id, 'title' => $product->title, 'handle' => $product->handle, 'vendor' => $product->vendor, 'price_amount' => $product->defaultVariant()?->price_amount, 'image_url' => $product->media->first()?->url])->values()->all(), 'meta' => ['query' => $query, 'current_page' => $results->currentPage(), 'per_page' => $results->perPage(), 'total' => $results->total(), 'last_page' => $results->lastPage()]]); + } + + public function suggest(Request $request): JsonResponse + { + $data = $request->validate(['q' => ['required', 'string', 'min:2', 'max:80'], 'limit' => ['nullable', 'integer', 'min:1', 'max:10']]); + + return response()->json(['data' => $this->search->autocomplete(app('current_store'), $data['q'], $data['limit'] ?? 8)->map(fn ($product): array => ['id' => $product->id, 'title' => $product->title, 'handle' => $product->handle])->values()->all()]); + } +} diff --git a/app/Http/Middleware/EnsureApiAbility.php b/app/Http/Middleware/EnsureApiAbility.php new file mode 100644 index 00000000..adc88196 --- /dev/null +++ b/app/Http/Middleware/EnsureApiAbility.php @@ -0,0 +1,66 @@ +user('sanctum'); + + abort_unless($request->bearerToken() !== null && $user !== null, 401, 'A Sanctum bearer token is required.'); + + $ability = $this->abilityFor($request); + + abort_unless($ability !== null && $user->tokenCan($ability), 403, 'This token does not have the required ability.'); + + return $next($request); + } + + private function abilityFor(Request $request): ?string + { + $path = $request->path(); + $method = $request->method(); + + if (Str::contains($path, '/platform/')) { + return 'manage-platform'; + } + + if (Str::endsWith($path, '/invites')) { + return 'manage-platform'; + } + + foreach (['products' => 'products', 'collections' => 'collections', 'orders' => 'orders', 'customers' => 'customers', 'discounts' => 'discounts'] as $segment => $resource) { + if (Str::contains($path, '/'.$segment)) { + return in_array($method, ['GET', 'HEAD'], true) ? 'read-'.$resource : 'write-'.$resource; + } + } + + if (Str::contains($path, '/themes')) { + return in_array($method, ['GET', 'HEAD'], true) ? 'read-themes' : 'write-themes'; + } + + if (Str::contains($path, '/pages')) { + return in_array($method, ['GET', 'HEAD'], true) ? 'read-content' : 'write-content'; + } + + if (Str::contains($path, '/shipping') || Str::contains($path, '/tax/')) { + return in_array($method, ['GET', 'HEAD'], true) ? 'read-settings' : 'write-settings'; + } + + if (Str::contains($path, '/analytics')) { + return 'read-analytics'; + } + + if (Str::contains($path, '/search')) { + return in_array($method, ['GET', 'HEAD'], true) ? 'read-products' : 'write-products'; + } + + return null; + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php index 8bcce48a..66db1074 100644 --- a/app/Http/Middleware/ResolveStore.php +++ b/app/Http/Middleware/ResolveStore.php @@ -25,6 +25,11 @@ public function handle(Request $request, Closure $next, string $context = 'store if ($this->isPublicAdminAuthRequest($request)) { return $next($request); } + + if ($this->isAdminApiRequest($request) && $request->user('sanctum') === null) { + return $next($request); + } + $context = $context === 'storefront' && $this->isAdminRequest($request) ? 'admin' : $context; @@ -33,7 +38,7 @@ public function handle(Request $request, Closure $next, string $context = 'store ? $this->resolveAdminStore($request) : $this->resolveStorefrontStore($request); - if ($store === null && $context === 'storefront' && $this->isPublicCustomerAuthRequest($request)) { + if ($store === null && $context === 'storefront' && $this->isPublicCustomerAuthRequest($request) && Store::query()->doesntExist()) { return $next($request); } @@ -75,8 +80,13 @@ private function resolveStorefrontStore(Request $request): ?Store private function resolveAdminStore(Request $request): ?Store { - $storeId = $request->session()->get(config('tenancy.admin_session_key', 'current_store_id')); - $user = $request->user('web') ?? $request->user(); + $isApiRequest = $this->isAdminApiRequest($request); + $storeId = $isApiRequest + ? $request->route('storeId') + : $request->session()->get(config('tenancy.admin_session_key', 'current_store_id')); + $user = $isApiRequest + ? $request->user('sanctum') + : ($request->user('web') ?? $request->user()); if ($storeId === null || $user === null) { return null; @@ -87,6 +97,10 @@ private function resolveAdminStore(Request $request): ?Store private function isAdminRequest(Request $request): bool { + if ($this->isAdminApiRequest($request)) { + return true; + } + $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/'); if ($request->is($prefix, $prefix.'/*') || $request->routeIs($prefix.'.*')) { @@ -96,13 +110,20 @@ private function isAdminRequest(Request $request): bool return $request->is('livewire/update') && str_contains((string) $request->headers->get('referer'), '/admin'); } - private function isPublicAdminAuthRequest(Request $request): bool + private function isAdminApiRequest(Request $request): bool { - return $request->is('admin/login', 'admin/forgot-password', 'admin/reset-password/*'); + $prefix = trim((string) config('tenancy.admin_path_prefix', 'admin'), '/'); + + return $request->is('api/'.$prefix, 'api/'.$prefix.'/*'); } private function isPublicCustomerAuthRequest(Request $request): bool { return $request->is('forgot-password', 'reset-password/*'); } + + private function isPublicAdminAuthRequest(Request $request): bool + { + return $request->is('admin/login', 'admin/forgot-password', 'admin/reset-password/*'); + } } diff --git a/app/Http/Requests/ApplyDiscountRequest.php b/app/Http/Requests/ApplyDiscountRequest.php new file mode 100644 index 00000000..cb534214 --- /dev/null +++ b/app/Http/Requests/ApplyDiscountRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return ['code' => ['required', 'string', 'max:64']]; + } +} diff --git a/app/Http/Requests/CreateFulfillmentRequest.php b/app/Http/Requests/CreateFulfillmentRequest.php new file mode 100644 index 00000000..d4ab90da --- /dev/null +++ b/app/Http/Requests/CreateFulfillmentRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'lines' => ['required', 'array', 'min:1'], + 'lines.*.order_line_id' => ['required', 'integer'], + 'lines.*.quantity' => ['required', 'integer', 'min:1'], + 'tracking_company' => ['nullable', 'string', 'max:100'], + 'tracking_number' => ['nullable', 'string', 'max:255'], + 'tracking_url' => ['nullable', 'url'], + ]; + } +} diff --git a/app/Http/Requests/CreateOrganizationRequest.php b/app/Http/Requests/CreateOrganizationRequest.php new file mode 100644 index 00000000..21fe7f81 --- /dev/null +++ b/app/Http/Requests/CreateOrganizationRequest.php @@ -0,0 +1,29 @@ +user('sanctum')?->tokenCan('manage-platform'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['required', 'email', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/CreatePlatformStoreRequest.php b/app/Http/Requests/CreatePlatformStoreRequest.php new file mode 100644 index 00000000..f95dd4eb --- /dev/null +++ b/app/Http/Requests/CreatePlatformStoreRequest.php @@ -0,0 +1,34 @@ +user('sanctum')?->tokenCan('manage-platform'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'organization_id' => ['required', 'integer', 'exists:organizations,id'], + 'name' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'string', 'lowercase', 'max:63', 'regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', Rule::unique('stores', 'handle')], + 'default_currency' => ['required', 'string', 'size:3', 'alpha', 'uppercase'], + 'default_locale' => ['required', 'string', 'max:10', 'regex:/^[a-z]{2}(?:-[A-Z]{2})?$/'], + 'timezone' => ['required', 'timezone'], + ]; + } +} diff --git a/app/Http/Requests/CreateRefundRequest.php b/app/Http/Requests/CreateRefundRequest.php new file mode 100644 index 00000000..b457b028 --- /dev/null +++ b/app/Http/Requests/CreateRefundRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'payment_id' => ['nullable', 'integer'], + 'amount' => ['nullable', 'integer', 'min:1'], + 'lines' => ['nullable', 'array'], + 'lines.*.order_line_id' => ['required_with:lines', 'integer'], + 'lines.*.quantity' => ['required_with:lines', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:500'], + 'restock' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/InviteStaffRequest.php b/app/Http/Requests/InviteStaffRequest.php new file mode 100644 index 00000000..22a8810c --- /dev/null +++ b/app/Http/Requests/InviteStaffRequest.php @@ -0,0 +1,30 @@ +bound('current_store') && Gate::allows('update', app('current_store')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'role' => ['required', 'in:admin,staff,support'], + ]; + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 00000000..2cb0f09b --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/PresignMediaUploadRequest.php b/app/Http/Requests/PresignMediaUploadRequest.php new file mode 100644 index 00000000..4f7140dd --- /dev/null +++ b/app/Http/Requests/PresignMediaUploadRequest.php @@ -0,0 +1,33 @@ +user('sanctum')?->tokenCan('write-products'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $contentType = (string) $this->input('content_type'); + $maxBytes = $contentType === 'video/mp4' ? 500 * 1024 * 1024 : 50 * 1024 * 1024; + + return [ + 'filename' => ['required', 'string', 'max:255', 'regex:/\.[a-z0-9]{2,5}$/i'], + 'content_type' => ['required', 'in:image/jpeg,image/png,image/webp,image/avif,video/mp4'], + 'byte_size' => ['required', 'integer', 'min:1', 'max:'.$maxBytes], + ]; + } +} diff --git a/app/Http/Requests/RegisterCustomerRequest.php b/app/Http/Requests/RegisterCustomerRequest.php new file mode 100644 index 00000000..2fa2d4b7 --- /dev/null +++ b/app/Http/Requests/RegisterCustomerRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]; + } +} diff --git a/app/Http/Requests/SetCheckoutAddressRequest.php b/app/Http/Requests/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..104ae74f --- /dev/null +++ b/app/Http/Requests/SetCheckoutAddressRequest.php @@ -0,0 +1,76 @@ +|string> + */ + public function rules(): array + { + $requiresShipping = $this->checkoutRequiresShipping(); + $useShippingAsBilling = $this->boolean('use_shipping_as_billing', true); + $rules = [ + 'use_shipping_as_billing' => ['sometimes', 'boolean'], + ...$this->addressRules('shipping_address', $requiresShipping), + ]; + + if (! $useShippingAsBilling) { + $rules = [...$rules, ...$this->addressRules('billing_address', true)]; + } + + return $rules; + } + + /** + * @return array> + */ + private function addressRules(string $key, bool $required): array + { + $presence = $required ? 'required' : 'sometimes'; + $root = $required ? 'required' : 'nullable'; + + return [ + $key => [$root, 'array'], + "{$key}.first_name" => [$presence, 'string', 'max:255'], + "{$key}.last_name" => [$presence, 'string', 'max:255'], + "{$key}.address1" => [$presence, 'string', 'max:500'], + "{$key}.address2" => ['sometimes', 'nullable', 'string', 'max:500'], + "{$key}.company" => ['sometimes', 'nullable', 'string', 'max:255'], + "{$key}.city" => [$presence, 'string', 'max:255'], + "{$key}.province" => ['sometimes', 'nullable', 'string', 'max:255'], + "{$key}.province_code" => ['sometimes', 'nullable', 'string', 'max:10'], + "{$key}.country" => [$presence, 'string', 'max:255'], + "{$key}.country_code" => [$presence, 'string', 'regex:/^[A-Z]{2}$/'], + "{$key}.postal_code" => [$presence, 'string', 'max:20'], + "{$key}.phone" => ['sometimes', 'nullable', 'string', 'max:50'], + ]; + } + + private function checkoutRequiresShipping(): bool + { + $checkoutId = $this->route('checkoutId'); + + if (! is_numeric($checkoutId)) { + return true; + } + + $checkout = Checkout::query()->with('cart.lines.variant')->find((int) $checkoutId); + + return $checkout?->cart?->lines?->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping) ?? true; + } +} diff --git a/app/Http/Requests/StoreCollectionRequest.php b/app/Http/Requests/StoreCollectionRequest.php new file mode 100644 index 00000000..a13034d1 --- /dev/null +++ b/app/Http/Requests/StoreCollectionRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'description_html' => ['nullable', 'string'], + 'type' => ['required', 'in:manual,automated'], + 'rules_json' => ['required_if:type,automated', 'nullable', 'array'], + 'status' => ['nullable', 'in:active,draft,archived'], + 'product_ids' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/StoreDiscountRequest.php b/app/Http/Requests/StoreDiscountRequest.php new file mode 100644 index 00000000..dc0d4ccc --- /dev/null +++ b/app/Http/Requests/StoreDiscountRequest.php @@ -0,0 +1,45 @@ +|string> + */ + public function rules(): array + { + return [ + 'type' => ['required', 'in:code,automatic'], + 'code' => ['required_if:type,code', 'nullable', 'string', 'max:50'], + 'value_type' => ['required', 'in:fixed,percent,free_shipping'], + 'value_amount' => ['required_unless:value_type,free_shipping', 'integer', 'min:0'], + 'minimum_order_amount' => ['nullable', 'integer', 'min:0'], + 'usage_limit' => ['nullable', 'integer', 'min:1'], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + 'rules_json' => ['nullable', 'array'], + 'status' => ['nullable', 'in:active,inactive'], + ]; + } + + protected function withValidator(Validator $validator): void + { + $validator->sometimes('value_amount', ['max:100'], fn (): bool => $this->input('value_type') === 'percent'); + } +} diff --git a/app/Http/Requests/StoreInvitationRequest.php b/app/Http/Requests/StoreInvitationRequest.php new file mode 100644 index 00000000..3ac4c043 --- /dev/null +++ b/app/Http/Requests/StoreInvitationRequest.php @@ -0,0 +1,29 @@ +user('sanctum')?->tokenCan('manage-platform'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:owner,admin,staff,support'], + ]; + } +} diff --git a/app/Http/Requests/StorePageRequest.php b/app/Http/Requests/StorePageRequest.php new file mode 100644 index 00000000..f6280ba6 --- /dev/null +++ b/app/Http/Requests/StorePageRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('create', \App\Models\Page::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'body_html' => ['nullable', 'string'], 'status' => ['required', 'in:draft,published']]; + } +} diff --git a/app/Http/Requests/StoreProductRequest.php b/app/Http/Requests/StoreProductRequest.php new file mode 100644 index 00000000..70b81f3c --- /dev/null +++ b/app/Http/Requests/StoreProductRequest.php @@ -0,0 +1,67 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'description_html' => ['nullable', 'string'], + 'description' => ['nullable', 'string'], + 'status' => ['sometimes', 'in:active,draft,archived'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'options' => ['nullable', 'array', 'max:3'], + 'options.*.name' => ['required', 'string', 'max:255'], + 'options.*.position' => ['required', 'integer', 'between:1,3'], + 'options.*.values' => ['nullable', 'array'], + 'options.*.values.*.value' => ['required', 'string', 'max:255'], + 'options.*.values.*.position' => ['nullable', 'integer', 'min:1'], + 'variants' => ['required', 'array', 'min:1', 'max:100'], + 'variants.*.sku' => ['required', 'string', 'max:255'], + 'variants.*.barcode' => ['nullable', 'string', 'max:255'], + 'variants.*.title' => ['nullable', 'string', 'max:255'], + 'variants.*.price_amount' => ['required', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['nullable', 'string', 'size:3', 'uppercase'], + 'variants.*.weight_g' => ['nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['nullable', 'boolean'], + 'variants.*.is_default' => ['nullable', 'boolean'], + 'variants.*.position' => ['nullable', 'integer', 'min:1'], + 'variants.*.status' => ['nullable', 'in:active,archived'], + 'variants.*.option_values' => ['nullable', 'array'], + 'variants.*.option_values.*.option_name' => ['required', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required', 'string', 'max:255'], + 'variants.*.inventory.quantity_on_hand' => ['nullable', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['nullable', 'in:deny,continue'], + 'collections' => ['nullable', 'array'], + 'collections.*' => [ + 'integer', + Rule::exists('collections', 'id')->where(fn ($query) => $query->where('store_id', app('current_store')->getKey())), + ], + ]; + } +} diff --git a/app/Http/Requests/StoreShippingRateRequest.php b/app/Http/Requests/StoreShippingRateRequest.php new file mode 100644 index 00000000..fa248d27 --- /dev/null +++ b/app/Http/Requests/StoreShippingRateRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('update', app('current_store')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['required', 'integer', 'min:0'], 'currency' => ['required', 'size:3'], 'config_json' => ['nullable', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']]; + } +} diff --git a/app/Http/Requests/StoreShippingZoneRequest.php b/app/Http/Requests/StoreShippingZoneRequest.php new file mode 100644 index 00000000..b526fd3c --- /dev/null +++ b/app/Http/Requests/StoreShippingZoneRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('update', app('current_store')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['name' => ['required', 'string', 'max:255'], 'countries_json' => ['nullable', 'array'], 'regions_json' => ['nullable', 'array']]; + } +} diff --git a/app/Http/Requests/StoreThemeRequest.php b/app/Http/Requests/StoreThemeRequest.php new file mode 100644 index 00000000..25fc3f57 --- /dev/null +++ b/app/Http/Requests/StoreThemeRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('create', \App\Models\Theme::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['name' => ['required', 'string', 'max:255'], 'version' => ['nullable', 'string', 'max:30'], 'settings' => ['nullable', 'array']]; + } +} diff --git a/app/Http/Requests/UpdateCollectionRequest.php b/app/Http/Requests/UpdateCollectionRequest.php new file mode 100644 index 00000000..6476e63c --- /dev/null +++ b/app/Http/Requests/UpdateCollectionRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', 'max:255'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'type' => ['sometimes', 'in:manual,automated'], + 'rules_json' => ['sometimes', 'nullable', 'array'], + 'status' => ['sometimes', 'in:active,draft,archived'], + 'product_ids' => ['sometimes', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UpdateDiscountRequest.php b/app/Http/Requests/UpdateDiscountRequest.php new file mode 100644 index 00000000..e1fd97af --- /dev/null +++ b/app/Http/Requests/UpdateDiscountRequest.php @@ -0,0 +1,45 @@ +|string> + */ + public function rules(): array + { + return [ + 'type' => ['sometimes', 'in:code,automatic'], + 'code' => ['sometimes', 'nullable', 'string', 'max:50'], + 'value_type' => ['sometimes', 'in:fixed,percent,free_shipping'], + 'value_amount' => ['sometimes', 'integer', 'min:0'], + 'minimum_order_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'starts_at' => ['sometimes', 'nullable', 'date'], + 'ends_at' => ['sometimes', 'nullable', 'date', 'after:starts_at'], + 'rules_json' => ['sometimes', 'nullable', 'array'], + 'status' => ['sometimes', 'in:active,inactive'], + ]; + } + + protected function withValidator(Validator $validator): void + { + $validator->sometimes('value_amount', ['max:100'], fn (): bool => $this->input('value_type') === 'percent'); + } +} diff --git a/app/Http/Requests/UpdatePageRequest.php b/app/Http/Requests/UpdatePageRequest.php new file mode 100644 index 00000000..d96a8eac --- /dev/null +++ b/app/Http/Requests/UpdatePageRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('viewAny', \App\Models\Page::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['title' => ['sometimes', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'body_html' => ['sometimes', 'nullable', 'string'], 'status' => ['sometimes', 'in:draft,published']]; + } +} diff --git a/app/Http/Requests/UpdateProductRequest.php b/app/Http/Requests/UpdateProductRequest.php new file mode 100644 index 00000000..9eed72b1 --- /dev/null +++ b/app/Http/Requests/UpdateProductRequest.php @@ -0,0 +1,70 @@ +|string> + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', 'max:255'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'description' => ['sometimes', 'nullable', 'string'], + 'status' => ['sometimes', 'in:active,draft,archived'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tags' => ['sometimes', 'nullable', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'options' => ['sometimes', 'array', 'max:3'], + 'options.*.name' => ['required', 'string', 'max:255'], + 'options.*.position' => ['required', 'integer', 'between:1,3'], + 'options.*.values' => ['nullable', 'array'], + 'options.*.values.*.value' => ['required', 'string', 'max:255'], + 'options.*.values.*.position' => ['nullable', 'integer', 'min:1'], + 'variants' => ['sometimes', 'array', 'min:1', 'max:100'], + 'variants.*.id' => ['nullable', 'integer'], + 'variants.*.sku' => ['sometimes', 'string', 'max:255'], + 'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'], + 'variants.*.title' => ['sometimes', 'nullable', 'string', 'max:255'], + 'variants.*.price_amount' => ['sometimes', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['sometimes', 'nullable', 'string', 'size:3', 'uppercase'], + 'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['sometimes', 'boolean'], + 'variants.*.is_default' => ['sometimes', 'boolean'], + 'variants.*.position' => ['sometimes', 'integer', 'min:1'], + 'variants.*.status' => ['sometimes', 'in:active,archived'], + 'variants.*.option_values' => ['sometimes', 'array'], + 'variants.*.option_values.*.option_name' => ['required', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required', 'string', 'max:255'], + 'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['sometimes', 'in:deny,continue'], + 'remove_variant_ids' => ['sometimes', 'array'], + 'remove_variant_ids.*' => ['integer'], + 'collections' => ['sometimes', 'array'], + 'collections.*' => [ + 'integer', + Rule::exists('collections', 'id')->where(fn ($query) => $query->where('store_id', app('current_store')->getKey())), + ], + ]; + } +} diff --git a/app/Http/Requests/UpdateShippingZoneRequest.php b/app/Http/Requests/UpdateShippingZoneRequest.php new file mode 100644 index 00000000..ca996391 --- /dev/null +++ b/app/Http/Requests/UpdateShippingZoneRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('update', app('current_store')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['name' => ['sometimes', 'string', 'max:255'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']]; + } +} diff --git a/app/Http/Requests/UpdateStoreSettingsRequest.php b/app/Http/Requests/UpdateStoreSettingsRequest.php new file mode 100644 index 00000000..2b58d488 --- /dev/null +++ b/app/Http/Requests/UpdateStoreSettingsRequest.php @@ -0,0 +1,32 @@ +bound('current_store') && Gate::allows('update', app('current_store')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'default_currency' => ['required', 'in:EUR,USD,GBP'], + 'default_locale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'timezone'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaxSettingsRequest.php b/app/Http/Requests/UpdateTaxSettingsRequest.php new file mode 100644 index 00000000..53280e03 --- /dev/null +++ b/app/Http/Requests/UpdateTaxSettingsRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('update', app('current_store')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['mode' => ['sometimes', 'string'], 'provider' => ['sometimes', 'nullable', 'string'], 'prices_include_tax' => ['sometimes', 'boolean'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']]; + } +} diff --git a/app/Http/Requests/UpdateThemeSettingsRequest.php b/app/Http/Requests/UpdateThemeSettingsRequest.php new file mode 100644 index 00000000..99d8162e --- /dev/null +++ b/app/Http/Requests/UpdateThemeSettingsRequest.php @@ -0,0 +1,27 @@ +bound('current_store') && Gate::allows('viewAny', \App\Models\Theme::class); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return ['settings' => ['required', 'array']]; + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php index 3780e0ca..fe86e927 100644 --- a/app/Jobs/CleanupAbandonedCarts.php +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -2,18 +2,43 @@ namespace App\Jobs; +use App\Enums\CheckoutStatus; use App\Models\Cart; +use App\Models\Checkout; +use App\Services\InventoryService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\DB; class CleanupAbandonedCarts implements ShouldQueue { use Dispatchable, InteractsWithQueue, SerializesModels; - public function handle(): void + public function handle(InventoryService $inventory): void { - Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->update(['status' => 'abandoned']); + Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void { + DB::transaction(function () use ($cart, $inventory): void { + $cart->load('lines.variant.inventory'); + $checkouts = Checkout::withoutGlobalScopes() + ->where('cart_id', $cart->getKey()) + ->where('status', CheckoutStatus::PaymentSelected->value) + ->lockForUpdate() + ->get(); + + foreach ($checkouts as $checkout) { + foreach ($cart->lines as $line) { + if ($line->variant?->inventory !== null) { + $inventory->release($line->variant->inventory, $line->quantity); + } + } + + $checkout->update(['status' => CheckoutStatus::Expired]); + } + + $cart->update(['status' => 'abandoned']); + }); + }); } } diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php index 4b79186f..e18ec779 100644 --- a/app/Jobs/DeliverWebhook.php +++ b/app/Jobs/DeliverWebhook.php @@ -30,15 +30,24 @@ public function handle(WebhookService $webhooks): void $this->delivery->load('subscription'); $payload = json_encode($this->delivery->payload, JSON_THROW_ON_ERROR); $subscription = $this->delivery->subscription; + $timestamp = (string) now()->timestamp; $response = Http::withHeaders([ - 'X-Platform-Signature' => $webhooks->sign($payload, $subscription->secret_encrypted), + 'Content-Type' => 'application/json', + 'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted), 'X-Platform-Event' => $this->delivery->event, 'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(), - 'X-Platform-Timestamp' => (string) now()->timestamp, + 'X-Platform-Timestamp' => $timestamp, ])->timeout(10)->post($subscription->target_url, $this->delivery->payload); $this->delivery->increment('attempts'); - $this->delivery->update(['response_status' => $response->status(), 'response_body' => mb_substr($response->body(), 0, 10000)]); + $this->delivery->increment('attempt_count'); + $this->delivery->update([ + 'response_status' => $response->status(), + 'response_code' => $response->status(), + 'response_body' => mb_substr($response->body(), 0, 10000), + 'response_body_snippet' => mb_substr($response->body(), 0, 1000), + 'last_attempt_at' => now(), + ]); if ($response->successful()) { $subscription->update(['consecutive_failures' => 0]); diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php index d9008a86..7e9e0f89 100644 --- a/app/Jobs/ProcessMediaUpload.php +++ b/app/Jobs/ProcessMediaUpload.php @@ -7,21 +7,127 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Storage; +use RuntimeException; use Throwable; class ProcessMediaUpload implements ShouldQueue { use Dispatchable, InteractsWithQueue, SerializesModels; + public int $tries = 3; + + /** @var array */ + public array $backoff = [10, 30, 60]; + public function __construct(public ProductMedia $media) {} public function handle(): void { - try { - $this->media->update(['status' => 'ready']); - } catch (Throwable $exception) { - $this->media->update(['status' => 'failed', 'metadata' => ['error' => $exception->getMessage()]]); - throw $exception; + $media = $this->media->fresh(); + $disk = Storage::disk('public'); + $sourceKey = $media?->storage_key ?: $media?->path; + + if ($media === null || $sourceKey === null || ! $disk->exists($sourceKey)) { + throw new RuntimeException('The uploaded media file is not available yet.'); + } + + $contents = $disk->get($sourceKey); + $mimeType = $media->mime_type ?: $disk->mimeType($sourceKey) ?: 'application/octet-stream'; + $metadata = $media->metadata ?? []; + $width = null; + $height = null; + $variants = ['original' => $sourceKey]; + + if (str_starts_with($mimeType, 'image/')) { + $dimensions = @getimagesizefromstring($contents); + if ($dimensions === false) { + throw new RuntimeException('The uploaded image could not be decoded.'); + } + [$width, $height] = $dimensions; + $variants = $this->writeImageVariants($disk, $sourceKey, $contents, $mimeType, $width, $height); + } + + $metadata['variants'] = $variants; + $metadata['processed_at'] = now()->toIso8601String(); + $media->update([ + 'status' => 'ready', + 'width' => $width, + 'height' => $height, + 'mime_type' => $mimeType, + 'byte_size' => strlen($contents), + 'checksum' => hash('sha256', $contents), + 'url' => $disk->url($sourceKey), + 'metadata' => $metadata, + ]); + } + + public function failed(Throwable $exception): void + { + $media = $this->media->fresh(); + $metadata = $media?->metadata ?? []; + $metadata['error'] = $exception->getMessage(); + $metadata['failed_at'] = now()->toIso8601String(); + $media?->update(['status' => 'failed', 'metadata' => $metadata]); + } + + /** + * @return array + */ + private function writeImageVariants(object $disk, string $sourceKey, string $contents, string $mimeType, int $width, int $height): array + { + $variants = ['original' => $sourceKey]; + $directory = trim(pathinfo($sourceKey, PATHINFO_DIRNAME), '.'); + $extension = strtolower(pathinfo($sourceKey, PATHINFO_EXTENSION)); + + foreach (['thumbnail' => 320, 'medium' => 800, 'large' => 1600] as $name => $maximum) { + if ($width <= $maximum && $height <= $maximum) { + $variants[$name] = $sourceKey; + + continue; + } + + $resized = $this->resize($contents, $mimeType, $width, $height, $maximum); + if ($resized === null) { + $variants[$name] = $sourceKey; + + continue; + } + + $key = $directory.'/'.$name.'.'.$extension; + $disk->put($key, $resized); + $variants[$name] = $key; + } + + return $variants; + } + + private function resize(string $contents, string $mimeType, int $width, int $height, int $maximum): ?string + { + $source = @imagecreatefromstring($contents); + if ($source === false) { + return null; } + + $scale = min($maximum / $width, $maximum / $height); + $targetWidth = max(1, (int) round($width * $scale)); + $targetHeight = max(1, (int) round($height * $scale)); + $target = imagecreatetruecolor($targetWidth, $targetHeight); + imagealphablending($target, false); + imagesavealpha($target, true); + imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); + ob_start(); + + $written = match ($mimeType) { + 'image/png' => imagepng($target, null, 8), + 'image/webp' => function_exists('imagewebp') ? imagewebp($target, null, 85) : false, + default => imagejpeg($target, null, 85), + }; + + $result = $written ? ob_get_clean() : false; + imagedestroy($source); + imagedestroy($target); + + return is_string($result) ? $result : null; } } diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php index 407f3d05..9679da5e 100644 --- a/app/Livewire/Admin/Analytics/Index.php +++ b/app/Livewire/Admin/Analytics/Index.php @@ -2,6 +2,26 @@ namespace App\Livewire\Admin\Analytics; -use App\Livewire\Admin\Section; +use App\Models\AnalyticsDaily; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Index extends Section {} +class Index extends Component +{ + public string $range = '30'; + + public function render(): View + { + $days = AnalyticsDaily::query()->where('date', '>=', now()->subDays((int) $this->range - 1)->toDateString())->orderBy('date')->get(); + $summary = [ + 'visits' => (int) $days->sum('visits_count'), + 'orders' => (int) $days->sum('orders_count'), + 'revenue' => (int) $days->sum('revenue_amount'), + 'add_to_cart' => (int) $days->sum('add_to_cart_count'), + 'checkout_started' => (int) $days->sum('checkout_started_count'), + 'checkout_completed' => (int) $days->sum('checkout_completed_count'), + ]; + + return view('livewire.admin.analytics.index', compact('days', 'summary'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php index 69f01509..2111e65a 100644 --- a/app/Livewire/Admin/Apps/Index.php +++ b/app/Livewire/Admin/Apps/Index.php @@ -2,6 +2,20 @@ namespace App\Livewire\Admin\Apps; -use App\Livewire\Admin\Section; +use App\Models\AppInstallation; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Index extends Section {} +class Index extends Component +{ + public function uninstall(int $installationId): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + AppInstallation::query()->findOrFail($installationId)->delete(); + } + + public function render(): View + { + return view('livewire.admin.apps.index', ['installations' => AppInstallation::query()->with('app')->latest()->get(), 'availableApps' => \App\Models\App::query()->whereDoesntHave('installations', fn ($query) => $query->where('store_id', app('current_store')->getKey()))->get()])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php index 6203e8ee..ac613745 100644 --- a/app/Livewire/Admin/Auth/Login.php +++ b/app/Livewire/Admin/Auth/Login.php @@ -3,6 +3,7 @@ namespace App\Livewire\Admin\Auth; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Component; class Login extends Component @@ -16,13 +17,23 @@ class Login extends Component public function login(): void { $credentials = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + $key = 'admin-login|'.request()->ip(); - if (! Auth::guard('web')->attempt($credentials, $this->remember)) { - $this->addError('email', 'These credentials do not match our records.'); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('email', 'Too many attempts. Try again later.'); return; } + RateLimiter::hit($key, 60); + + if (! Auth::guard('web')->attempt([...$credentials, 'status' => 'active'], $this->remember)) { + $this->addError('email', 'Invalid credentials'); + + return; + } + + RateLimiter::clear($key); session()->regenerate(); $store = auth()->user()->stores()->first(); diff --git a/app/Livewire/Admin/Collections/Create.php b/app/Livewire/Admin/Collections/Create.php index 1070db69..1d13ce83 100644 --- a/app/Livewire/Admin/Collections/Create.php +++ b/app/Livewire/Admin/Collections/Create.php @@ -2,6 +2,52 @@ namespace App\Livewire\Admin\Collections; -use App\Livewire\Admin\Section; +use App\Models\Collection; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Create extends Section {} +class Create extends Component +{ + public string $title = ''; + + public string $handle = ''; + + public string $description = ''; + + public string $status = 'draft'; + + /** @var list */ + public array $productIds = []; + + public function save(): void + { + $this->authorize('create', Collection::class); + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'in:draft,active,archived'], + 'productIds' => ['array'], + ]); + $collection = Collection::query()->create([ + 'title' => $data['title'], + 'handle' => $data['handle'] !== '' ? $data['handle'] : str($data['title'])->slug()->toString(), + 'description' => $data['description'], + 'status' => $data['status'], + ]); + $this->syncProducts($collection, $data['productIds']); + $this->redirectRoute('admin.collections.edit', ['collection' => $collection], navigate: true); + } + + /** @param list $productIds */ + private function syncProducts(Collection $collection, array $productIds): void + { + $validIds = \App\Models\Product::query()->whereIn('id', $productIds)->pluck('id')->all(); + $collection->products()->sync(array_fill_keys($validIds, ['position' => 0])); + } + + public function render(): View + { + return view('livewire.admin.collections.form', ['collection' => null])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Collections/Edit.php b/app/Livewire/Admin/Collections/Edit.php index e9933d61..ae2fda6c 100644 --- a/app/Livewire/Admin/Collections/Edit.php +++ b/app/Livewire/Admin/Collections/Edit.php @@ -2,6 +2,58 @@ namespace App\Livewire\Admin\Collections; -use App\Livewire\Admin\Section; +use App\Models\Collection; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Edit extends Section {} +class Edit extends Component +{ + public Collection $collection; + + public string $title = ''; + + public string $handle = ''; + + public string $description = ''; + + public string $status = 'draft'; + + /** @var list */ + public array $productIds = []; + + public function mount(Collection $collection): void + { + $this->collection = $collection; + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->description = (string) $collection->description; + $this->status = $collection->status->value; + $this->productIds = $collection->products()->pluck('products.id')->all(); + } + + public function save(): void + { + $this->authorize('update', $this->collection); + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'in:draft,active,archived'], + 'productIds' => ['array'], + ]); + $this->collection->update([ + 'title' => $data['title'], + 'handle' => $data['handle'], + 'description' => $data['description'], + 'status' => $data['status'], + ]); + $validIds = \App\Models\Product::query()->whereIn('id', $data['productIds'])->pluck('id')->all(); + $this->collection->products()->sync(array_fill_keys($validIds, ['position' => 0])); + $this->dispatch('toast', message: 'Collection saved.'); + } + + public function render(): View + { + return view('livewire.admin.collections.form')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..56d0c528 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,13 @@ +resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function delete(int $collectionId): void + { + $collection = Collection::query()->findOrFail($collectionId); + $this->authorize('delete', $collection); + $collection->delete(); + $this->dispatch('toast', message: 'Collection deleted.'); + } + + public function render(): View + { + $collections = Collection::query() + ->withCount('products') + ->when($this->search !== '', fn ($query) => $query->where(function ($nested): void { + $nested->where('title', 'like', '%'.$this->search.'%') + ->orWhere('handle', 'like', '%'.$this->search.'%'); + })) + ->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) + ->latest() + ->paginate(20); + + return view('livewire.admin.collections.index', compact('collections'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php index 886e5fe8..316b283c 100644 --- a/app/Livewire/Admin/Customers/Index.php +++ b/app/Livewire/Admin/Customers/Index.php @@ -4,14 +4,40 @@ use App\Models\Customer; use Livewire\Component; +use Livewire\WithPagination; class Index extends Component { + use WithPagination; + public string $search = ''; + public function mount(): void + { + $this->authorize('viewAny', Customer::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + public function render(): mixed { - $customers = Customer::query()->when($this->search !== '', fn ($query) => $query->where('email', 'like', '%'.$this->search.'%')->orWhere('first_name', 'like', '%'.$this->search.'%')->orWhere('last_name', 'like', '%'.$this->search.'%'))->withCount('orders')->latest()->paginate(15); + $search = trim($this->search); + $customers = Customer::query() + ->select(['id', 'store_id', 'first_name', 'last_name', 'email', 'created_at']) + ->withCount('orders') + ->withSum('orders', 'total_amount') + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($query) use ($search): void { + $query->where('email', 'like', '%'.$search.'%') + ->orWhere('first_name', 'like', '%'.$search.'%') + ->orWhere('last_name', 'like', '%'.$search.'%'); + }); + }) + ->latest() + ->paginate(15); return view('livewire.admin.customers.index', compact('customers'))->layout('layouts.admin'); } diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php index efaa2313..0fc36471 100644 --- a/app/Livewire/Admin/Customers/Show.php +++ b/app/Livewire/Admin/Customers/Show.php @@ -3,19 +3,175 @@ namespace App\Livewire\Admin\Customers; use App\Models\Customer; +use App\Models\CustomerAddress; +use Illuminate\Support\Arr; +use Illuminate\Validation\Rule; use Livewire\Component; +use Livewire\WithPagination; class Show extends Component { + use WithPagination; + public Customer $customer; + public ?CustomerAddress $editingAddress = null; + + public bool $showAddressModal = false; + + public bool $editingCustomer = false; + + public string $firstName = ''; + + public string $lastName = ''; + + public string $email = ''; + + public bool $marketingOptIn = false; + + public string $addressLabel = ''; + + /** @var array */ + public array $addressJson = [ + 'line1' => '', + 'line2' => '', + 'city' => '', + 'state' => '', + 'zip' => '', + 'country' => 'DE', + ]; + + public string $message = ''; + public function mount(Customer $customer): void { - $this->customer = $customer->load(['orders', 'addresses']); + $this->customer = $customer; + $this->authorize('view', $this->customer); + $this->loadCustomer(); + $this->fillCustomerForm(); + } + + public function openCustomerForm(): void + { + $this->authorize('update', $this->customer); + $this->fillCustomerForm(); + $this->resetValidation(); + $this->editingCustomer = true; + } + + public function saveCustomer(): void + { + $this->authorize('update', $this->customer); + $data = $this->validate([ + 'firstName' => ['required', 'string', 'max:255'], + 'lastName' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('customers', 'email')->where(fn ($query) => $query->where('store_id', app('current_store')->getKey()))->ignore($this->customer->id)], + ]); + + $this->customer->update([ + 'first_name' => $data['firstName'], + 'last_name' => $data['lastName'], + 'email' => $data['email'], + 'metadata' => array_merge($this->customer->metadata ?? [], ['marketing_opt_in' => $this->marketingOptIn]), + ]); + $this->editingCustomer = false; + $this->message = 'Customer details saved.'; + $this->loadCustomer(); + } + + public function openAddressForm(?int $addressId = null): void + { + $this->authorize('update', $this->customer); + $this->resetValidation(); + $this->editingAddress = $addressId === null + ? null + : $this->customer->addresses()->whereKey($addressId)->firstOrFail(); + $this->addressLabel = (string) ($this->editingAddress?->label ?? ''); + $stored = $this->editingAddress?->address_json ?? []; + $this->addressJson = [ + 'line1' => (string) ($stored['line1'] ?? $stored['address1'] ?? ''), + 'line2' => (string) ($stored['line2'] ?? $stored['address2'] ?? ''), + 'city' => (string) ($stored['city'] ?? ''), + 'state' => (string) ($stored['state'] ?? $stored['province'] ?? ''), + 'zip' => (string) ($stored['zip'] ?? $stored['postal_code'] ?? ''), + 'country' => strtoupper((string) ($stored['country'] ?? $stored['country_code'] ?? 'DE')), + ]; + $this->showAddressModal = true; + } + + public function saveAddress(): void + { + $this->authorize('update', $this->customer); + $data = $this->validate([ + 'addressLabel' => ['nullable', 'string', 'max:100'], + 'addressJson.line1' => ['required', 'string', 'max:500'], + 'addressJson.line2' => ['nullable', 'string', 'max:500'], + 'addressJson.city' => ['required', 'string', 'max:255'], + 'addressJson.state' => ['nullable', 'string', 'max:255'], + 'addressJson.zip' => ['required', 'string', 'max:30'], + 'addressJson.country' => ['required', 'string', 'size:2'], + ]); + $addressData = [ + 'label' => $data['addressLabel'] ?: null, + 'address_json' => array_map('trim', $data['addressJson']), + ]; + + if ($this->editingAddress === null) { + $this->customer->addresses()->create($addressData + ['is_default' => ! $this->customer->addresses()->exists()]); + } else { + $this->editingAddress->update($addressData); + } + + $this->showAddressModal = false; + $this->message = 'Address saved.'; + $this->loadCustomer(); + } + + public function deleteAddress(int $addressId): void + { + $this->authorize('update', $this->customer); + $address = $this->customer->addresses()->whereKey($addressId)->firstOrFail(); + $wasDefault = $address->is_default; + $address->delete(); + + if ($wasDefault) { + $this->customer->addresses()->latest('id')->first()?->update(['is_default' => true]); + } + + $this->message = 'Address deleted.'; + $this->loadCustomer(); + } + + public function setDefaultAddress(int $addressId): void + { + $this->authorize('update', $this->customer); + $address = $this->customer->addresses()->whereKey($addressId)->firstOrFail(); + $this->customer->addresses()->update(['is_default' => false]); + $address->update(['is_default' => true]); + $this->message = 'Default address updated.'; + $this->loadCustomer(); } public function render(): mixed { - return view('livewire.admin.customers.show')->layout('layouts.admin'); + $orders = $this->customer->orders() + ->select(['id', 'customer_id', 'order_number', 'status', 'financial_status', 'total_amount', 'currency', 'placed_at']) + ->latest('placed_at') + ->paginate(8, pageName: 'customer-orders'); + + return view('livewire.admin.customers.show', compact('orders'))->layout('layouts.admin'); + } + + private function loadCustomer(): void + { + $this->customer = $this->customer->refresh()->load('addresses'); + } + + private function fillCustomerForm(): void + { + $this->firstName = (string) $this->customer->first_name; + $this->lastName = (string) $this->customer->last_name; + $this->email = (string) $this->customer->email; + $this->marketingOptIn = (bool) Arr::get($this->customer->metadata ?? [], 'marketing_opt_in', false); } } diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php index 203c97d2..70e92ba9 100644 --- a/app/Livewire/Admin/Dashboard.php +++ b/app/Livewire/Admin/Dashboard.php @@ -2,18 +2,27 @@ namespace App\Livewire\Admin; +use App\Models\AnalyticsDaily; use App\Models\Order; +use App\Models\OrderLine; use App\Models\Product; use Livewire\Component; class Dashboard extends Component { + public string $range = '30'; + public function render(): mixed { - $orders = Order::query()->with('customer')->latest('placed_at')->take(10)->get(); - $sales = (int) Order::query()->where('financial_status', 'paid')->sum('total_amount'); - $orderCount = (int) Order::query()->count(); + $days = in_array($this->range, ['7', '30', '90'], true) ? (int) $this->range : 30; + $from = now()->subDays($days - 1)->startOfDay(); + $to = now()->endOfDay(); + $orders = Order::query()->with('customer')->whereBetween('placed_at', [$from, $to])->latest('placed_at')->take(10)->get(); + $sales = (int) Order::query()->where('financial_status', 'paid')->whereBetween('placed_at', [$from, $to])->sum('total_amount'); + $orderCount = (int) Order::query()->whereBetween('placed_at', [$from, $to])->count(); + $analytics = AnalyticsDaily::query()->whereBetween('date', [$from->toDateString(), $to->toDateString()])->orderBy('date')->get(); + $topProducts = OrderLine::query()->whereHas('order', fn ($query) => $query->where('financial_status', 'paid')->whereBetween('placed_at', [$from, $to]))->select('product_id', 'product_title')->selectRaw('SUM(quantity) AS units, SUM(line_total_amount) AS revenue')->groupBy('product_id', 'product_title')->orderByDesc('revenue')->limit(5)->get(); - return view('livewire.admin.dashboard', ['orders' => $orders, 'sales' => $sales, 'orderCount' => $orderCount, 'productCount' => Product::query()->count()])->layout('layouts.admin'); + return view('livewire.admin.dashboard', ['orders' => $orders, 'sales' => $sales, 'orderCount' => $orderCount, 'productCount' => Product::query()->count(), 'analytics' => $analytics, 'topProducts' => $topProducts, 'visitors' => (int) $analytics->sum('visits_count'), 'addToCart' => (int) $analytics->sum('add_to_cart_count'), 'checkoutStarted' => (int) $analytics->sum('checkout_started_count')])->layout('layouts.admin'); } } diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php index f09050df..6324c6b0 100644 --- a/app/Livewire/Admin/Developers/Index.php +++ b/app/Livewire/Admin/Developers/Index.php @@ -2,6 +2,65 @@ namespace App\Livewire\Admin\Developers; -use App\Livewire\Admin\Section; +use App\Models\WebhookSubscription; +use Carbon\CarbonImmutable; +use Illuminate\Contracts\View\View; +use Illuminate\Support\Str; +use Livewire\Component; -class Index extends Section {} +class Index extends Component +{ + public string $event = 'order.created'; + + public string $targetUrl = ''; + + public string $tokenName = ''; + + public string $tokenExpiresAt = ''; + + public string $tokenAbilities = 'read-products,read-orders'; + + public ?string $plainTextToken = null; + + public function createWebhook(): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + $data = $this->validate(['event' => ['required', 'string', 'max:100'], 'targetUrl' => ['required', 'url', 'max:2000']]); + WebhookSubscription::query()->create(['event' => $data['event'], 'event_type' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']); + $this->reset('targetUrl'); + } + + public function pause(int $subscriptionId): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + $subscription = WebhookSubscription::query()->findOrFail($subscriptionId); + $subscription->update(['status' => $subscription->status === 'active' ? 'paused' : 'active']); + } + + public function createToken(): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + $data = $this->validate([ + 'tokenName' => ['required', 'string', 'max:100'], + 'tokenExpiresAt' => ['nullable', 'date', 'after:today'], + 'tokenAbilities' => ['required', 'string', 'max:1000'], + ]); + $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content']; + $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities']))))); + abort_if($abilities === [], 422, 'Select at least one token ability.'); + $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']); + $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken; + $this->reset(['tokenName', 'tokenExpiresAt']); + } + + public function revokeToken(int $tokenId): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + auth()->user()->tokens()->whereKey($tokenId)->delete(); + } + + public function render(): View + { + return view('livewire.admin.developers.index', ['subscriptions' => WebhookSubscription::query()->latest()->get(), 'tokens' => auth()->user()->tokens()->latest()->get()])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php index 3ed072b7..79acf3ff 100644 --- a/app/Livewire/Admin/Discounts/Form.php +++ b/app/Livewire/Admin/Discounts/Form.php @@ -2,43 +2,186 @@ namespace App\Livewire\Admin\Discounts; +use App\Enums\DiscountType; +use App\Enums\DiscountValueType; +use App\Models\Collection; use App\Models\Discount; +use App\Models\Product; +use Illuminate\Support\Carbon; +use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Livewire\Component; class Form extends Component { public ?Discount $discount = null; + public string $type = 'code'; + public string $code = ''; public string $valueType = 'percent'; - public int $valueAmount = 10; + public ?int $valueAmount = 10; + + public ?int $minimumPurchaseAmount = null; + + /** @var array */ + public array $specificProductIds = []; + + /** @var array */ + public array $specificCollectionIds = []; + + public ?int $usageLimit = null; + + public bool $onePerCustomer = false; + + public string $startsAt = ''; + + public ?string $endsAt = null; + + public bool $isActive = true; + + public string $productSearch = ''; + + public string $collectionSearch = ''; public string $message = ''; public function mount(?Discount $discount = null): void { $this->discount = $discount; + $this->authorize($discount === null ? 'create' : 'view', $discount ?? Discount::class); + $this->startsAt = now()->format('Y-m-d\TH:i'); if ($discount !== null) { - $this->code = (string) $discount->code; - $this->valueType = $discount->value_type->value; + $rules = $discount->rules_json ?? []; + $this->type = $discount->type instanceof DiscountType ? $discount->type->value : (string) $discount->type; + $this->code = (string) ($discount->code ?? ''); + $this->valueType = $discount->value_type instanceof DiscountValueType ? $discount->value_type->value : (string) $discount->value_type; $this->valueAmount = $discount->value_amount; + $this->minimumPurchaseAmount = $rules['min_purchase_amount'] ?? $rules['minimum_purchase_amount'] ?? null; + $this->specificProductIds = array_map('intval', $rules['applicable_product_ids'] ?? []); + $this->specificCollectionIds = array_map('intval', $rules['applicable_collection_ids'] ?? []); + $this->usageLimit = $discount->usage_limit; + $this->onePerCustomer = (bool) ($rules['one_per_customer'] ?? false); + $this->startsAt = $discount->starts_at?->format('Y-m-d\TH:i') ?? ''; + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i'); + $this->isActive = $discount->status === 'active'; } } - public function save(): void + public function generateCode(): void + { + $this->code = Str::upper(Str::random(10)); + } + + public function addProduct(int $productId): void + { + $this->authorize($this->discount === null ? 'create' : 'update', $this->discount ?? Discount::class); + $product = Product::query()->findOrFail($productId); + + if (! in_array($product->id, $this->specificProductIds, true)) { + $this->specificProductIds[] = $product->id; + } + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void { - $data = $this->validate(['code' => ['required', 'string', 'max:64'], 'valueType' => ['required', 'in:percent,fixed,free_shipping'], 'valueAmount' => ['required', 'integer', 'min:0']]); + $this->authorize($this->discount === null ? 'create' : 'update', $this->discount ?? Discount::class); + $this->specificProductIds = array_values(array_filter($this->specificProductIds, fn (int $id): bool => $id !== $productId)); + } + public function addCollection(int $collectionId): void + { $this->authorize($this->discount === null ? 'create' : 'update', $this->discount ?? Discount::class); - $this->discount = Discount::updateOrCreate(['id' => $this->discount?->id], ['store_id' => app('current_store')->getKey(), 'code' => strtoupper($data['code']), 'type' => 'code', 'value_type' => $data['valueType'], 'value_amount' => $data['valueAmount'], 'status' => 'active', 'starts_at' => now(), 'rules_json' => []]); - $this->message = 'Discount saved'; + $collection = Collection::query()->findOrFail($collectionId); + + if (! in_array($collection->id, $this->specificCollectionIds, true)) { + $this->specificCollectionIds[] = $collection->id; + } + $this->collectionSearch = ''; + } + + public function removeCollection(int $collectionId): void + { + $this->authorize($this->discount === null ? 'create' : 'update', $this->discount ?? Discount::class); + $this->specificCollectionIds = array_values(array_filter($this->specificCollectionIds, fn (int $id): bool => $id !== $collectionId)); + } + + public function save(): void + { + $data = $this->validate([ + 'type' => ['required', 'in:code,automatic'], + 'code' => ['nullable', 'string', 'max:64', 'required_if:type,code', Rule::when($this->type === 'code', [Rule::unique('discounts', 'code')->where(fn ($query) => $query->where('store_id', app('current_store')->getKey()))->ignore($this->discount?->id)])], + 'valueType' => ['required', 'in:percent,fixed,free_shipping'], + 'valueAmount' => ['nullable', 'integer', 'min:0', 'required_unless:valueType,free_shipping'], + 'minimumPurchaseAmount' => ['nullable', 'integer', 'min:0'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'startsAt' => ['required', 'date'], + 'endsAt' => ['nullable', 'date', 'after:startsAt'], + 'specificProductIds' => ['array'], + 'specificProductIds.*' => ['integer', 'exists:products,id'], + 'specificCollectionIds' => ['array'], + 'specificCollectionIds.*' => ['integer', 'exists:collections,id'], + 'onePerCustomer' => ['boolean'], + 'isActive' => ['boolean'], + ]); + + if ($data['valueType'] === 'percent' && $data['valueAmount'] > 100) { + $this->addError('valueAmount', 'Percentage discounts cannot exceed 100%.'); + + return; + } + + $ability = $this->discount === null ? 'create' : 'update'; + $this->authorize($ability, $this->discount ?? Discount::class); + $rules = [ + 'min_purchase_amount' => $data['minimumPurchaseAmount'], + 'applicable_product_ids' => array_map('intval', $data['specificProductIds']), + 'applicable_collection_ids' => array_map('intval', $data['specificCollectionIds']), + 'one_per_customer' => (bool) $data['onePerCustomer'], + ]; + $attributes = [ + 'store_id' => app('current_store')->getKey(), + 'code' => $data['type'] === 'code' ? Str::upper(trim($data['code'])) : null, + 'type' => $data['type'], + 'value_type' => $data['valueType'], + 'value_amount' => $data['valueAmount'] ?? 0, + 'status' => $data['isActive'] ? 'active' : 'disabled', + 'usage_limit' => $data['usageLimit'], + 'starts_at' => Carbon::parse($data['startsAt']), + 'ends_at' => $data['endsAt'] ? Carbon::parse($data['endsAt']) : null, + 'rules_json' => $rules, + ]; + + if ($this->discount === null) { + $this->discount = Discount::create($attributes); + } else { + $this->discount->update($attributes); + } + + $this->message = 'Discount saved.'; } public function render(): mixed { - return view('livewire.admin.discounts.form')->layout('layouts.admin'); + $productResults = Product::query() + ->select(['id', 'title']) + ->when(trim($this->productSearch) !== '', fn ($query) => $query->where('title', 'like', '%'.trim($this->productSearch).'%')) + ->whereNotIn('id', $this->specificProductIds ?: [0]) + ->limit(8) + ->get(); + $collectionResults = Collection::query() + ->select(['id', 'title']) + ->when(trim($this->collectionSearch) !== '', fn ($query) => $query->where('title', 'like', '%'.trim($this->collectionSearch).'%')) + ->whereNotIn('id', $this->specificCollectionIds ?: [0]) + ->limit(8) + ->get(); + $selectedProducts = Product::query()->select(['id', 'title'])->whereIn('id', $this->specificProductIds ?: [0])->get(); + $selectedCollections = Collection::query()->select(['id', 'title'])->whereIn('id', $this->specificCollectionIds ?: [0])->get(); + + return view('livewire.admin.discounts.form', compact('productResults', 'collectionResults', 'selectedProducts', 'selectedCollections'))->layout('layouts.admin'); } } diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php index 74482dfa..9b36da42 100644 --- a/app/Livewire/Admin/Discounts/Index.php +++ b/app/Livewire/Admin/Discounts/Index.php @@ -3,12 +3,62 @@ namespace App\Livewire\Admin\Discounts; use App\Models\Discount; +use Illuminate\Support\Carbon; use Livewire\Component; +use Livewire\WithPagination; class Index extends Component { + use WithPagination; + + public string $search = ''; + + public string $statusFilter = 'all'; + + public function mount(): void + { + $this->authorize('viewAny', Discount::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function delete(int $discountId): void + { + $discount = Discount::query()->findOrFail($discountId); + $this->authorize('delete', $discount); + $discount->delete(); + $this->dispatch('toast', message: 'Discount deleted.'); + } + public function render(): mixed { - return view('livewire.admin.discounts.index', ['discounts' => Discount::query()->latest()->get()])->layout('layouts.admin'); + $search = trim($this->search); + $now = Carbon::now(); + $discounts = Discount::query() + ->when($search !== '', fn ($query) => $query->where('code', 'like', '%'.$search.'%')) + ->when($this->statusFilter === 'active', fn ($query) => $query->where('status', 'active')->where(function ($query) use ($now): void { + $query->whereNull('starts_at')->orWhere('starts_at', '<=', $now); + })->where(function ($query) use ($now): void { + $query->whereNull('ends_at')->orWhere('ends_at', '>', $now); + })) + ->when($this->statusFilter === 'scheduled', fn ($query) => $query->where('starts_at', '>', $now)) + ->when($this->statusFilter === 'expired', fn ($query) => $query->where(function ($query) use ($now): void { + $query->where('ends_at', '<=', $now) + ->orWhere(function ($query): void { + $query->whereNotNull('usage_limit')->whereColumn('usage_count', '>=', 'usage_limit'); + }); + })) + ->latest() + ->paginate(15); + + return view('livewire.admin.discounts.index', compact('discounts'))->layout('layouts.admin'); } } diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php index 75880ce1..6cc0b43b 100644 --- a/app/Livewire/Admin/Inventory/Index.php +++ b/app/Livewire/Admin/Inventory/Index.php @@ -2,6 +2,63 @@ namespace App\Livewire\Admin\Inventory; -use App\Livewire\Admin\Section; +use App\Enums\InventoryPolicy; +use App\Models\InventoryItem; +use Illuminate\Contracts\View\View; +use Livewire\Component; +use Livewire\WithPagination; -class Index extends Section {} +class Index extends Component +{ + use WithPagination; + + public string $search = ''; + + public string $stock = 'all'; + + /** @var array */ + public array $quantities = []; + + /** @var array */ + public array $policies = []; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStock(): void + { + $this->resetPage(); + } + + public function save(int $inventoryId): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + $item = InventoryItem::query()->findOrFail($inventoryId); + $data = $this->validate([ + 'quantities.'.$inventoryId => ['required', 'integer', 'min:0'], + 'policies.'.$inventoryId => ['required', 'in:deny,continue'], + ]); + $item->update(['quantity_on_hand' => $data['quantities'][$inventoryId], 'policy' => $data['policies'][$inventoryId]]); + $this->dispatch('toast', message: 'Inventory updated.'); + } + + public function render(): View + { + $items = InventoryItem::query() + ->with('variant.product') + ->when($this->search !== '', fn ($query) => $query->whereHas('variant.product', fn ($product) => $product->where('title', 'like', '%'.$this->search.'%'))->orWhereHas('variant', fn ($variant) => $variant->where('sku', 'like', '%'.$this->search.'%'))) + ->when($this->stock === 'out', fn ($query) => $query->whereColumn('quantity_on_hand', '<=', 'quantity_reserved')) + ->when($this->stock === 'low', fn ($query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) between 1 and 10')) + ->latest('updated_at') + ->paginate(25); + + foreach ($items as $item) { + $this->quantities[$item->id] ??= $item->quantity_on_hand; + $this->policies[$item->id] ??= $item->policy instanceof InventoryPolicy ? $item->policy->value : (string) $item->policy; + } + + return view('livewire.admin.inventory.index', compact('items'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php index b52a1bb1..a33c7401 100644 --- a/app/Livewire/Admin/Navigation/Index.php +++ b/app/Livewire/Admin/Navigation/Index.php @@ -2,6 +2,70 @@ namespace App\Livewire\Admin\Navigation; -use App\Livewire\Admin\Section; +use App\Models\NavigationItem; +use App\Models\NavigationMenu; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Index extends Section {} +class Index extends Component +{ + public int $menuId = 0; + + public string $menuName = ''; + + public string $menuHandle = ''; + + public string $label = ''; + + public string $url = ''; + + public string $type = 'link'; + + public function mount(): void + { + $menu = NavigationMenu::query()->first(); + $this->selectMenu($menu?->getKey() ?? 0); + } + + public function selectMenu(int $menuId): void + { + $menu = NavigationMenu::query()->find($menuId); + $this->menuId = $menu?->getKey() ?? 0; + $this->menuName = $menu?->name ?? ''; + $this->menuHandle = $menu?->handle ?? ''; + } + + public function saveMenu(): void + { + $this->authorizeStoreManager(); + $data = $this->validate(['menuName' => ['required', 'string', 'max:255'], 'menuHandle' => ['required', 'string', 'max:255']]); + $menu = $this->menuId > 0 ? NavigationMenu::query()->findOrFail($this->menuId) : new NavigationMenu; + $menu->fill(['name' => $data['menuName'], 'handle' => $data['menuHandle']])->save(); + $this->selectMenu($menu->getKey()); + } + + public function addItem(): void + { + $this->authorizeStoreManager(); + $data = $this->validate(['menuId' => ['required', 'integer'], 'label' => ['required', 'string', 'max:255'], 'url' => ['required', 'string', 'max:500'], 'type' => ['required', 'string']]); + $menu = NavigationMenu::query()->findOrFail($data['menuId']); + $menu->items()->create(['label' => $data['label'], 'url' => $data['url'], 'type' => $data['type'], 'position' => (int) $menu->items()->max('position') + 1]); + $this->reset(['label', 'url']); + } + + public function deleteItem(int $itemId): void + { + $this->authorizeStoreManager(); + NavigationItem::query()->where('navigation_menu_id', $this->menuId)->findOrFail($itemId)->delete(); + } + + public function render(): View + { + return view('livewire.admin.navigation.index', ['menus' => NavigationMenu::query()->with('items')->latest()->get(), 'menu' => $this->menuId > 0 ? NavigationMenu::query()->with('items')->find($this->menuId) : null])->layout('layouts.admin'); + } + + private function authorizeStoreManager(): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php index 4076871a..bab6b061 100644 --- a/app/Livewire/Admin/Orders/Index.php +++ b/app/Livewire/Admin/Orders/Index.php @@ -4,14 +4,85 @@ use App\Models\Order; use Livewire\Component; +use Livewire\WithPagination; class Index extends Component { - public string $status = 'all'; + use WithPagination; + + public string $search = ''; + + public string $statusFilter = 'all'; + + public string $sortField = 'placed_at'; + + public string $sortDirection = 'desc'; + + public function mount(): void + { + $this->authorize('viewAny', Order::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if (! in_array($field, ['order_number', 'placed_at', 'total_amount'], true)) { + return; + } + + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = $field === 'order_number' ? 'asc' : 'desc'; + } + + $this->resetPage(); + } public function render(): mixed { - $orders = Order::query()->with('customer')->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status))->latest('placed_at')->paginate(15); + $search = trim($this->search); + + $orders = Order::query() + ->select(['id', 'store_id', 'customer_id', 'order_number', 'email', 'status', 'financial_status', 'fulfillment_status', 'total_amount', 'currency', 'placed_at']) + ->with('customer:id,store_id,first_name,last_name,email') + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($query) use ($search): void { + $query->where('order_number', 'like', '%'.$search.'%') + ->orWhere('email', 'like', '%'.$search.'%') + ->orWhereHas('customer', function ($customerQuery) use ($search): void { + $customerQuery->where(function ($customerQuery) use ($search): void { + $customerQuery->where('first_name', 'like', '%'.$search.'%') + ->orWhere('last_name', 'like', '%'.$search.'%') + ->orWhere('email', 'like', '%'.$search.'%'); + }); + }); + }); + }) + ->when($this->statusFilter !== 'all', function ($query): void { + match ($this->statusFilter) { + 'pending' => $query->where('financial_status', 'pending'), + 'paid' => $query->where('financial_status', 'paid'), + 'fulfilled' => $query->where(function ($query): void { + $query->where('status', 'fulfilled')->orWhere('fulfillment_status', 'fulfilled'); + }), + 'cancelled' => $query->where('status', 'cancelled'), + 'refunded' => $query->where('financial_status', 'refunded'), + default => null, + }; + }) + ->orderBy($this->sortField, $this->sortDirection) + ->paginate(15); return view('livewire.admin.orders.index', compact('orders'))->layout('layouts.admin'); } diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php index 12bfc763..e6753632 100644 --- a/app/Livewire/Admin/Orders/Show.php +++ b/app/Livewire/Admin/Orders/Show.php @@ -12,50 +12,222 @@ class Show extends Component { public Order $order; - public string $message = ''; + /** @var array */ + public array $fulfillmentLines = []; + + /** @var array */ + public array $selectedFulfillmentLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public ?int $refundAmount = null; + + public string $refundReason = ''; + + /** @var array */ + public array $refundLines = []; - public int $refundAmount = 0; + /** @var array */ + public array $selectedRefundLines = []; + + public bool $showFulfillmentModal = false; + + public bool $showRefundModal = false; + + public string $message = ''; public function mount(Order $order): void { - $this->order = $order->load(['lines.variant.inventory', 'payments', 'fulfillments.lines']); + $this->order = $order; + $this->authorize('view', $this->order); + $this->loadOrder(); + } + + public function openFulfillmentModal(): void + { + $this->authorize('createFulfillment', $this->order); + $this->resetValidation(); + $this->fulfillmentLines = []; + $this->selectedFulfillmentLines = []; + + foreach ($this->order->lines as $line) { + $remaining = max(0, $line->quantity - $this->fulfilledQuantity($line->id)); + + if ($remaining > 0) { + $this->fulfillmentLines[$line->id] = $remaining; + $this->selectedFulfillmentLines[$line->id] = true; + } + } + + $this->showFulfillmentModal = true; + } + + public function openRefundModal(): void + { + $this->authorize('createRefund', $this->order); + $this->resetValidation(); + $this->refundAmount = null; + $this->refundReason = ''; + $this->refundLines = []; + $this->selectedRefundLines = []; + foreach ($this->order->lines as $line) { + $this->refundLines[$line->id] = $line->quantity; + $this->selectedRefundLines[$line->id] = false; + } + $this->showRefundModal = true; } public function confirmPayment(OrderService $orders): void { $this->authorize('update', $this->order); - $orders->confirmPayment($this->order); - $this->message = 'Payment confirmed'; - $this->order = $this->order->refresh()->load(['lines.variant.inventory', 'payments', 'fulfillments.lines']); + + try { + $orders->confirmPayment($this->order); + $this->message = 'Payment confirmed.'; + $this->loadOrder(); + } catch (\Throwable $exception) { + $this->addError('payment', $exception->getMessage()); + } + } + + public function createFulfillment(FulfillmentService $fulfillments): void + { + $this->authorize('createFulfillment', $this->order); + + $this->validate([ + 'trackingCompany' => ['nullable', 'string', 'max:100'], + 'trackingNumber' => ['nullable', 'string', 'max:100'], + 'trackingUrl' => ['nullable', 'url', 'max:500'], + 'fulfillmentLines' => ['array'], + 'fulfillmentLines.*' => ['integer', 'min:0'], + ]); + + $lines = collect($this->fulfillmentLines) + ->mapWithKeys(fn (int|string $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity]) + ->filter(fn (int $quantity, int $lineId): bool => (bool) ($this->selectedFulfillmentLines[$lineId] ?? false)) + ->filter(fn (int $quantity): bool => $quantity > 0) + ->map(fn (int $quantity, int $lineId): array => ['order_line_id' => $lineId, 'quantity' => $quantity]) + ->values() + ->all(); + + if ($lines === []) { + $this->addError('fulfillmentLines', 'Select at least one item to fulfill.'); + + return; + } + + try { + $fulfillments->create($this->order, $lines, array_filter([ + 'tracking_company' => $this->trackingCompany, + 'tracking_number' => $this->trackingNumber, + 'tracking_url' => $this->trackingUrl, + ])); + $this->showFulfillmentModal = false; + $this->message = 'Fulfillment created.'; + $this->loadOrder(); + } catch (\Throwable $exception) { + $this->addError('fulfillmentLines', $exception->getMessage()); + } } - public function fulfill(FulfillmentService $fulfillments): void + public function markAsShipped(int $fulfillmentId, FulfillmentService $fulfillments): void { $this->authorize('createFulfillment', $this->order); - $lines = $this->order->lines->map(fn ($line): array => ['order_line_id' => $line->id, 'quantity' => $line->quantity])->all(); - $fulfillments->create($this->order, $lines); - $this->message = 'Fulfillment created'; - $this->order = $this->order->refresh()->load(['lines', 'payments', 'fulfillments.lines']); + $fulfillment = $this->order->fulfillments->firstWhere('id', $fulfillmentId); + + abort_if($fulfillment === null, 404); + + try { + $fulfillments->markAsShipped($fulfillment); + $this->message = 'Fulfillment marked as shipped.'; + $this->loadOrder(); + } catch (\Throwable $exception) { + $this->addError('fulfillment', $exception->getMessage()); + } } - public function refund(RefundService $refunds): void + public function markAsDelivered(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $this->authorize('createFulfillment', $this->order); + $fulfillment = $this->order->fulfillments->firstWhere('id', $fulfillmentId); + + abort_if($fulfillment === null, 404); + + try { + $fulfillments->markAsDelivered($fulfillment); + $this->message = 'Fulfillment marked as delivered.'; + $this->loadOrder(); + } catch (\Throwable $exception) { + $this->addError('fulfillment', $exception->getMessage()); + } + } + + public function createRefund(RefundService $refunds): void { $this->authorize('createRefund', $this->order); + $this->validate([ + 'refundAmount' => ['nullable', 'integer', 'min:1'], + 'refundReason' => ['nullable', 'string', 'max:1000'], + 'refundLines' => ['array'], + 'refundLines.*' => ['integer', 'min:0'], + ]); + + $lines = collect($this->refundLines) + ->mapWithKeys(fn (int|string $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity]) + ->filter(fn (int $quantity, int $lineId): bool => (bool) ($this->selectedRefundLines[$lineId] ?? false)) + ->filter(fn (int $quantity): bool => $quantity > 0) + ->all(); + + if ($lines === [] && $this->refundAmount === null) { + $this->addError('refundAmount', 'Select items or enter a refund amount in cents.'); + + return; + } + $payment = $this->order->payments->first(); if ($payment === null) { - $this->addError('refundAmount', 'No payment found.'); + $this->addError('refundAmount', 'No payment is available for this order.'); return; } - $refunds->create($this->order, $payment, $this->refundAmount ?: $payment->amount, 'Admin refund', true); - $this->message = 'Refund processed'; - $this->order = $this->order->refresh()->load(['lines', 'payments', 'refunds']); + try { + $refunds->create($this->order, $payment, $lines !== [] ? $lines : $this->refundAmount, $this->refundReason ?: null, true); + $this->showRefundModal = false; + $this->message = 'Refund processed.'; + $this->loadOrder(); + } catch (\Throwable $exception) { + $this->addError('refundAmount', $exception->getMessage()); + } } public function render(): mixed { return view('livewire.admin.orders.show')->layout('layouts.admin'); } + + private function loadOrder(): void + { + $this->order = $this->order->refresh()->load([ + 'customer', + 'lines.variant.product', + 'payments', + 'refunds', + 'fulfillments.lines.orderLine', + ]); + } + + private function fulfilledQuantity(int $lineId): int + { + return (int) $this->order->fulfillments + ->whereIn('status', ['shipped', 'delivered']) + ->flatMap->lines + ->where('order_line_id', $lineId) + ->sum('quantity'); + } } diff --git a/app/Livewire/Admin/Pages/Create.php b/app/Livewire/Admin/Pages/Create.php index dca9a4e3..363dec02 100644 --- a/app/Livewire/Admin/Pages/Create.php +++ b/app/Livewire/Admin/Pages/Create.php @@ -2,6 +2,41 @@ namespace App\Livewire\Admin\Pages; -use App\Livewire\Admin\Section; +use App\Models\Page; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Create extends Section {} +class Create extends Component +{ + public string $title = ''; + + public string $handle = ''; + + public string $bodyHtml = ''; + + public string $status = 'draft'; + + public function save(): void + { + $this->authorize('create', Page::class); + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'bodyHtml' => ['nullable', 'string'], + 'status' => ['required', 'in:draft,published'], + ]); + $page = Page::query()->create([ + 'title' => $data['title'], + 'handle' => $data['handle'] !== '' ? $data['handle'] : str($data['title'])->slug()->toString(), + 'body_html' => $data['bodyHtml'], + 'status' => $data['status'], + 'published_at' => $data['status'] === 'published' ? now() : null, + ]); + $this->redirectRoute('admin.pages.edit', ['page' => $page], navigate: true); + } + + public function render(): View + { + return view('livewire.admin.pages.form', ['page' => null])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Pages/Edit.php b/app/Livewire/Admin/Pages/Edit.php index a18e8947..5517e02e 100644 --- a/app/Livewire/Admin/Pages/Edit.php +++ b/app/Livewire/Admin/Pages/Edit.php @@ -2,6 +2,52 @@ namespace App\Livewire\Admin\Pages; -use App\Livewire\Admin\Section; +use App\Models\Page; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Edit extends Section {} +class Edit extends Component +{ + public Page $page; + + public string $title = ''; + + public string $handle = ''; + + public string $bodyHtml = ''; + + public string $status = 'draft'; + + public function mount(Page $page): void + { + $this->page = $page; + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = (string) $page->body_html; + $this->status = $page->status->value; + } + + public function save(): void + { + $this->authorize('update', $this->page); + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'string', 'max:255'], + 'bodyHtml' => ['nullable', 'string'], + 'status' => ['required', 'in:draft,published'], + ]); + $this->page->update([ + 'title' => $data['title'], + 'handle' => $data['handle'], + 'body_html' => $data['bodyHtml'], + 'status' => $data['status'], + 'published_at' => $data['status'] === 'published' ? ($this->page->published_at ?? now()) : null, + ]); + $this->dispatch('toast', message: 'Page saved.'); + } + + public function render(): View + { + return view('livewire.admin.pages.form')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..76345d84 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,13 @@ +resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function delete(int $pageId): void + { + $page = Page::query()->findOrFail($pageId); + $this->authorize('delete', $page); + $page->delete(); + $this->dispatch('toast', message: 'Page deleted.'); + } + + public function render(): View + { + $pages = Page::query() + ->when($this->search !== '', fn ($query) => $query->where(function ($nested): void { + $nested->where('title', 'like', '%'.$this->search.'%') + ->orWhere('handle', 'like', '%'.$this->search.'%'); + })) + ->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) + ->latest('updated_at') + ->paginate(20); + + return view('livewire.admin.pages.index', compact('pages'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php index dadec5ec..b3bfa11d 100644 --- a/app/Livewire/Admin/Products/Index.php +++ b/app/Livewire/Admin/Products/Index.php @@ -13,8 +13,22 @@ class Index extends Component public string $status = 'active'; + public string $productType = 'all'; + + public string $vendor = ''; + + /** @var array */ + public array $selectedIds = []; + + public bool $selectAll = false; + public string $message = ''; + public function updatedSelectAll(bool $selected): void + { + $this->selectedIds = $selected ? $this->filteredProductsQuery()->pluck('id')->all() : []; + } + public function archive(int $productId, ProductService $products): void { $product = Product::query()->findOrFail($productId); @@ -23,10 +37,32 @@ public function archive(int $productId, ProductService $products): void $this->message = 'Product archived'; } + public function bulkArchive(ProductService $products): void + { + foreach ($this->selectedIds as $productId) { + $product = Product::query()->find($productId); + + if ($product !== null) { + $this->authorize('archive', $product); + $products->transitionStatus($product, ProductStatus::Archived); + } + } + + $this->reset(['selectedIds', 'selectAll']); + $this->message = 'Selected products archived.'; + } + public function render(): mixed { - $products = Product::query()->with(['variants.inventory'])->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.$this->search.'%'))->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status))->latest()->paginate(15); + $products = $this->filteredProductsQuery()->with(['variants.inventory'])->latest()->paginate(15); return view('livewire.admin.products.index', compact('products'))->layout('layouts.admin'); } + + private function filteredProductsQuery(): \Illuminate\Database\Eloquent\Builder + { + return Product::query()->when($this->search !== '', fn ($query) => $query->where(function ($nested): void { + $nested->where('title', 'like', '%'.$this->search.'%')->orWhere('handle', 'like', '%'.$this->search.'%'); + }))->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status))->when($this->productType !== 'all', fn ($query) => $query->where('product_type', $this->productType))->when($this->vendor !== '', fn ($query) => $query->where('vendor', 'like', '%'.$this->vendor.'%')); + } } diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php index a41ca96c..c5d94590 100644 --- a/app/Livewire/Admin/Search/Settings.php +++ b/app/Livewire/Admin/Search/Settings.php @@ -2,6 +2,52 @@ namespace App\Livewire\Admin\Search; -use App\Livewire\Admin\Section; +use App\Models\Product; +use App\Models\SearchSetting; +use App\Services\SearchService; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Settings extends Section {} +class Settings extends Component +{ + public bool $enabled = true; + + public string $synonyms = ''; + + public string $stopwords = ''; + + public string $message = ''; + + public function mount(): void + { + $settings = SearchSetting::query()->first(); + $this->enabled = $settings?->enabled ?? true; + $this->synonyms = collect($settings?->synonyms ?? [])->map(fn ($group): string => is_array($group) ? implode(',', $group) : (string) $group)->implode("\n"); + $this->stopwords = implode("\n", $settings?->stopwords ?? []); + } + + public function save(): void + { + $this->authorizeStoreManager(); + $data = $this->validate(['enabled' => ['boolean'], 'synonyms' => ['nullable', 'string'], 'stopwords' => ['nullable', 'string']]); + SearchSetting::query()->updateOrCreate(['store_id' => app('current_store')->getKey()], ['enabled' => $data['enabled'], 'synonyms' => collect(preg_split('/\R/', $data['synonyms'] ?? '', -1, PREG_SPLIT_NO_EMPTY))->map(fn (string $line): array => array_values(array_filter(array_map('trim', explode(',', $line)))))->filter()->values()->all(), 'stopwords' => array_values(array_filter(array_map('trim', preg_split('/\R/', $data['stopwords'] ?? '', -1, PREG_SPLIT_NO_EMPTY))))]); + $this->message = 'Search settings saved.'; + } + + public function reindex(SearchService $search): void + { + $this->authorizeStoreManager(); + Product::query()->each(fn (Product $product) => $search->syncProduct($product)); + $this->message = 'Search index rebuilt.'; + } + + public function render(): View + { + return view('livewire.admin.search.settings')->layout('layouts.admin'); + } + + private function authorizeStoreManager(): void + { + abort_unless(auth()->user()?->canManageStore(app('current_store')), 403); + } +} diff --git a/app/Livewire/Admin/Settings/Domains.php b/app/Livewire/Admin/Settings/Domains.php new file mode 100644 index 00000000..a23bd904 --- /dev/null +++ b/app/Livewire/Admin/Settings/Domains.php @@ -0,0 +1,74 @@ +authorize('update', app('current_store')); + } + + public function addDomain(): void + { + $this->authorize('update', app('current_store')); + $data = $this->validate([ + 'newHostname' => ['required', 'string', 'max:255', 'regex:/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i'], + 'newType' => ['required', 'in:storefront,admin,api'], + ]); + $store = app('current_store'); + $isPrimary = ! StoreDomain::query()->where('store_id', $store->getKey())->where('type', $data['newType'])->exists(); + StoreDomain::create([ + 'store_id' => $store->getKey(), + 'hostname' => strtolower($data['newHostname']), + 'type' => $data['newType'], + 'is_primary' => $isPrimary, + 'tls_mode' => 'managed', + ]); + $this->reset(['newHostname', 'showModal']); + $this->newType = 'storefront'; + $this->dispatch('toast', message: 'Domain added.'); + } + + public function removeDomain(int $domainId): void + { + $this->authorize('update', app('current_store')); + $domain = StoreDomain::query()->where('store_id', app('current_store')->getKey())->findOrFail($domainId); + $wasPrimary = $domain->is_primary; + $type = $domain->type->value; + $domain->delete(); + + if ($wasPrimary) { + StoreDomain::query()->where('store_id', app('current_store')->getKey())->where('type', $type)->latest('id')->first()?->update(['is_primary' => true]); + } + $this->dispatch('toast', message: 'Domain removed.'); + } + + public function setPrimary(int $domainId): void + { + $this->authorize('update', app('current_store')); + $domain = StoreDomain::query()->where('store_id', app('current_store')->getKey())->findOrFail($domainId); + DB::transaction(function () use ($domain): void { + StoreDomain::query()->where('store_id', $domain->store_id)->where('type', $domain->type->value)->update(['is_primary' => false]); + $domain->update(['is_primary' => true]); + }); + $this->dispatch('toast', message: 'Primary domain updated.'); + } + + public function render(): mixed + { + $domains = StoreDomain::query()->where('store_id', app('current_store')->getKey())->orderByDesc('is_primary')->orderBy('hostname')->get(); + + return view('livewire.admin.settings.domains', compact('domains'))->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php index e10729ae..4febfa36 100644 --- a/app/Livewire/Admin/Settings/General.php +++ b/app/Livewire/Admin/Settings/General.php @@ -9,23 +9,59 @@ class General extends Component { public string $storeName = ''; + public string $storeHandle = ''; + + public string $defaultCurrency = 'EUR'; + + public string $defaultLocale = 'en'; + + public string $timezone = 'UTC'; + public string $message = ''; public function mount(): void { - $settings = StoreSettings::first(); - $this->storeName = (string) ($settings?->general_json['store_name'] ?? app('current_store')->name); + $this->authorize('update', app('current_store')); + $store = app('current_store'); + $settings = $store->settings; + $general = $settings?->general_json ?? []; + $this->storeName = (string) ($general['store_name'] ?? $store->name); + $this->storeHandle = (string) $store->handle; + $this->defaultCurrency = (string) $store->default_currency; + $this->defaultLocale = (string) $store->default_locale; + $this->timezone = (string) $store->timezone; } public function save(): void { - StoreSettings::updateOrCreate(['store_id' => app('current_store')->getKey()], ['general_json' => ['store_name' => $this->storeName]]); - app('current_store')->update(['name' => $this->storeName]); - $this->message = 'Settings saved'; + $this->authorize('update', app('current_store')); + $data = $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'size:3'], + 'defaultLocale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'timezone'], + ]); + $store = app('current_store'); + $settings = StoreSettings::query()->first(); + $store->update([ + 'name' => $data['storeName'], + 'default_currency' => strtoupper($data['defaultCurrency']), + 'default_locale' => $data['defaultLocale'], + 'timezone' => $data['timezone'], + ]); + StoreSettings::updateOrCreate( + ['store_id' => $store->getKey()], + ['general_json' => array_merge($settings?->general_json ?? [], ['store_name' => $data['storeName']])], + ); + $this->message = 'General settings saved.'; } public function render(): mixed { - return view('livewire.admin.settings.general')->layout('layouts.admin'); + return view('livewire.admin.settings.general', [ + 'currencies' => ['EUR' => 'Euro (EUR)', 'USD' => 'US Dollar (USD)', 'GBP' => 'British Pound (GBP)', 'CHF' => 'Swiss Franc (CHF)', 'CAD' => 'Canadian Dollar (CAD)', 'AUD' => 'Australian Dollar (AUD)'], + 'locales' => ['en' => 'English', 'de' => 'German', 'fr' => 'French', 'es' => 'Spanish'], + 'timezones' => \DateTimeZone::listIdentifiers(), + ])->layout('layouts.admin'); } } diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php index f6c6c0de..40032a4a 100644 --- a/app/Livewire/Admin/Settings/Shipping.php +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -4,28 +4,207 @@ use App\Models\ShippingRate; use App\Models\ShippingZone; +use App\Services\ShippingCalculator; use Livewire\Component; class Shipping extends Component { - public string $zoneName = 'Domestic'; + public ?ShippingZone $editingZone = null; - public string $rateName = 'Standard Shipping'; + public string $zoneName = ''; - public int $amount = 499; + /** @var array */ + public array $zoneCountries = []; + + public ?ShippingRate $editingRate = null; + + public ?int $editingRateZoneId = null; + + public string $rateName = ''; + + public string $rateType = 'flat'; + + /** @var array */ + public array $rateConfig = ['price' => 0, 'min' => null, 'max' => null]; + + public bool $rateActive = true; + + /** @var array */ + public array $testAddress = ['country_code' => 'DE', 'province_code' => '', 'city' => '', 'postal_code' => '']; + + /** @var array|null */ + public ?array $testResult = null; + + public bool $showZoneModal = false; + + public bool $showRateModal = false; public string $message = ''; - public function save(): void + public function mount(): void + { + $this->authorize('update', app('current_store')); + } + + public function openZoneModal(?int $zoneId = null): void + { + $this->authorize('update', app('current_store')); + $this->resetValidation(); + $this->editingZone = $zoneId === null ? null : $this->storeZones()->findOrFail($zoneId); + $this->zoneName = (string) ($this->editingZone?->name ?? ''); + $this->zoneCountries = array_map('strtoupper', $this->editingZone?->countries_json ?? []); + $this->showZoneModal = true; + } + + public function saveZone(): void + { + $this->authorize('update', app('current_store')); + $data = $this->validate([ + 'zoneName' => ['required', 'string', 'max:255'], + 'zoneCountries' => ['array'], + 'zoneCountries.*' => ['string', 'size:2'], + ]); + $attributes = [ + 'name' => $data['zoneName'], + 'countries_json' => array_values(array_unique(array_map('strtoupper', $data['zoneCountries']))), + 'regions_json' => $this->editingZone?->regions_json ?? [], + ]; + + if ($this->editingZone === null) { + ShippingZone::create(['store_id' => app('current_store')->getKey()] + $attributes); + } else { + $this->editingZone->update($attributes); + } + + $this->showZoneModal = false; + $this->message = 'Shipping zone saved.'; + } + + public function deleteZone(int $zoneId): void + { + $this->authorize('update', app('current_store')); + $this->storeZones()->findOrFail($zoneId)->delete(); + $this->message = 'Shipping zone deleted.'; + } + + public function openRateModal(int $zoneId, ?int $rateId = null): void { - $data = $this->validate(['zoneName' => ['required', 'string'], 'rateName' => ['required', 'string'], 'amount' => ['required', 'integer', 'min:0']]); - $zone = ShippingZone::updateOrCreate(['store_id' => app('current_store')->getKey(), 'name' => $data['zoneName']], ['countries_json' => ['DE'], 'regions_json' => []]); - ShippingRate::updateOrCreate(['shipping_zone_id' => $zone->getKey(), 'name' => $data['rateName']], ['type' => 'flat', 'price_amount' => $data['amount'], 'currency' => app('current_store')->default_currency, 'is_active' => true]); - $this->message = 'Shipping rate saved'; + $this->authorize('update', app('current_store')); + $zone = $this->storeZones()->findOrFail($zoneId); + $this->resetValidation(); + $this->editingRateZoneId = $zone->id; + $this->editingRate = $rateId === null ? null : $zone->rates()->findOrFail($rateId); + $this->rateName = (string) ($this->editingRate?->name ?? ''); + $this->rateType = (string) ($this->editingRate?->type ?? 'flat'); + $config = $this->editingRate?->config_json ?? []; + $range = $config['ranges'][0] ?? []; + $this->rateConfig = [ + 'price' => (int) ($this->editingRate?->price_amount ?? $range['amount'] ?? 0), + 'min' => $range['min_g'] ?? $range['min_amount'] ?? null, + 'max' => $range['max_g'] ?? $range['max_amount'] ?? null, + ]; + $this->rateActive = $this->editingRate?->is_active ?? true; + $this->showRateModal = true; + } + + public function saveRate(): void + { + $this->authorize('update', app('current_store')); + $data = $this->validate([ + 'editingRateZoneId' => ['required', 'integer'], + 'rateName' => ['required', 'string', 'max:255'], + 'rateType' => ['required', 'in:flat,weight,price,carrier'], + 'rateConfig.price' => ['nullable', 'integer', 'min:0'], + 'rateConfig.min' => ['nullable', 'integer', 'min:0'], + 'rateConfig.max' => ['nullable', 'integer', 'gte:rateConfig.min'], + 'rateActive' => ['boolean'], + ]); + + if ($data['rateType'] !== 'carrier' && ($data['rateConfig']['price'] ?? null) === null) { + $this->addError('rateConfig.price', 'Enter a price for this rate.'); + + return; + } + + $data['rateConfig']['price'] ??= 0; + $zone = $this->storeZones()->findOrFail($data['editingRateZoneId']); + $config = []; + + if (in_array($data['rateType'], ['weight', 'price'], true)) { + $config['ranges'] = [[ + $data['rateType'] === 'weight' ? 'min_g' : 'min_amount' => $data['rateConfig']['min'] ?? 0, + $data['rateType'] === 'weight' ? 'max_g' : 'max_amount' => $data['rateConfig']['max'] ?? PHP_INT_MAX, + 'amount' => $data['rateConfig']['price'], + ]]; + } + $attributes = [ + 'name' => $data['rateName'], + 'type' => $data['rateType'], + 'price_amount' => $data['rateConfig']['price'], + 'currency' => app('current_store')->default_currency, + 'config_json' => $config, + 'is_active' => (bool) $data['rateActive'], + ]; + + if ($this->editingRate === null) { + $zone->rates()->create($attributes); + } else { + $this->editingRate->update($attributes); + } + + $this->showRateModal = false; + $this->message = 'Shipping rate saved.'; + } + + public function deleteRate(int $rateId): void + { + $this->authorize('update', app('current_store')); + $rate = ShippingRate::query()->whereHas('zone', fn ($query) => $query->where('store_id', app('current_store')->getKey()))->findOrFail($rateId); + $rate->delete(); + $this->message = 'Shipping rate deleted.'; + } + + public function toggleRate(int $rateId): void + { + $this->authorize('update', app('current_store')); + $rate = ShippingRate::query()->whereHas('zone', fn ($query) => $query->where('store_id', app('current_store')->getKey()))->findOrFail($rateId); + $rate->update(['is_active' => ! $rate->is_active]); + } + + public function testShippingAddress(ShippingCalculator $shipping): void + { + $this->authorize('update', app('current_store')); + $data = $this->validate([ + 'testAddress.country_code' => ['required', 'string', 'size:2'], + 'testAddress.province_code' => ['nullable', 'string', 'max:10'], + 'testAddress.city' => ['nullable', 'string', 'max:255'], + 'testAddress.postal_code' => ['nullable', 'string', 'max:30'], + ]); + $rates = $shipping->getAvailableRates(app('current_store'), $data['testAddress']); + $this->testResult = [ + 'zone' => $rates->first()?->zone?->name, + 'rates' => $rates->map(fn (ShippingRate $rate): array => ['name' => $rate->name, 'price_amount' => $rate->price_amount, 'currency' => $rate->currency])->values()->all(), + ]; } public function render(): mixed { - return view('livewire.admin.settings.shipping', ['zones' => ShippingZone::with('rates')->latest()->get()])->layout('layouts.admin'); + return view('livewire.admin.settings.shipping', [ + 'zones' => $this->storeZones()->with('rates')->latest()->get(), + 'countries' => $this->countryOptions(), + ])->layout('layouts.admin'); + } + + private function storeZones(): \Illuminate\Database\Eloquent\Builder + { + return ShippingZone::query()->where('store_id', app('current_store')->getKey()); + } + + /** @return array */ + private function countryOptions(): array + { + return [ + 'AU' => 'Australia', 'AT' => 'Austria', 'BE' => 'Belgium', 'BR' => 'Brazil', 'CA' => 'Canada', 'CH' => 'Switzerland', 'CN' => 'China', 'DE' => 'Germany', 'DK' => 'Denmark', 'ES' => 'Spain', 'FI' => 'Finland', 'FR' => 'France', 'GB' => 'United Kingdom', 'IE' => 'Ireland', 'IN' => 'India', 'IT' => 'Italy', 'JP' => 'Japan', 'LU' => 'Luxembourg', 'MX' => 'Mexico', 'NL' => 'Netherlands', 'NO' => 'Norway', 'NZ' => 'New Zealand', 'PL' => 'Poland', 'PT' => 'Portugal', 'SE' => 'Sweden', 'SG' => 'Singapore', 'US' => 'United States', + ]; } } diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php index 30d82c49..a5271139 100644 --- a/app/Livewire/Admin/Settings/Taxes.php +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -3,24 +3,95 @@ namespace App\Livewire\Admin\Settings; use App\Models\TaxSettings; +use Illuminate\Support\Facades\Crypt; use Livewire\Component; class Taxes extends Component { - public int $rate = 1900; + public string $mode = 'manual'; + + public bool $pricesIncludeTax = false; + + public string $provider = 'none'; + + public string $providerApiKey = ''; + + public bool $providerKeyConfigured = false; + + /** @var array */ + public array $manualRates = []; public string $message = ''; public function mount(): void { - $this->rate = (int) (TaxSettings::first()?->default_rate_basis_points ?? 1900); + $this->authorize('update', app('current_store')); + $settings = TaxSettings::query()->first(); + $storedMode = (string) ($settings?->mode ?? 'manual'); + $this->mode = in_array($storedMode, ['manual', 'provider'], true) ? $storedMode : 'manual'; + $this->pricesIncludeTax = (bool) ($settings?->prices_include_tax ?? $storedMode === 'inclusive'); + $providerConfig = $settings?->provider_config_json ?? []; + $this->provider = (string) ($settings?->provider ?? $providerConfig['provider'] ?? 'none'); + $this->providerKeyConfigured = isset($providerConfig['api_key_encrypted']) || isset($providerConfig['api_key']); + $this->manualRates = collect($settings?->rates_json ?? []) + ->map(fn (int|float|string $rate, string $zone): array => ['zone_name' => $zone, 'rate_percentage' => number_format(((float) $rate) / 100, 2, '.', '')]) + ->values() + ->all(); + + if ($this->manualRates === []) { + $this->manualRates[] = ['zone_name' => 'DE', 'rate_percentage' => '19.00']; + } + } + + public function addManualRate(): void + { + $this->manualRates[] = ['zone_name' => '', 'rate_percentage' => '']; + } + + public function removeManualRate(int $index): void + { + unset($this->manualRates[$index]); + $this->manualRates = array_values($this->manualRates); } public function save(): void { - $this->validate(['rate' => ['required', 'integer', 'min:0', 'max:10000']]); - TaxSettings::updateOrCreate(['store_id' => app('current_store')->getKey()], ['mode' => 'exclusive', 'default_rate_basis_points' => $this->rate, 'rates_json' => ['DE' => $this->rate]]); - $this->message = 'Tax settings saved'; + $this->authorize('update', app('current_store')); + $data = $this->validate([ + 'mode' => ['required', 'in:manual,provider'], + 'pricesIncludeTax' => ['boolean'], + 'provider' => ['required', 'in:none,stripe_tax'], + 'providerApiKey' => ['nullable', 'string', 'max:500'], + 'manualRates' => ['array'], + 'manualRates.*.zone_name' => ['required', 'string', 'max:50'], + 'manualRates.*.rate_percentage' => ['required', 'numeric', 'min:0', 'max:100'], + ]); + $rates = collect($data['manualRates']) + ->mapWithKeys(fn (array $rate): array => [strtoupper(trim($rate['zone_name'])) => (int) round(((float) $rate['rate_percentage']) * 100)]) + ->all(); + $providerConfig = ['provider' => $data['provider']]; + $existingApiKey = TaxSettings::query()->first()?->provider_config_json['api_key_encrypted'] ?? null; + + if ($data['providerApiKey'] !== '') { + $providerConfig['api_key_encrypted'] = Crypt::encryptString($data['providerApiKey']); + } elseif ($existingApiKey !== null) { + $providerConfig['api_key_encrypted'] = $existingApiKey; + } + + TaxSettings::updateOrCreate( + ['store_id' => app('current_store')->getKey()], + [ + 'mode' => $data['mode'], + 'provider' => $data['provider'], + 'prices_include_tax' => (bool) $data['pricesIncludeTax'], + 'default_rate_basis_points' => (int) (array_values($rates)[0] ?? 0), + 'rates_json' => $rates, + 'provider_config_json' => $providerConfig, + ], + ); + $this->providerApiKey = ''; + $this->providerKeyConfigured = isset($providerConfig['api_key_encrypted']); + $this->message = 'Tax settings saved.'; } public function render(): mixed diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php index 9656ddee..32c5e117 100644 --- a/app/Livewire/Admin/Themes/Editor.php +++ b/app/Livewire/Admin/Themes/Editor.php @@ -2,6 +2,35 @@ namespace App\Livewire\Admin\Themes; -use App\Livewire\Admin\Section; +use App\Models\Theme; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Editor extends Section {} +class Editor extends Component +{ + public Theme $theme; + + public string $settingsJson = '{}'; + + public function mount(Theme $theme): void + { + $this->theme = $theme; + $this->settingsJson = json_encode($theme->settings?->settings_json ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}'; + } + + public function save(): void + { + $this->authorize('update', $this->theme); + $data = $this->validate(['settingsJson' => ['required', 'json']]); + $this->theme->settings()->updateOrCreate( + ['theme_id' => $this->theme->getKey()], + ['settings_json' => json_decode($data['settingsJson'], true, 512, JSON_THROW_ON_ERROR)], + ); + $this->dispatch('toast', message: 'Theme settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.themes.editor')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php index 9dfd0e26..19120ffc 100644 --- a/app/Livewire/Admin/Themes/Index.php +++ b/app/Livewire/Admin/Themes/Index.php @@ -2,6 +2,47 @@ namespace App\Livewire\Admin\Themes; -use App\Livewire\Admin\Section; +use App\Enums\ThemeStatus; +use App\Models\Theme; +use Illuminate\Contracts\View\View; +use Livewire\Component; -class Index extends Section {} +class Index extends Component +{ + public function publish(int $themeId): void + { + $theme = Theme::query()->findOrFail($themeId); + $this->authorize('publish', $theme); + Theme::query()->where('store_id', $theme->store_id)->whereKeyNot($theme->id)->where('status', ThemeStatus::Published)->update(['status' => ThemeStatus::Draft]); + $theme->update(['status' => ThemeStatus::Published]); + $this->dispatch('toast', message: 'Theme published.'); + } + + public function duplicate(int $themeId): void + { + $theme = Theme::query()->with(['files', 'settings'])->findOrFail($themeId); + $this->authorize('create', Theme::class); + $copy = $theme->replicate(['status']); + $copy->name = $theme->name.' copy'; + $copy->status = ThemeStatus::Draft; + $copy->save(); + foreach ($theme->files as $file) { + $copy->files()->create($file->only(['path', 'content', 'storage_key', 'sha256', 'byte_size'])); + } + $copy->settings()->create(['settings_json' => $theme->settings?->settings_json ?? []]); + $this->dispatch('toast', message: 'Theme duplicated.'); + } + + public function delete(int $themeId): void + { + $theme = Theme::query()->findOrFail($themeId); + $this->authorize('delete', $theme); + abort_if($theme->status === ThemeStatus::Published, 422, 'Publish another theme before deleting this one.'); + $theme->delete(); + } + + public function render(): View + { + return view('livewire.admin.themes.index', ['themes' => Theme::query()->latest()->get()])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php index 146d7e89..e9c2d33b 100644 --- a/app/Livewire/Storefront/Account/Auth/Login.php +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -5,6 +5,7 @@ use App\Models\Cart; use App\Services\CartService; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Component; class Login extends Component @@ -18,13 +19,23 @@ class Login extends Component public function login(CartService $carts): void { $credentials = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + $key = 'customer-login|'.request()->ip(); - if (! Auth::guard('customer')->attempt($credentials, $this->remember)) { - $this->addError('email', 'These credentials do not match our records.'); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('email', 'Too many attempts. Try again later.'); return; } + RateLimiter::hit($key, 60); + + if (! Auth::guard('customer')->attempt([...$credentials, 'status' => 'active'], $this->remember)) { + $this->addError('email', 'Invalid credentials'); + + return; + } + + RateLimiter::clear($key); session()->regenerate(); $customer = Auth::guard('customer')->user(); $store = app('current_store'); diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php index 611ff7f3..0e4a557e 100644 --- a/app/Livewire/Storefront/Account/Auth/Register.php +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -19,10 +19,12 @@ class Register extends Component public string $passwordConfirmation = ''; + public bool $marketingOptIn = false; + public function register(): void { - $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', Rule::unique('customers', 'email')->where('store_id', app('current_store')->getKey())], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]); - $customer = Customer::create(['store_id' => app('current_store')->getKey(), 'first_name' => $data['firstName'], 'last_name' => $data['lastName'], 'email' => $data['email'], 'password_hash' => $data['password'], 'status' => 'active']); + $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', Rule::unique('customers', 'email')->where('store_id', app('current_store')->getKey())], 'password' => ['required', 'min:8', 'same:passwordConfirmation'], 'marketingOptIn' => ['boolean']]); + $customer = Customer::create(['store_id' => app('current_store')->getKey(), 'first_name' => $data['firstName'], 'last_name' => $data['lastName'], 'name' => trim($data['firstName'].' '.$data['lastName']), 'email' => $data['email'], 'password_hash' => $data['password'], 'marketing_opt_in' => $data['marketingOptIn'], 'status' => 'active']); Auth::guard('customer')->login($customer); $this->redirect(route('account.dashboard')); } diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php index 1753aa91..949fd1a6 100644 --- a/app/Livewire/Storefront/Cart/Show.php +++ b/app/Livewire/Storefront/Cart/Show.php @@ -17,55 +17,126 @@ class Show extends Component public string $message = ''; - public function mount(CartService $carts): void + public int $discountAmount = 0; + + public int $totalAmount = 0; + + public function mount(CartService $carts, DiscountService $discounts): void { $this->cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + $this->discountCode = (string) ($this->cart->discount_code ?? ''); + $this->refreshCart($discounts); } - public function increase(int $lineId, CartService $carts): void + public function increase(int $lineId, CartService $carts, DiscountService $discounts): void { $line = $this->cart->lines->firstWhere('id', $lineId); + + if ($line === null) { + return; + } + $carts->updateLineQuantity($this->cart, $lineId, $line->quantity + 1); - $this->refreshCart(); + $this->refreshCart($discounts); } - public function decrease(int $lineId, CartService $carts): void + public function decrease(int $lineId, CartService $carts, DiscountService $discounts): void { $line = $this->cart->lines->firstWhere('id', $lineId); + if ($line === null) { + return; + } + if ($line->quantity > 1) { $carts->updateLineQuantity($this->cart, $lineId, $line->quantity - 1); } - $this->refreshCart(); + $this->refreshCart($discounts); } - public function remove(int $lineId, CartService $carts): void + public function remove(int $lineId, CartService $carts, DiscountService $discounts): void { + if (! $this->cart->lines->contains('id', $lineId)) { + return; + } + $carts->removeLine($this->cart, $lineId); - $this->refreshCart(); + $this->message = 'Item removed from your cart.'; + $this->refreshCart($discounts); } public function checkout(CheckoutService $checkouts): void { + if ($this->cart->lines->isEmpty()) { + $this->addError('cart', 'Your cart is empty.'); + + return; + } + $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user()); $this->redirect(route('checkout.show', $checkout), navigate: true); } public function applyDiscount(DiscountService $discounts): void { + $this->validate(['discountCode' => ['required', 'string', 'max:64']]); + try { - $discount = $discounts->validate($this->discountCode, app('current_store'), $this->cart); + $discount = $discounts->validate(trim($this->discountCode), app('current_store'), $this->cart); $this->cart->update(['discount_code' => $discount->code]); - $this->message = 'Discount applied'; + $this->discountCode = $discount->code; + $this->message = 'Discount applied.'; + $this->resetValidation('discountCode'); + $this->refreshCart($discounts); } catch (InvalidDiscountException $exception) { $this->addError('discountCode', $exception->getMessage()); } } - private function refreshCart(): void + public function removeDiscount(DiscountService $discounts): void + { + $this->cart->update(['discount_code' => null]); + $this->discountCode = ''; + $this->message = 'Discount removed.'; + $this->resetValidation('discountCode'); + $this->refreshCart($discounts); + } + + public function formatMoney(int|float $amount): string + { + return number_format((float) $amount / 100, 2, '.', ',').' '.($this->cart->currency ?: 'EUR'); + } + + private function refreshCart(DiscountService $discounts): void + { + $this->cart = $this->cart->refresh()->load(['lines.variant.product.media', 'lines.variant.product.collections', 'lines.variant.inventory']); + $this->discountAmount = $this->calculateDiscountAmount($discounts); + $subtotal = (int) $this->cart->lines->sum('line_subtotal_amount'); + $this->totalAmount = max(0, $subtotal - $this->discountAmount); + } + + private function calculateDiscountAmount(DiscountService $discounts): int { - $this->cart = $this->cart->refresh()->load(['lines.variant.product', 'lines.variant.inventory']); + if ($this->cart->discount_code === null || $this->cart->lines->isEmpty()) { + return 0; + } + + try { + $discount = $discounts->validate($this->cart->discount_code, app('current_store'), $this->cart); + } catch (InvalidDiscountException) { + return 0; + } + + $subtotal = (int) $this->cart->lines->sum('line_subtotal_amount'); + $result = $discounts->calculate($discount, $subtotal, $this->cart->lines->map(fn ($line): array => [ + 'line_id' => $line->id, + 'amount' => $line->line_subtotal_amount, + 'product_id' => $line->variant->product_id, + 'collection_ids' => $line->variant->product->collections->modelKeys(), + ])->all()); + + return $result->amount; } public function render(): mixed diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..3563187d --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,44 @@ +cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + } + + #[On('open-cart-drawer')] + public function open(): void + { + $this->refreshCart(); + $this->open = true; + } + + #[On('cart-updated')] + public function refreshCart(): void + { + $this->cart = app(CartService::class)->getOrCreateForSession(app('current_store'), auth('customer')->user()); + $this->open = true; + } + + public function close(): void + { + $this->open = false; + } + + public function render(): mixed + { + return view('livewire.storefront.cart-drawer'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php index 670bb689..28cdc0fd 100644 --- a/app/Livewire/Storefront/Checkout/Confirmation.php +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -3,7 +3,7 @@ namespace App\Livewire\Storefront\Checkout; use App\Models\Order; -use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; use Livewire\Component; class Confirmation extends Component @@ -12,13 +12,51 @@ class Confirmation extends Component public function mount(string $checkoutId): void { - $this->order = Order::query()->with(['lines', 'customer', 'checkout'])->where('checkout_id', $checkoutId)->first() - ?? Order::query()->with(['lines', 'customer', 'checkout'])->where('id', $checkoutId)->firstOrFail(); + $this->order = Order::query() + ->with(['lines.variant.product.media', 'customer', 'checkout', 'payments']) + ->where('checkout_id', $checkoutId) + ->first() + ?? Order::query() + ->with(['lines.variant.product.media', 'customer', 'checkout', 'payments']) + ->where('id', $checkoutId) + ->firstOrFail(); - $customerId = Auth::guard('customer')->id(); - $sessionCartIds = array_filter([session('cart_id'), session('cart_id_'.app('current_store')->getKey())]); - abort_unless(($customerId !== null && (int) $this->order->customer_id === (int) $customerId) - || ($customerId === null && in_array($this->order->checkout?->cart_id, $sessionCartIds, true)), 404); + $customerId = auth('customer')->id(); + $sessionCartIds = array_filter([ + session('cart_id'), + session('cart_id_'.app('current_store')->getKey()), + ]); + + abort_unless( + ($customerId !== null && (int) $this->order->customer_id === (int) $customerId) + || ($customerId === null && in_array($this->order->checkout?->cart_id, $sessionCartIds, true)), + 404, + ); + } + + public function formatMoney(int|float $amount): string + { + return number_format((float) $amount / 100, 2, '.', ',').' '.($this->order->currency ?: 'EUR'); + } + + public function paymentLabel(): string + { + return match ((string) $this->order->payment_method) { + 'credit_card' => 'Credit card', + 'paypal' => 'PayPal', + 'bank_transfer' => 'Bank transfer', + default => Str::headline((string) $this->order->payment_method), + }; + } + + public function isBankTransfer(): bool + { + return $this->order->payment_method === 'bank_transfer'; + } + + public function canViewAccountOrder(): bool + { + return auth('customer')->check() && $this->order->customer_id !== null; } public function render(): mixed diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php index 8b255d7f..569edabc 100644 --- a/app/Livewire/Storefront/Checkout/Show.php +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -2,75 +2,399 @@ namespace App\Livewire\Storefront\Checkout; +use App\Enums\CheckoutStatus; use App\Enums\PaymentMethod; +use App\Exceptions\InvalidDiscountException; use App\Models\Checkout as CheckoutModel; +use App\Models\CustomerAddress; use App\Services\CheckoutService; +use App\Services\DiscountService; use App\Services\PaymentService; use App\Services\PricingEngine; use App\Services\ShippingCalculator; +use Carbon\Carbon; use Livewire\Component; +use Throwable; class Show extends Component { public CheckoutModel $checkout; - public array $shippingAddress = ['first_name' => '', 'last_name' => '', 'address1' => '', 'city' => '', 'country_code' => 'DE', 'postal_code' => '']; + public string $email = ''; + + /** @var array */ + public array $shippingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'state' => '', + 'country_code' => 'DE', + 'postal_code' => '', + 'phone' => '', + ]; + + /** @var array */ + public array $billingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'state' => '', + 'country_code' => 'DE', + 'postal_code' => '', + 'phone' => '', + ]; + + public bool $billingSameAsShipping = true; + + public ?int $savedAddressId = null; public ?int $shippingRateId = null; - public string $paymentMethod = 'credit_card'; + public string $paymentMethod = PaymentMethod::CreditCard->value; public string $cardNumber = '4242424242424242'; + public string $cardholderName = ''; + + public string $cardExpiry = ''; + + public string $cardCvc = ''; + + public string $discountCode = ''; + public string $message = ''; + public bool $processing = false; + + public int $activeStep = 1; + + public bool $showOrderSummary = false; + public function mount(int $checkoutId): void { - $this->checkout = CheckoutModel::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId); + $this->checkout = CheckoutModel::query() + ->with(['cart.lines.variant.product.media', 'shippingRate']) + ->findOrFail($checkoutId); + $customerId = auth('customer')->id(); - $sessionCartIds = array_filter([session('cart_id'), session('cart_id_'.app('current_store')->getKey())]); - abort_unless(($customerId !== null && (int) $this->checkout->customer_id === (int) $customerId) - || ($customerId === null && in_array($this->checkout->cart_id, $sessionCartIds, true)), 404); + $sessionCartIds = array_filter([ + session('cart_id'), + session('cart_id_'.app('current_store')->getKey()), + ]); + + abort_unless( + ($customerId !== null && (int) $this->checkout->customer_id === (int) $customerId) + || ($customerId === null && in_array($this->checkout->cart_id, $sessionCartIds, true)), + 404, + ); + + $this->email = (string) $this->checkout->email; + $this->discountCode = (string) ($this->checkout->discount_code ?? ''); + $this->shippingRateId = $this->checkout->shipping_rate_id; + $this->paymentMethod = (string) ($this->checkout->payment_method ?? PaymentMethod::CreditCard->value); if ($this->checkout->shipping_address_json !== null) { - $this->shippingAddress = $this->checkout->shipping_address_json; + $this->shippingAddress = array_merge($this->shippingAddress, $this->checkout->shipping_address_json); + } + + if ($this->checkout->billing_address_json !== null) { + $this->billingAddress = array_merge($this->billingAddress, $this->checkout->billing_address_json); + $this->billingSameAsShipping = $this->billingAddress === $this->shippingAddress; + } + + $this->activeStep = match ($this->checkout->status) { + CheckoutStatus::Started => 1, + CheckoutStatus::Addressed => 3, + CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected, CheckoutStatus::PaymentPending, CheckoutStatus::Completed => 4, + default => 1, + }; + } + + public function saveContact(): void + { + $this->validate([ + 'email' => ['required', 'email', 'max:255'], + ], [ + 'email.required' => 'Enter an email address to receive your order confirmation.', + ]); + + $this->checkout->update(['email' => $this->email]); + $this->checkout->refresh(); + $this->message = 'Contact information saved.'; + $this->activeStep = 2; + } + + public function saveAddress(CheckoutService $checkouts, PricingEngine $pricing): void + { + $this->normalizeAddresses(); + + $rules = [ + 'email' => ['required', 'email', 'max:255'], + ...$this->addressRules('shippingAddress'), + ]; + + if (! $this->billingSameAsShipping) { + $rules = [...$rules, ...$this->addressRules('billingAddress')]; + } + + $this->validate($rules); + + $this->checkout->update(['email' => $this->email]); + $this->checkout = $checkouts->setAddress( + $this->checkout->refresh(), + $this->shippingAddress, + $this->billingAddress, + $this->billingSameAsShipping, + ); + + if ($this->requiresShipping()) { + $this->checkout->update([ + 'shipping_rate_id' => null, + 'shipping_method_id' => null, + 'status' => CheckoutStatus::Addressed, + ]); + $this->checkout = $this->checkout->refresh(); + $pricing->calculate($this->checkout); + $this->checkout = $this->checkout->refresh(); + $this->shippingRateId = null; + $this->activeStep = 3; + } else { + $this->checkout = $checkouts->setShippingMethod($this->checkout, 0); + $this->shippingRateId = null; + $this->activeStep = 4; + } + + $this->message = 'Address saved.'; + } + + public function selectSavedAddress(int|string|null $addressId): void + { + $customerId = auth('customer')->id(); + + if ($customerId === null || $addressId === null || $addressId === '') { + $this->savedAddressId = null; + + return; + } + + $address = CustomerAddress::query() + ->whereKey((int) $addressId) + ->where('customer_id', $customerId) + ->firstOrFail(); + + $this->savedAddressId = $address->getKey(); + $this->shippingAddress = array_merge($this->shippingAddress, $address->address_json ?? []); + + if ($this->billingSameAsShipping) { + $this->billingAddress = $this->shippingAddress; } } - public function saveAddress(CheckoutService $checkouts): void + public function updatedBillingSameAsShipping(bool $same): void { - $this->checkout = $checkouts->setAddress($this->checkout, $this->shippingAddress); - $this->message = 'Address saved'; + if ($same) { + $this->billingAddress = $this->shippingAddress; + } } public function chooseShipping(CheckoutService $checkouts): void { + if (! $this->requiresShipping()) { + $this->checkout = $checkouts->setShippingMethod($this->checkout, 0); + $this->activeStep = 4; + + return; + } + $this->validate(['shippingRateId' => ['required', 'integer']]); - $this->checkout = $checkouts->setShippingMethod($this->checkout, $this->shippingRateId); - $this->message = 'Shipping method saved'; + + try { + $this->checkout = $checkouts->setShippingMethod($this->checkout, $this->shippingRateId); + $this->message = 'Shipping method saved.'; + $this->activeStep = 4; + } catch (Throwable $exception) { + $this->addError('shippingRateId', $exception->getMessage()); + } + } + + public function applyDiscount(DiscountService $discounts, PricingEngine $pricing): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:64']]); + + try { + $code = trim($this->discountCode); + $discount = $discounts->validate($code, app('current_store'), $this->checkout->cart); + $this->checkout->cart->update(['discount_code' => $discount->code]); + $this->checkout->update(['discount_code' => $discount->code]); + $this->checkout = $this->checkout->refresh(); + $pricing->calculate($this->checkout); + $this->checkout = $this->checkout->refresh(); + $this->checkout->load(['cart.lines.variant.product.media', 'shippingRate']); + $this->discountCode = $discount->code; + $this->message = 'Discount applied.'; + } catch (InvalidDiscountException $exception) { + $this->addError('discountCode', $exception->getMessage()); + } + } + + public function removeDiscount(PricingEngine $pricing): void + { + $this->checkout->cart->update(['discount_code' => null]); + $this->checkout->update(['discount_code' => null]); + $this->checkout = $this->checkout->refresh(); + $pricing->calculate($this->checkout); + $this->checkout = $this->checkout->refresh(); + $this->checkout->load(['cart.lines.variant.product.media', 'shippingRate']); + $this->discountCode = ''; + $this->message = 'Discount removed.'; + $this->resetValidation('discountCode'); } public function pay(CheckoutService $checkouts, PaymentService $payments): void { - $this->validate(['paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer']]); - $this->checkout = $checkouts->selectPaymentMethod($this->checkout, $this->paymentMethod); - $order = $payments->pay($this->checkout, PaymentMethod::from($this->paymentMethod), ['card_number' => $this->cardNumber]); + if ($this->processing) { + return; + } + + $this->cardNumber = preg_replace('/\D+/', '', $this->cardNumber) ?? ''; + $rules = [ + 'email' => ['required', 'email', 'max:255'], + 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'], + ]; + + if ($this->paymentMethod === PaymentMethod::CreditCard->value) { + $rules = [ + ...$rules, + 'cardNumber' => ['required', 'digits:16'], + 'cardholderName' => ['required', 'string', 'max:255'], + 'cardExpiry' => ['required', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'], + 'cardCvc' => ['required', 'digits_between:3,4'], + ]; + } + + $this->validate($rules); - if ($order === null) { - $this->addError('paymentMethod', 'Your payment was declined.'); + if ($this->paymentMethod === PaymentMethod::CreditCard->value && ! $this->cardExpiryIsFuture()) { + $this->addError('cardExpiry', 'Enter a future expiry date in MM/YY format.'); return; } - $this->redirect(route('checkout.confirmation', ['checkoutId' => $order->checkout_id]), navigate: true); + if ($this->checkout->shipping_address_json === null) { + $this->addError('payment', 'Save your shipping address before placing the order.'); + $this->activeStep = 2; + + return; + } + + if ($this->requiresShipping() && $this->checkout->shipping_rate_id === null) { + $this->addError('payment', 'Choose a shipping method before placing the order.'); + $this->activeStep = 3; + + return; + } + + $this->processing = true; + $this->resetValidation('payment'); + + try { + $this->checkout->update(['email' => $this->email]); + $this->checkout = $checkouts->selectPaymentMethod($this->checkout->refresh(), $this->paymentMethod); + $order = $payments->pay($this->checkout, PaymentMethod::from($this->paymentMethod), [ + 'card_number' => $this->cardNumber, + 'cardholder_name' => $this->cardholderName, + 'card_expiry' => $this->cardExpiry, + 'card_cvc' => $this->cardCvc, + ]); + + if ($order === null) { + $this->addError('payment', 'Payment declined. Check your details and try again.'); + + return; + } + + $this->redirect(route('checkout.confirmation', ['checkoutId' => $order->checkout_id]), navigate: true); + } catch (Throwable $exception) { + $this->addError('payment', $exception->getMessage() ?: 'We could not process your payment. Please try again.'); + } finally { + $this->processing = false; + } + } + + public function setActiveStep(int $step): void + { + if ($step >= 1 && $step <= $this->maxAvailableStep()) { + $this->activeStep = $step; + } + } + + public function toggleOrderSummary(): void + { + $this->showOrderSummary = ! $this->showOrderSummary; + } + + public function requiresShipping(): bool + { + return $this->checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping); + } + + public function formatMoney(int|float $amount, ?string $currency = null): string + { + return number_format((float) $amount / 100, 2, '.', ',').' '.($currency ?? $this->checkout->cart->currency ?? 'EUR'); } public function render(ShippingCalculator $shipping, PricingEngine $pricing): mixed { - $rates = $this->checkout->shipping_address_json === null ? collect() : $shipping->getAvailableRates(app('current_store'), $this->checkout->shipping_address_json); - $this->checkout->load(['cart.lines.variant.product', 'shippingRate']); + $this->checkout->load(['cart.lines.variant.product.media', 'shippingRate']); + $rates = $this->checkout->shipping_address_json === null || ! $this->requiresShipping() + ? collect() + : $shipping->getAvailableRates(app('current_store'), $this->checkout->shipping_address_json); $totals = $this->checkout->totals_json ?? $pricing->calculate($this->checkout)->toArray(); + $savedAddresses = auth('customer')->user()?->addresses()->latest()->get() ?? collect(); - return view('livewire.storefront.checkout.show', compact('rates', 'totals'))->layout('layouts.storefront'); + return view('livewire.storefront.checkout.show', compact('rates', 'totals', 'savedAddresses'))->layout('layouts.storefront'); + } + + /** @return array> */ + private function addressRules(string $prefix): array + { + return [ + $prefix.'.first_name' => ['required', 'string', 'max:255'], + $prefix.'.last_name' => ['required', 'string', 'max:255'], + $prefix.'.address1' => ['required', 'string', 'max:500'], + $prefix.'.address2' => ['nullable', 'string', 'max:500'], + $prefix.'.city' => ['required', 'string', 'max:255'], + $prefix.'.state' => ['required', 'string', 'max:255'], + $prefix.'.country_code' => ['required', 'string', 'size:2'], + $prefix.'.postal_code' => ['required', 'string', 'max:20'], + $prefix.'.phone' => ['nullable', 'string', 'max:40'], + ]; + } + + private function normalizeAddresses(): void + { + $this->shippingAddress['country_code'] = strtoupper(trim($this->shippingAddress['country_code'] ?? '')); + $this->billingAddress['country_code'] = strtoupper(trim($this->billingAddress['country_code'] ?? '')); + } + + private function cardExpiryIsFuture(): bool + { + [$month, $year] = array_map('intval', explode('/', $this->cardExpiry)); + + return Carbon::create(2000 + $year, $month, 1)->endOfMonth()->isFuture(); + } + + private function maxAvailableStep(): int + { + return match ($this->checkout->status) { + CheckoutStatus::Started => $this->checkout->email === $this->email && $this->email !== '' ? 2 : 1, + CheckoutStatus::Addressed => 3, + CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected, CheckoutStatus::PaymentPending, CheckoutStatus::Completed => 4, + default => 1, + }; } } diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php index 66692fcc..bc8e84e7 100644 --- a/app/Livewire/Storefront/Products/Show.php +++ b/app/Livewire/Storefront/Products/Show.php @@ -13,15 +13,20 @@ class Show extends Component public int $selectedVariantId; + /** @var array */ + public array $selectedOptions = []; + public int $quantity = 1; public string $message = ''; public function mount(string $handle): void { - $this->product = Product::query()->with(['variants.inventory', 'media', 'options.values'])->where('handle', $handle)->firstOrFail(); + $this->product = Product::query()->with(['variants.inventory', 'variants.optionValues', 'media', 'options.values'])->where('handle', $handle)->firstOrFail(); abort_unless($this->product->status->value === 'active', 404); $this->selectedVariantId = $this->product->defaultVariant()?->getKey() ?? 0; + $default = $this->product->defaultVariant(); + $this->selectedOptions = $default?->optionValues->mapWithKeys(fn ($value): array => [$value->product_option_id => $value->getKey()])->all() ?? []; } public function addToCart(CartService $carts): void @@ -45,9 +50,26 @@ public function selectVariant(int $variantId): void $this->selectedVariantId = $variantId; } + public function selectOption(int $optionId, int $valueId): void + { + abort_unless($this->product->options->firstWhere('id', $optionId)?->values->contains('id', $valueId), 404); + $this->selectedOptions[$optionId] = $valueId; + $selectedValueIds = array_values($this->selectedOptions); + $variant = $this->product->variants->first(function ($candidate) use ($selectedValueIds): bool { + $candidateValueIds = $candidate->optionValues->modelKeys(); + + return count($selectedValueIds) === count($candidateValueIds) + && count(array_intersect($selectedValueIds, $candidateValueIds)) === count($selectedValueIds); + }); + + if ($variant !== null) { + $this->selectedVariantId = $variant->getKey(); + } + } + public function render(): mixed { - $this->product->loadMissing(['variants.inventory', 'media', 'options.values']); + $this->product->loadMissing(['variants.inventory', 'variants.optionValues', 'media', 'options.values']); return view('livewire.storefront.products.show')->layout('layouts.storefront'); } diff --git a/app/Models/Collection.php b/app/Models/Collection.php index 72cf6802..0642630c 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -11,7 +11,7 @@ class Collection extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'title', 'handle', 'description', 'status', 'image_url']; + protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'type', 'status', 'image_url']; protected function casts(): array { diff --git a/app/Models/Customer.php b/app/Models/Customer.php index d0ff785d..d30cc38a 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -4,23 +4,25 @@ use App\Models\Concerns\BelongsToStore; use Illuminate\Auth\Authenticatable as AuthenticatableTrait; +use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; -class Customer extends Model implements Authenticatable +class Customer extends Model implements Authenticatable, CanResetPasswordContract { - use AuthenticatableTrait, BelongsToStore, HasFactory, Notifiable; + use AuthenticatableTrait, BelongsToStore, CanResetPassword, HasFactory, Notifiable; - protected $fillable = ['store_id', 'first_name', 'last_name', 'email', 'password_hash', 'status', 'email_verified_at', 'metadata']; + protected $fillable = ['store_id', 'first_name', 'last_name', 'name', 'email', 'password_hash', 'status', 'marketing_opt_in', 'email_verified_at', 'metadata']; protected $hidden = ['password_hash', 'remember_token']; protected function casts(): array { - return ['email_verified_at' => 'datetime', 'metadata' => 'array', 'password_hash' => 'hashed']; + return ['email_verified_at' => 'datetime', 'marketing_opt_in' => 'boolean', 'metadata' => 'array', 'password_hash' => 'hashed']; } public function getAuthPasswordName(): string diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php index 4b072ff9..0ad0eb43 100644 --- a/app/Models/NavigationMenu.php +++ b/app/Models/NavigationMenu.php @@ -10,7 +10,7 @@ class NavigationMenu extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'name', 'handle']; + protected $fillable = ['store_id', 'name', 'title', 'handle']; public function items(): HasMany { diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php index 34566bee..73c38abc 100644 --- a/app/Models/ProductMedia.php +++ b/app/Models/ProductMedia.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Storage; class ProductMedia extends Model { @@ -14,6 +15,18 @@ protected function casts(): array return ['metadata' => 'array']; } + protected static function booted(): void + { + static::deleting(function (ProductMedia $media): void { + $keys = array_values(array_filter([$media->storage_key, $media->path, ...array_values($media->metadata['variants'] ?? [])])); + $disk = Storage::disk('public'); + + foreach (array_unique($keys) as $key) { + $disk->delete($key); + } + }); + } + public function product(): BelongsTo { return $this->belongsTo(Product::class); diff --git a/app/Models/Refund.php b/app/Models/Refund.php index cec2cf3d..e25aa017 100644 --- a/app/Models/Refund.php +++ b/app/Models/Refund.php @@ -7,11 +7,11 @@ class Refund extends Model { - protected $fillable = ['order_id', 'payment_id', 'amount', 'status', 'reason', 'restock', 'provider_refund_id']; + protected $fillable = ['order_id', 'payment_id', 'amount', 'status', 'reason', 'restock', 'provider_refund_id', 'lines_json']; protected function casts(): array { - return ['restock' => 'boolean']; + return ['restock' => 'boolean', 'lines_json' => 'array']; } public function order(): BelongsTo diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php index f14e1530..f87cd926 100644 --- a/app/Models/SearchQuery.php +++ b/app/Models/SearchQuery.php @@ -9,5 +9,10 @@ class SearchQuery extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'query', 'results_count', 'customer_id']; + protected $fillable = ['store_id', 'query', 'filters_json', 'results_count', 'customer_id']; + + protected function casts(): array + { + return ['filters_json' => 'array']; + } } diff --git a/app/Models/StoreInvitation.php b/app/Models/StoreInvitation.php new file mode 100644 index 00000000..be12d70c --- /dev/null +++ b/app/Models/StoreInvitation.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + protected $fillable = ['store_id', 'email', 'role', 'invited_at', 'expires_at', 'accepted_at']; + + protected function casts(): array + { + return ['invited_at' => 'datetime', 'expires_at' => 'datetime', 'accepted_at' => 'datetime']; + } + + public function store(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php index 12f0daf4..23ac5aaf 100644 --- a/app/Models/Theme.php +++ b/app/Models/Theme.php @@ -4,18 +4,23 @@ use App\Enums\ThemeStatus; use App\Models\Concerns\BelongsToStore; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class Theme extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'name', 'status', 'version', 'settings']; + /** @use HasFactory<\Database\Factories\ThemeFactory> */ + use HasFactory; + + protected $fillable = ['store_id', 'name', 'status', 'version', 'published_at']; protected function casts(): array { - return ['status' => ThemeStatus::class, 'settings' => 'array']; + return ['status' => ThemeStatus::class, 'published_at' => 'datetime']; } public function files(): HasMany @@ -23,8 +28,18 @@ public function files(): HasMany return $this->hasMany(ThemeFile::class); } - public function settingsRows(): HasMany + public function themeSettings(): HasOne + { + return $this->hasOne(ThemeSetting::class, 'theme_id', 'id'); + } + + public function settings(): HasOne + { + return $this->themeSettings(); + } + + public function settingsRows(): HasOne { - return $this->hasMany(ThemeSetting::class); + return $this->themeSettings(); } } diff --git a/app/Models/ThemeSetting.php b/app/Models/ThemeSetting.php index 637b24f4..f732d390 100644 --- a/app/Models/ThemeSetting.php +++ b/app/Models/ThemeSetting.php @@ -2,16 +2,30 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class ThemeSetting extends Model { - protected $fillable = ['theme_id', 'key', 'value']; + /** @use HasFactory<\Database\Factories\ThemeSettingFactory> */ + use HasFactory; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + public const CREATED_AT = null; + + protected $keyType = 'int'; + + protected $attributes = ['settings_json' => '{}']; + + protected $fillable = ['theme_id', 'settings_json']; protected function casts(): array { - return ['value' => 'array']; + return ['settings_json' => 'array']; } public function theme(): BelongsTo diff --git a/app/Models/User.php b/app/Models/User.php index 8b2d453d..86e52422 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,11 +10,12 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php index 0837f11c..0843161a 100644 --- a/app/Models/WebhookSubscription.php +++ b/app/Models/WebhookSubscription.php @@ -10,13 +10,13 @@ class WebhookSubscription extends Model { use BelongsToStore; - protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'secret_encrypted', 'status', 'consecutive_failures']; + protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures']; - protected $hidden = ['secret_encrypted']; + protected $hidden = ['signing_secret_encrypted']; protected function casts(): array { - return ['secret_encrypted' => 'encrypted']; + return ['signing_secret_encrypted' => 'encrypted']; } protected static function booted(): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9f79d2ae..93b51f60 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,10 +3,13 @@ namespace App\Providers; use App\Auth\CustomerUserProvider; +use App\Auth\StoreScopedPasswordBrokerManager; use App\Contracts\PaymentProvider as PaymentProviderContract; +use App\Contracts\TaxProvider; use App\Models\Product; use App\Observers\ProductObserver; use App\Services\MockPaymentProvider; +use App\Services\Tax\ManualTaxProvider; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Model; @@ -29,7 +32,9 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class); + $this->app->bind(TaxProvider::class, ManualTaxProvider::class); Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model'])); + $this->app->extend('auth.password', fn ($manager, $app): StoreScopedPasswordBrokerManager => new StoreScopedPasswordBrokerManager($app)); } /** @@ -49,6 +54,9 @@ public function boot(): void RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip())); RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip())); RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip())); + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip())); + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip())); + RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip())); } /** diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php index b867d563..d85d0547 100644 --- a/app/Services/AnalyticsService.php +++ b/app/Services/AnalyticsService.php @@ -11,7 +11,7 @@ class AnalyticsService /** @var array */ private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']; - public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null): AnalyticsEvent + public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null, ?\DateTimeInterface $occurredAt = null): AnalyticsEvent { if (! in_array($type, $this->eventTypes, true)) { throw new \InvalidArgumentException('Unsupported analytics event type.'); @@ -25,7 +25,7 @@ public function track(Store $store, string $type, array $properties = [], ?strin } } - return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties, 'properties_json' => $properties, 'occurred_at' => now()]); + return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties, 'properties_json' => $properties, 'occurred_at' => $occurredAt ?? now()]); } public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 00000000..26ea7534 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,22 @@ +user(); + + Log::channel('audit')->info($event, array_merge([ + 'user_id' => $user?->getAuthIdentifier(), + 'subject_type' => $subject?->getMorphClass(), + 'subject_id' => $subject?->getKey(), + 'ip' => app()->runningInConsole() ? null : request()->ip(), + 'user_agent' => app()->runningInConsole() ? null : request()->userAgent(), + ], $context)); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php index 98ece4e2..6435746a 100644 --- a/app/Services/CartService.php +++ b/app/Services/CartService.php @@ -152,7 +152,7 @@ public function mergeOnLogin(Cart $guest, Cart $customer): Cart } $guest->update(['status' => 'abandoned']); - session()->forget('cart_id_'.$guest->store_id); + session()->forget(['cart_id_'.$guest->store_id, 'cart_id']); }); return $customer->refresh()->load('lines'); diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php index 968ca604..fcb921ef 100644 --- a/app/Services/FulfillmentService.php +++ b/app/Services/FulfillmentService.php @@ -56,6 +56,7 @@ public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null) $fulfillment->update(array_merge($tracking ?? [], ['status' => 'shipped', 'shipped_at' => now(), 'fulfilled_at' => now()])); FulfillmentShipped::dispatch($fulfillment->refresh()); + $this->refreshOrderStatus($fulfillment->load('order')->order); } public function markAsDelivered(Fulfillment $fulfillment): void @@ -69,7 +70,7 @@ public function markAsDelivered(Fulfillment $fulfillment): void FulfillmentDelivered::dispatch($fulfillment); if ($fulfillment->order !== null) { - $fulfillment->order->update(['status' => 'fulfilled']); + $this->refreshOrderStatus($fulfillment->order); } } @@ -78,7 +79,7 @@ private function refreshOrderStatus(Order $order): void $order->load(['lines', 'fulfillments.lines']); $fulfilledQuantities = []; - foreach ($order->fulfillments as $fulfillment) { + foreach ($order->fulfillments->whereIn('status', ['shipped', 'delivered']) as $fulfillment) { foreach ($fulfillment->lines as $line) { $fulfilledQuantities[$line->order_line_id] = ($fulfilledQuantities[$line->order_line_id] ?? 0) + $line->quantity; } @@ -91,6 +92,8 @@ private function refreshOrderStatus(Order $order): void if ($fulfilled) { $order->update(['status' => OrderStatus::Fulfilled]); OrderFulfilled::dispatch($order->refresh()); + } elseif ($order->status === OrderStatus::Fulfilled) { + $order->update(['status' => OrderStatus::Paid]); } } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index c0394016..f65da467 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -14,11 +14,12 @@ use App\Models\Order; use App\Models\Store; use App\ValueObjects\PaymentResult; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class OrderService { - public function __construct(private readonly InventoryService $inventory) {} + public function __construct(private readonly InventoryService $inventory, private readonly AuditLogger $audit) {} public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentResult = null): Order { @@ -33,6 +34,7 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency]; $discount = $checkout->discount_code === null ? null : Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first(); $status = $paymentResult?->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending; + $taxByLine = $this->allocateTaxLines($checkout->cart->lines, $totals['tax_lines'] ?? []); $order = Order::withoutGlobalScopes()->create([ 'store_id' => $checkout->store_id, 'customer_id' => $checkout->customer_id, @@ -68,7 +70,7 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe 'line_subtotal_amount' => $line->line_subtotal_amount, 'line_discount_amount' => $line->line_discount_amount, 'line_total_amount' => $line->line_total_amount, - 'tax_lines_json' => $totals['tax_lines'] ?? [], + 'tax_lines_json' => $taxByLine[$line->getKey()] ?? [], 'discount_allocations_json' => $discount === null || $line->line_discount_amount < 1 ? [] : [['discount_id' => $discount->getKey(), 'amount' => $line->line_discount_amount]], ]); @@ -80,10 +82,12 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe $checkout->update(['status' => 'completed']); $checkout->cart->update(['status' => 'converted']); OrderCreated::dispatch($order); + $this->audit->record('order.created', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]); $order->load(['lines', 'payments']); if ($status === FinancialStatus::Paid) { OrderPaid::dispatch($order); + $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]); if ($checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) { $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]); @@ -104,7 +108,7 @@ public function generateOrderNumber(Store $store): string { $lastNumber = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->selectRaw("max(cast(replace(order_number, '#', '') as integer)) as value")->value('value'); - return '#'.(max(1000, (int) $lastNumber) + 1); + return (string) config('shop.order_prefix', '#').(max(1000, (int) $lastNumber) + 1); } public function cancel(Order $order, string $reason): void @@ -114,15 +118,19 @@ public function cancel(Order $order, string $reason): void } DB::transaction(function () use ($order, $reason): void { - $order->load('lines.variant.inventory')->update(['status' => OrderStatus::Cancelled, 'metadata' => array_merge($order->metadata ?? [], ['cancellation_reason' => $reason])]); + $wasPending = $order->financial_status === FinancialStatus::Pending; + $order->load('lines.variant.inventory')->update(['status' => OrderStatus::Cancelled, 'financial_status' => $order->financial_status === FinancialStatus::Pending ? FinancialStatus::Voided : $order->financial_status, 'metadata' => array_merge($order->metadata ?? [], ['cancellation_reason' => $reason])]); foreach ($order->lines as $line) { - if ($line->variant?->inventory !== null && $order->financial_status === FinancialStatus::Pending) { + if ($line->variant?->inventory !== null && $wasPending) { $this->inventory->release($line->variant->inventory, $line->quantity); } } + $order->payments()->whereIn('status', [PaymentStatus::Pending, PaymentStatus::Authorized])->update(['status' => PaymentStatus::Failed]); + OrderCancelled::dispatch($order->refresh()); + $this->audit->record('order.cancelled', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number, 'reason' => $reason]); }); } @@ -143,6 +151,53 @@ public function confirmPayment(Order $order): void $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]); OrderPaid::dispatch($order->refresh()); + $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]); + + if ($order->lines->every(fn ($line): bool => ! $line->variant?->requires_shipping)) { + $this->autoFulfillDigitalOrder($order->refresh()); + } }); } + + private function autoFulfillDigitalOrder(Order $order): void + { + if ($order->fulfillments()->exists()) { + return; + } + + $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]); + foreach ($order->lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]); + } + $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]); + } + + /** + * @param \Illuminate\Support\Collection $lines + * @param array $taxLines + * @return array>> + */ + private function allocateTaxLines(Collection $lines, array $taxLines): array + { + $base = max(1, (int) $lines->sum('line_total_amount')); + $allocations = $lines->mapWithKeys(fn ($line): array => [$line->getKey() => []])->all(); + + foreach ($taxLines as $taxLine) { + $remaining = (int) ($taxLine['amount'] ?? 0); + $lineCount = $lines->count(); + + foreach ($lines->values() as $index => $line) { + $amount = $index === $lineCount - 1 + ? $remaining + : intdiv((int) ($taxLine['amount'] ?? 0) * (int) $line->line_total_amount, $base); + $remaining -= $amount; + + if ($amount > 0) { + $allocations[$line->getKey()][] = [...$taxLine, 'amount' => $amount]; + } + } + } + + return $allocations; + } } diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 97049381..3230ad35 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -51,7 +51,7 @@ public function pay(Checkout $checkout, PaymentMethod $method, array $details = Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]); if ($checkout->discount_code !== null) { - Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->where('code', $checkout->discount_code)->increment('usage_count'); + Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->increment('usage_count'); } return $order->refresh()->load(['lines', 'payments']); diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 977fb3fa..22f3023d 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -5,20 +5,25 @@ use App\Enums\ProductStatus; use App\Events\ProductStatusChanged; use App\Exceptions\InvalidProductTransitionException; +use App\Models\InventoryItem; use App\Models\Product; +use App\Models\ProductOption; +use App\Models\ProductVariant; use App\Models\Store; use App\Support\HandleGenerator; use App\Support\HtmlSanitizer; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use LogicException; class ProductService { - public function __construct(private readonly HandleGenerator $handles, private readonly HtmlSanitizer $sanitizer) {} + public function __construct(private readonly HandleGenerator $handles, private readonly HtmlSanitizer $sanitizer, private readonly AuditLogger $audit) {} public function create(Store $store, array $data): Product { return DB::transaction(function () use ($store, $data): Product { + $data['variants'] ??= [['title' => 'Default', 'price_amount' => 0, 'is_default' => true]]; $status = $data['status'] ?? ProductStatus::Draft; $status = $status instanceof ProductStatus ? $status : ProductStatus::from($status); $product = Product::withoutGlobalScopes()->create([ @@ -34,43 +39,51 @@ public function create(Store $store, array $data): Product 'published_at' => $status === ProductStatus::Active ? now() : null, ]); - foreach ($data['variants'] ?? [['title' => 'Default', 'price_amount' => 0, 'is_default' => true]] as $position => $variant) { - $product->variants()->create(array_merge($variant, ['position' => $position, 'is_default' => $variant['is_default'] ?? $position === 0])); - } + $this->syncCatalogDetails($product, $store, $data, true); if ($status === ProductStatus::Active && (trim((string) $product->title) === '' || ! $product->variants()->where('price_amount', '>', 0)->exists())) { throw new InvalidProductTransitionException('An active product requires a title and a priced variant.'); } - return $product->load('variants'); + $this->audit->record('product.created', $product, ['store_id' => $store->getKey()]); + + return $product->load(['variants.inventory', 'options.values', 'variants.optionValues', 'media', 'collections']); }); } public function update(Product $product, array $data): Product { - $updates = array_intersect_key($data, array_flip(['title', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at'])); + return DB::transaction(function () use ($product, $data): Product { + $updates = array_intersect_key($data, array_flip(['title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'published_at'])); - $newStatus = null; + $newStatus = null; - if (array_key_exists('status', $updates)) { - $newStatus = $updates['status'] instanceof ProductStatus ? $updates['status'] : ProductStatus::from($updates['status']); - unset($updates['status']); - } + if (array_key_exists('status', $data)) { + $newStatus = $data['status'] instanceof ProductStatus ? $data['status'] : ProductStatus::from($data['status']); + } - if (array_key_exists('description', $data)) { - $updates['description'] = $this->sanitizer->sanitize($data['description']); - $updates['description_html'] = $this->sanitizer->sanitize($data['description']); - } elseif (array_key_exists('description_html', $data)) { - $updates['description_html'] = $this->sanitizer->sanitize($data['description_html']); - } + if (array_key_exists('description', $data)) { + $updates['description'] = $this->sanitizer->sanitize($data['description']); + $updates['description_html'] = $this->sanitizer->sanitize($data['description']); + } elseif (array_key_exists('description_html', $data)) { + $updates['description_html'] = $this->sanitizer->sanitize($data['description_html']); + } - $product->update($updates); + $product->update($updates); - if ($newStatus !== null) { - $this->transitionStatus($product->refresh(), $newStatus); - } + if ($newStatus !== null) { + $this->transitionStatus($product->refresh(), $newStatus); + } - return $product->refresh()->load('variants'); + if (array_key_exists('options', $data) || array_key_exists('variants', $data) || array_key_exists('remove_variant_ids', $data) || array_key_exists('collections', $data)) { + $this->syncCatalogDetails($product->refresh(), $product->store, $data, array_key_exists('options', $data)); + } + + $product = $product->refresh()->load(['variants.inventory', 'options.values', 'variants.optionValues', 'media', 'collections']); + $this->audit->record('product.updated', $product, ['store_id' => $product->store_id]); + + return $product; + }); } public function transitionStatus(Product $product, ProductStatus $newStatus): void @@ -91,6 +104,7 @@ public function transitionStatus(Product $product, ProductStatus $newStatus): vo $product->update(['status' => $newStatus, 'published_at' => $newStatus === ProductStatus::Active ? ($product->published_at ?? now()) : null]); ProductStatusChanged::dispatch($product->refresh(), $from, $newStatus); + $this->audit->record('product.updated', $product, ['from_status' => $from->value, 'to_status' => $newStatus->value, 'store_id' => $product->store_id]); } public function delete(Product $product): void @@ -100,5 +114,151 @@ public function delete(Product $product): void } $product->delete(); + $this->audit->record('product.deleted', $product, ['store_id' => $product->store_id]); + } + + /** + * Persist product options, variants, inventory, option-value links, and collections. + * + * @param array $data + */ + private function syncCatalogDetails(Product $product, Store $store, array $data, bool $replaceOptions): void + { + $optionsByName = $this->optionMap($product, $data['options'] ?? [], $replaceOptions); + $variants = $data['variants'] ?? []; + + foreach ($variants as $position => $variantData) { + $optionValues = $variantData['option_values'] ?? []; + $inventoryData = $variantData['inventory'] ?? null; + $variantId = $variantData['id'] ?? null; + $attributes = Arr::except($variantData, ['id', 'option_values', 'inventory', 'quantity_on_hand', 'policy']); + $weight = $attributes['weight_g'] ?? $attributes['weight_grams'] ?? 0; + $attributes['weight_g'] = $weight; + $attributes['weight_grams'] = $weight; + $attributes['currency'] ??= $store->default_currency; + $attributes['position'] ??= $position + 1; + $attributes['is_default'] ??= $position === 0; + $attributes['title'] ??= $this->variantTitle($optionValues); + + if (($attributes['compare_at_amount'] ?? null) !== null && (int) $attributes['compare_at_amount'] <= (int) ($attributes['price_amount'] ?? 0)) { + throw new \InvalidArgumentException('A compare-at price must be greater than the variant price.'); + } + + if (($attributes['sku'] ?? null) !== null) { + $skuQuery = ProductVariant::query()->where('sku', $attributes['sku'])->whereHas('product', fn ($query) => $query->where('store_id', $store->getKey())); + if ($variantId !== null) { + $skuQuery->where('id', '!=', $variantId); + } + if ($skuQuery->exists()) { + throw new \InvalidArgumentException('Variant SKUs must be unique within a store.'); + } + } + + if ($variantId !== null) { + $variant = $product->variants()->whereKey($variantId)->first(); + if ($variant === null) { + throw new \InvalidArgumentException('The variant does not belong to this product.'); + } + $variant->update($attributes); + } else { + $variant = $product->variants()->create($attributes); + } + + if ($inventoryData !== null || $variantId === null) { + $inventoryData ??= []; + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + ['store_id' => $store->getKey(), 'quantity_on_hand' => (int) ($inventoryData['quantity_on_hand'] ?? 0), 'policy' => $inventoryData['policy'] ?? 'deny'], + ); + } + + if ($optionValues !== []) { + $variant->optionValues()->sync($this->resolveOptionValueIds($optionsByName, $optionValues)); + } + } + + if ($data['remove_variant_ids'] ?? false) { + $product->variants()->whereIn('id', $data['remove_variant_ids'])->delete(); + } + + if (array_key_exists('variants', $data)) { + $this->ensureSingleDefaultVariant($product); + } + + if (array_key_exists('collections', $data)) { + $collectionIds = $data['collections'] ?? []; + $product->collections()->sync(array_fill_keys($collectionIds, ['position' => 0])); + } + } + + /** + * @param array> $optionData + * @return array + */ + private function optionMap(Product $product, array $optionData, bool $replace): array + { + if ($replace) { + $product->options()->delete(); + } + + $options = $replace ? collect() : $product->options()->with('values')->get(); + + foreach ($optionData as $position => $optionDataItem) { + $option = $product->options()->updateOrCreate( + ['position' => $optionDataItem['position'] ?? $position + 1], + ['name' => $optionDataItem['name']], + ); + $options = $options->push($option->load('values')); + + foreach ($optionDataItem['values'] ?? [] as $valuePosition => $valueData) { + $option->values()->updateOrCreate( + ['value' => $valueData['value']], + ['position' => $valueData['position'] ?? $valuePosition + 1], + ); + } + } + + return $options->keyBy('name')->all(); + } + + /** + * @param array $optionsByName + * @param array> $optionValues + * @return array + */ + private function resolveOptionValueIds(array $optionsByName, array $optionValues): array + { + $ids = []; + + foreach ($optionValues as $optionValue) { + $option = $optionsByName[$optionValue['option_name']] ?? null; + if ($option === null) { + throw new \InvalidArgumentException('Variant option values must match the product options.'); + } + + $value = $option->values()->firstOrCreate(['value' => $optionValue['value']], ['position' => $option->values()->count() + 1]); + $ids[] = $value->getKey(); + } + + return $ids; + } + + /** @param array> $optionValues */ + private function variantTitle(array $optionValues): string + { + return $optionValues === [] ? 'Default' : implode(' / ', array_column($optionValues, 'value')); + } + + private function ensureSingleDefaultVariant(Product $product): void + { + $variants = $product->variants()->get(); + $default = $variants->firstWhere('is_default', true) ?? $variants->first(); + + if ($default === null) { + throw new \InvalidArgumentException('A product must have at least one variant.'); + } + + $product->variants()->where('id', '!=', $default->getKey())->update(['is_default' => false]); + $default->update(['is_default' => true]); } } diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php index 563647c7..7e073676 100644 --- a/app/Services/RefundService.php +++ b/app/Services/RefundService.php @@ -4,6 +4,7 @@ use App\Contracts\PaymentProvider; use App\Enums\FinancialStatus; +use App\Enums\PaymentStatus; use App\Events\OrderRefunded; use App\Models\Order; use App\Models\Payment; @@ -12,7 +13,7 @@ class RefundService { - public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory) {} + public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly AuditLogger $audit) {} /** * @param int|array|null $amount @@ -20,6 +21,10 @@ public function __construct(private readonly PaymentProvider $provider, private */ public function create(Order $order, Payment $payment, int|array|null $amount = null, ?string $reason = null, bool $restock = false, array $lines = []): Refund { + if ((int) $payment->order_id !== (int) $order->getKey() || $payment->status !== PaymentStatus::Captured) { + throw new \InvalidArgumentException('The payment is not refundable for this order.'); + } + if (is_array($amount)) { $lines = $amount; $amount = null; @@ -28,6 +33,12 @@ public function create(Order $order, Payment $payment, int|array|null $amount = $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); $order->loadMissing('lines'); $restockLines = $lines; + $previousLineQuantities = []; + foreach ($order->refunds()->where('status', 'processed')->get(['lines_json']) as $previousRefund) { + foreach ($previousRefund->lines_json ?? [] as $lineId => $quantity) { + $previousLineQuantities[(string) $lineId] = ($previousLineQuantities[(string) $lineId] ?? 0) + (int) $quantity; + } + } if ($lines !== []) { $amount = 0; @@ -39,6 +50,10 @@ public function create(Order $order, Payment $payment, int|array|null $amount = throw new \InvalidArgumentException('The refund quantity is invalid.'); } + if (($previousLineQuantities[(string) $lineId] ?? 0) + $quantity > $orderLine->quantity) { + throw new \InvalidArgumentException('The refund quantity exceeds the remaining refundable quantity.'); + } + $unitAmount = intdiv($orderLine->line_total_amount, $orderLine->quantity); $amount += $unitAmount * $quantity; } @@ -54,9 +69,9 @@ public function create(Order $order, Payment $payment, int|array|null $amount = $restockLines = $order->lines->mapWithKeys(fn ($line): array => [$line->getKey() => $line->quantity])->all(); } - return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $restockLines): Refund { + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $restockLines, $lines): Refund { $result = $this->provider->refund($payment, $amount); - $refund = $order->refunds()->create(['payment_id' => $payment->getKey(), 'amount' => $amount, 'status' => $result->successful ? 'processed' : 'failed', 'provider_refund_id' => $result->reference, 'reason' => $reason, 'restock' => $restock]); + $refund = $order->refunds()->create(['payment_id' => $payment->getKey(), 'amount' => $amount, 'status' => $result->successful ? 'processed' : 'failed', 'provider_refund_id' => $result->reference, 'reason' => $reason, 'restock' => $restock, 'lines_json' => $lines !== [] ? $lines : null]); if ($result->successful) { $totalRefunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); @@ -64,6 +79,7 @@ public function create(Order $order, Payment $payment, int|array|null $amount = 'financial_status' => $totalRefunded >= $payment->amount ? FinancialStatus::Refunded : FinancialStatus::PartiallyRefunded, 'status' => $totalRefunded >= $payment->amount ? 'refunded' : $order->status, ]); + $payment->update(['status' => $totalRefunded >= $payment->amount ? PaymentStatus::Refunded : $payment->status]); if ($restock && $restockLines !== []) { $order->load('lines.variant.inventory'); @@ -77,6 +93,7 @@ public function create(Order $order, Payment $payment, int|array|null $amount = } OrderRefunded::dispatch($order->refresh()); + $this->audit->record('order.refunded', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number, 'amount' => $amount]); } return $refund; diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php index 10d675c6..2b956241 100644 --- a/app/Services/ShippingCalculator.php +++ b/app/Services/ShippingCalculator.php @@ -14,15 +14,25 @@ public function getAvailableRates(Store $store, array $address): Collection $country = strtoupper((string) ($address['country_code'] ?? '')); $region = strtoupper((string) ($address['province_code'] ?? '')); - return ShippingRate::query()->where('is_active', true)->whereHas('zone', function ($query) use ($store, $country, $region): void { - $query->where('store_id', $store->getKey())->where(function ($zone) use ($country, $region): void { - $zone->whereJsonContains('countries_json', $country)->orWhereNull('countries_json'); - - if ($region !== '') { - $zone->orWhereJsonContains('regions_json', $region); - } - }); - })->with('zone')->get(); + $rates = ShippingRate::query()->where('is_active', true)->whereHas('zone', fn ($query) => $query->where('store_id', $store->getKey()))->with('zone')->get(); + + $matching = $rates->filter(function (ShippingRate $rate) use ($country, $region): bool { + $countries = array_map('strtoupper', $rate->zone->countries_json ?? []); + $regions = array_map('strtoupper', $rate->zone->regions_json ?? []); + + return ($region !== '' && in_array($region, $regions, true)) + || ($country !== '' && in_array($country, $countries, true)) + || ($countries === [] && $regions === []); + }); + + $specificity = $matching->groupBy(function (ShippingRate $rate) use ($country, $region): int { + $countries = array_map('strtoupper', $rate->zone->countries_json ?? []); + $regions = array_map('strtoupper', $rate->zone->regions_json ?? []); + + return ($region !== '' && in_array($region, $regions, true)) ? 2 : (($country !== '' && in_array($country, $countries, true)) ? 1 : 0); + }); + + return $specificity->sortKeysDesc()->first() ?? collect(); } public function calculate(ShippingRate $rate, Cart $cart): int @@ -48,6 +58,6 @@ private function rangeAmount(array $ranges, int $value, int $fallback): int } } - return $fallback; + return 0; } } diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php new file mode 100644 index 00000000..65f9d9cc --- /dev/null +++ b/app/Services/Tax/ManualTaxProvider.php @@ -0,0 +1,51 @@ +address['country_code'] ?? '')); + $rate = (int) ($request->settings->rates_json[$country] ?? $request->settings->default_rate_basis_points); + $taxLines = []; + + foreach ($request->lineItems as $line) { + $amount = (int) ($line['amount'] ?? $line['line_total_amount'] ?? 0); + $tax = $request->settings->prices_include_tax || $request->settings->mode === 'inclusive' + ? $this->extractInclusive($amount, $rate) + : $this->roundTax($amount, $rate); + + if ($tax > 0) { + $taxLines[] = new TaxLine('Sales tax', $rate, $tax); + } + } + + if ($request->shippingAmount > 0) { + $tax = $request->settings->prices_include_tax || $request->settings->mode === 'inclusive' + ? $this->extractInclusive($request->shippingAmount, $rate) + : $this->roundTax($request->shippingAmount, $rate); + + if ($tax > 0) { + $taxLines[] = new TaxLine('Shipping tax', $rate, $tax); + } + } + + return new TaxResult(array_sum(array_map(fn (TaxLine $line): int => $line->amount, $taxLines)), $taxLines); + } + + private function roundTax(int $amount, int $rate): int + { + return (int) floor(($amount * $rate / 10000) + 0.5); + } + + private function extractInclusive(int $amount, int $rate): int + { + return $rate > 0 ? $amount - intdiv($amount * 10000, 10000 + $rate) : 0; + } +} diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php new file mode 100644 index 00000000..04664a50 --- /dev/null +++ b/app/Services/Tax/StripeTaxProvider.php @@ -0,0 +1,21 @@ +settings->provider_config_json['fallback'] ?? 'allow') === 'block') { + throw new \RuntimeException('Stripe Tax is not configured for this environment.'); + } + + return new TaxResult(0, []); + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php index 33145379..23fdba70 100644 --- a/app/Services/TaxCalculator.php +++ b/app/Services/TaxCalculator.php @@ -2,19 +2,18 @@ namespace App\Services; +use App\Contracts\TaxProvider; use App\Models\TaxSettings; -use App\ValueObjects\TaxLine; +use App\ValueObjects\TaxCalculationRequest; use App\ValueObjects\TaxResult; class TaxCalculator { + public function __construct(private readonly ?TaxProvider $provider = null) {} + public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult { - $rates = $settings->rates_json ?? []; - $rate = (int) ($rates[strtoupper((string) ($address['country_code'] ?? ''))] ?? $settings->default_rate_basis_points); - $tax = $settings->prices_include_tax || $settings->mode === 'inclusive' ? $this->extractInclusive($amount, $rate) : $this->addExclusive($amount, $rate); - - return new TaxResult($tax, $tax > 0 ? [new TaxLine('Sales tax', $rate, $tax)] : []); + return ($this->provider ?? new \App\Services\Tax\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings)); } public function extractInclusive(int $grossAmount, int $rateBasisPoints): int diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php index 1b80fd95..d647095a 100644 --- a/app/Services/VariantMatrixService.php +++ b/app/Services/VariantMatrixService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\InventoryItem; use App\Models\Product; use App\Models\ProductVariant; @@ -35,6 +36,7 @@ public function rebuildMatrix(Product $product): void 'is_default' => $position === 0, 'position' => $position, ]); + InventoryItem::withoutGlobalScopes()->create(['store_id' => $product->store_id, 'variant_id' => $variant->getKey(), 'quantity_on_hand' => 0, 'policy' => 'deny']); } $variant->optionValues()->sync(collect($combination)->pluck('id')->all()); @@ -43,6 +45,8 @@ public function rebuildMatrix(Product $product): void foreach ($existing as $orphan) { if ($orphan->orders()->exists()) { + $orphan->update(['status' => 'archived', 'is_default' => false]); + continue; } diff --git a/app/ValueObjects/TaxCalculationRequest.php b/app/ValueObjects/TaxCalculationRequest.php new file mode 100644 index 00000000..64bd68c3 --- /dev/null +++ b/app/ValueObjects/TaxCalculationRequest.php @@ -0,0 +1,18 @@ +> $lineItems + */ + public function __construct( + public array $lineItems, + public int $shippingAmount, + public array $address, + public TaxSettings $settings, + ) {} +} diff --git a/bootstrap/app.php b/bootstrap/app.php index b0d113b8..1f7252a9 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -28,6 +28,7 @@ $middleware->alias([ 'store.resolve' => App\Http\Middleware\ResolveStore::class, 'role.check' => App\Http\Middleware\EnsureStoreRole::class, + 'api.ability' => App\Http\Middleware\EnsureApiAbility::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/auth.php b/config/auth.php index eddc80d9..bd0b5f6b 100644 --- a/config/auth.php +++ b/config/auth.php @@ -44,6 +44,10 @@ 'driver' => 'session', 'provider' => 'customers', ], + 'sanctum' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], ], /* diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 00000000..ea47980a --- /dev/null +++ b/config/cors.php @@ -0,0 +1,12 @@ + ['api/*', 'sanctum/csrf-cookie'], + 'allowed_methods' => ['*'], + 'allowed_origins' => array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', '*'))), + 'allowed_origins_patterns' => [], + 'allowed_headers' => ['*'], + 'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'], + 'max_age' => 0, + 'supports_credentials' => true, +]; diff --git a/config/logging.php b/config/logging.php index 9e998a49..b2a40373 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,14 @@ 'replace_placeholders' => true, ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..10d244ae --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => env('SANCTUM_EXPIRATION', 525600), + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'shop_'), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/session.php b/config/session.php index e6197a0f..914da71b 100644 --- a/config/session.php +++ b/config/session.php @@ -1,7 +1,5 @@ env('SESSION_ENCRYPT', false), + 'encrypt' => env('SESSION_ENCRYPT', true), /* |-------------------------------------------------------------------------- @@ -129,7 +127,7 @@ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + 'shop_session' ), /* diff --git a/config/shop.php b/config/shop.php index 48eee837..571e36ad 100644 --- a/config/shop.php +++ b/config/shop.php @@ -2,4 +2,5 @@ return [ 'bank_transfer_expiry_days' => (int) env('BANK_TRANSFER_EXPIRY_DAYS', 7), + 'order_prefix' => env('ORDER_PREFIX', '#'), ]; diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..165d3bab --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,35 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + protected $model = AnalyticsDaily::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'date' => today()->subDays(fake()->numberBetween(0, 30)), + 'orders_count' => fake()->numberBetween(2, 8), + 'revenue_amount' => fake()->numberBetween(7000, 65000), + 'aov_amount' => fake()->numberBetween(4000, 9000), + 'visits_count' => fake()->numberBetween(50, 190), + 'add_to_cart_count' => fake()->numberBetween(10, 45), + 'checkout_started_count' => fake()->numberBetween(4, 25), + 'checkout_completed_count' => fake()->numberBetween(2, 8), + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..ea718075 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,49 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + protected $model = AnalyticsEvent::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']), + 'session_id' => fake()->uuid(), + 'customer_id' => null, + 'client_event_id' => fake()->unique()->uuid(), + 'payload' => ['url' => '/'.fake()->slug(), 'referrer' => fake()->boolean(40) ? fake()->url() : null], + 'properties_json' => [], + 'occurred_at' => now()->subDays(fake()->numberBetween(0, 6))->subMinutes(fake()->numberBetween(0, 1439)), + ]; + } + + public function pageView(): static + { + return $this->state(['type' => 'page_view']); + } + + public function productView(int $productId, string $productTitle): static + { + return $this->state(fn (array $attributes): array => ['type' => 'product_view', 'payload' => array_merge($attributes['payload'] ?? [], ['product_id' => $productId, 'product_title' => $productTitle])]); + } + + public function addToCart(int $variantId, int $quantity = 1): static + { + return $this->state(fn (array $attributes): array => ['type' => 'add_to_cart', 'payload' => array_merge($attributes['payload'] ?? [], ['variant_id' => $variantId, 'quantity' => $quantity])]); + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php index 33e18712..44af93b3 100644 --- a/database/factories/CartFactory.php +++ b/database/factories/CartFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; +use App\Enums\CartStatus; +use App\Models\Customer; use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; @@ -11,6 +13,21 @@ class CartFactory extends Factory public function definition(): array { - return ['store_id' => Store::factory(), 'currency' => 'EUR', 'cart_version' => 1, 'status' => 'active']; + return ['store_id' => Store::factory(), 'customer_id' => null, 'currency' => 'EUR', 'cart_version' => 1, 'status' => CartStatus::Active, 'discount_code' => null]; + } + + public function forCustomer(): static + { + return $this->state(['customer_id' => Customer::factory()]); + } + + public function converted(): static + { + return $this->state(['status' => CartStatus::Converted]); + } + + public function abandoned(): static + { + return $this->state(['status' => CartStatus::Abandoned]); } } diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..80dc46cb --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,40 @@ + + */ +class CartLineFactory extends Factory +{ + protected $model = CartLine::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'cart_id' => CartFactory::new(), + 'variant_id' => ProductVariantFactory::new(), + 'quantity' => fake()->numberBetween(1, 5), + 'unit_price_amount' => fake()->numberBetween(999, 19999), + 'line_subtotal_amount' => 0, + 'line_discount_amount' => 0, + 'line_total_amount' => 0, + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (CartLine $line): void { + $line->line_subtotal_amount = $line->unit_price_amount * $line->quantity; + $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount; + }); + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..456222c7 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,66 @@ + + */ +class CheckoutFactory extends Factory +{ + protected $model = Checkout::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'cart_id' => CartFactory::new(), + 'customer_id' => null, + 'status' => CheckoutStatus::Started, + 'email' => fake()->safeEmail(), + 'shipping_address_json' => ['first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'address1' => fake()->streetAddress(), 'city' => fake()->city(), 'country_code' => 'DE', 'zip' => fake()->postcode()], + 'billing_address_json' => null, + 'shipping_rate_id' => null, + 'shipping_method_id' => null, + 'payment_method' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]; + } + + public function completed(): static + { + return $this->state(['status' => CheckoutStatus::Completed]); + } + + public function expired(): static + { + return $this->state(['status' => CheckoutStatus::Expired, 'expires_at' => now()->subHour()]); + } + + public function withCreditCard(): static + { + return $this->state(['payment_method' => 'credit_card']); + } + + public function withPaypal(): static + { + return $this->state(['payment_method' => 'paypal']); + } + + public function withBankTransfer(): static + { + return $this->state(['payment_method' => 'bank_transfer']); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php index f6060b4b..5d821480 100644 --- a/database/factories/CollectionFactory.php +++ b/database/factories/CollectionFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\CollectionStatus; use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; @@ -11,6 +12,21 @@ class CollectionFactory extends Factory public function definition(): array { - return ['store_id' => Store::factory(), 'title' => fake()->words(2, true), 'handle' => fake()->unique()->slug(2), 'description' => fake()->paragraph(), 'status' => 'active', 'image_url' => null]; + return ['store_id' => Store::factory(), 'title' => fake()->words(2, true), 'handle' => fake()->unique()->slug(2), 'description' => '

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

      ', 'status' => CollectionStatus::Active, 'image_url' => null]; + } + + public function draft(): static + { + return $this->state(['status' => CollectionStatus::Draft]); + } + + public function archived(): static + { + return $this->state(['status' => CollectionStatus::Archived]); + } + + public function automated(): static + { + return $this->state(['description' => '

      Automatically populated collection.

      ']); } } diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..9a15bc4d --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,35 @@ + + */ +class CustomerAddressFactory extends Factory +{ + protected $model = CustomerAddress::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work', 'Other']), + 'address_json' => ['first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'company' => '', 'address1' => fake()->streetAddress(), 'address2' => '', 'city' => fake()->city(), 'province' => '', 'province_code' => '', 'country' => 'Germany', 'country_code' => 'DE', 'zip' => fake()->postcode(), 'phone' => ''], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(['is_default' => true]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php index 07f73803..b7f1501e 100644 --- a/database/factories/CustomerFactory.php +++ b/database/factories/CustomerFactory.php @@ -4,6 +4,7 @@ use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Facades\Hash; class CustomerFactory extends Factory { @@ -11,6 +12,16 @@ class CustomerFactory extends Factory public function definition(): array { - return ['store_id' => Store::factory(), 'first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'email' => fake()->unique()->safeEmail(), 'password_hash' => 'password', 'status' => 'active']; + return ['store_id' => Store::factory(), 'first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'email' => fake()->unique()->safeEmail(), 'password_hash' => Hash::make('password'), 'status' => 'active', 'email_verified_at' => now(), 'metadata' => ['marketing_opt_in' => fake()->boolean(30)]]; + } + + public function guest(): static + { + return $this->state(['password_hash' => null, 'email_verified_at' => null]); + } + + public function optedInMarketing(): static + { + return $this->state(['metadata' => ['marketing_opt_in' => true]]); } } diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php index a911b4b5..cb62c83e 100644 --- a/database/factories/DiscountFactory.php +++ b/database/factories/DiscountFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; +use App\Enums\DiscountType; +use App\Enums\DiscountValueType; use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; @@ -11,6 +13,41 @@ class DiscountFactory extends Factory public function definition(): array { - return ['store_id' => Store::factory(), 'code' => strtoupper(fake()->unique()->lexify('CODE??')), 'type' => 'code', 'value_type' => 'percent', 'value_amount' => 10, 'status' => 'active', 'usage_limit' => null, 'usage_count' => 0, 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), 'rules_json' => []]; + return ['store_id' => Store::factory(), 'code' => strtoupper(fake()->unique()->bothify('????##')), 'type' => DiscountType::Code, 'value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'status' => 'active', 'usage_limit' => null, 'usage_count' => 0, 'starts_at' => now()->subMonth(), 'ends_at' => now()->addYear(), 'rules_json' => []]; + } + + public function fixed(int $amountCents): static + { + return $this->state(['value_type' => DiscountValueType::Fixed, 'value_amount' => $amountCents]); + } + + public function freeShipping(): static + { + return $this->state(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0]); + } + + public function expired(): static + { + return $this->state(['starts_at' => now()->subYear(), 'ends_at' => now()->subDay(), 'status' => 'expired']); + } + + public function maxedOut(): static + { + return $this->state(['usage_limit' => 5, 'usage_count' => 5]); + } + + public function automatic(): static + { + return $this->state(['type' => DiscountType::Automatic, 'code' => null]); + } + + public function draft(): static + { + return $this->state(['status' => 'draft']); + } + + public function disabled(): static + { + return $this->state(['status' => 'disabled', 'starts_at' => now()->subDay()]); } } diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..4ffcb74e --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,53 @@ + + */ +class FulfillmentFactory extends Factory +{ + protected $model = Fulfillment::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => OrderFactory::new(), + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => fake()->randomElement(['DHL', 'UPS', 'FedEx', 'DPD']), + 'tracking_number' => strtoupper(fake()->bothify('??########')), + 'tracking_url' => null, + 'shipped_at' => now(), + 'delivered_at' => null, + 'fulfilled_at' => null, + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (Fulfillment $fulfillment): void { + if ($fulfillment->tracking_number !== null) { + $fulfillment->tracking_url = 'https://tracking.example.com/'.$fulfillment->tracking_number; + } + }); + } + + public function pending(): static + { + return $this->state(['status' => FulfillmentShipmentStatus::Pending, 'tracking_number' => null, 'tracking_url' => null, 'shipped_at' => null]); + } + + public function delivered(): static + { + return $this->state(['status' => FulfillmentShipmentStatus::Delivered, 'delivered_at' => now()]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..8a5ac36d --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,28 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + protected $model = FulfillmentLine::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'fulfillment_id' => FulfillmentFactory::new(), + 'order_line_id' => OrderLineFactory::new(), + 'quantity' => fake()->numberBetween(1, 3), + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php index 858fb152..b99a5019 100644 --- a/database/factories/InventoryItemFactory.php +++ b/database/factories/InventoryItemFactory.php @@ -3,7 +3,6 @@ namespace Database\Factories; use App\Enums\InventoryPolicy; -use App\Models\ProductVariant; use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,7 +12,22 @@ class InventoryItemFactory extends Factory public function definition(): array { - return ['store_id' => Store::factory(), 'variant_id' => ProductVariant::factory(), 'quantity_on_hand' => 50, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny]; + return ['store_id' => Store::factory(), 'variant_id' => ProductVariantFactory::new(), 'quantity_on_hand' => fake()->numberBetween(0, 100), 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny]; + } + + public function outOfStock(): static + { + return $this->state(['quantity_on_hand' => 0]); + } + + public function continuePolicy(): static + { + return $this->state(['policy' => InventoryPolicy::Continue]); + } + + public function lowStock(): static + { + return $this->state(['quantity_on_hand' => fake()->numberBetween(1, 3)]); } public function backorder(): static diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..45604990 --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,48 @@ + + */ +class NavigationItemFactory extends Factory +{ + protected $model = NavigationItem::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'navigation_menu_id' => NavigationMenuFactory::new(), + 'menu_id' => null, + 'label' => fake()->words(2, true), + 'type' => 'link', + 'url' => '/', + 'resource_id' => null, + 'position' => 0, + 'parent_id' => null, + ]; + } + + public function page(int $pageId): static + { + return $this->state(['type' => 'page', 'url' => null, 'resource_id' => $pageId]); + } + + public function collection(int $collectionId): static + { + return $this->state(['type' => 'collection', 'url' => null, 'resource_id' => $collectionId]); + } + + public function product(int $productId): static + { + return $this->state(['type' => 'product', 'url' => null, 'resource_id' => $productId]); + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..5d4743ce --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,29 @@ + + */ +class NavigationMenuFactory extends Factory +{ + protected $model = NavigationMenu::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'handle' => fake()->unique()->slug(2), + 'name' => fake()->words(2, true), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index 4aa3994f..89f139d6 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -2,15 +2,67 @@ namespace Database\Factories; +use App\Enums\FinancialStatus; +use App\Enums\FulfillmentStatus; +use App\Enums\OrderStatus; +use App\Models\Customer; +use App\Models\Order; use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; class OrderFactory extends Factory { - protected $model = \App\Models\Order::class; + protected $model = Order::class; public function definition(): array { - return ['store_id' => Store::factory(), 'order_number' => '#'.fake()->unique()->numberBetween(1001, 9999), 'currency' => 'EUR', 'status' => 'processing', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', 'email' => fake()->safeEmail(), 'subtotal_amount' => 2499, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 0, 'total_amount' => 2998, 'placed_at' => now()]; + $address = ['first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'company' => '', 'address1' => fake()->streetAddress(), 'address2' => '', 'city' => fake()->city(), 'province' => '', 'province_code' => '', 'country' => 'Germany', 'country_code' => 'DE', 'zip' => fake()->postcode(), 'phone' => '']; + + return ['store_id' => Store::factory(), 'customer_id' => Customer::factory(), 'order_number' => '#'.fake()->unique()->numberBetween(1001, 9999), 'currency' => 'EUR', 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'payment_method' => 'credit_card', 'email' => fake()->safeEmail(), 'shipping_address_json' => $address, 'billing_address_json' => $address, 'subtotal_amount' => 4998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 798, 'total_amount' => 5497, 'placed_at' => now(), 'metadata' => []]; + } + + public function pending(): static + { + return $this->state(['status' => OrderStatus::Pending, 'financial_status' => FinancialStatus::Pending]); + } + + public function pendingBankTransfer(): static + { + return $this->pending()->bankTransfer(); + } + + public function fulfilled(): static + { + return $this->state(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]); + } + + public function cancelled(): static + { + return $this->state(['status' => OrderStatus::Cancelled, 'financial_status' => FinancialStatus::Refunded]); + } + + public function refunded(): static + { + return $this->state(['status' => OrderStatus::Refunded, 'financial_status' => FinancialStatus::Refunded]); + } + + public function partiallyFulfilled(): static + { + return $this->state(['fulfillment_status' => FulfillmentStatus::Partial]); + } + + public function creditCard(): static + { + return $this->state(['payment_method' => 'credit_card']); + } + + public function paypal(): static + { + return $this->state(['payment_method' => 'paypal']); + } + + public function bankTransfer(): static + { + return $this->state(['payment_method' => 'bank_transfer']); } } diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..9ed61f9f --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,50 @@ + + */ +class OrderLineFactory extends Factory +{ + protected $model = OrderLine::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => OrderFactory::new(), + 'product_id' => ProductFactory::new(), + 'variant_id' => ProductVariantFactory::new(), + 'product_title' => fake()->words(3, true), + 'title_snapshot' => fake()->words(3, true), + 'variant_title' => 'Default', + 'sku' => strtoupper(fake()->bothify('SKU-####-???')), + 'sku_snapshot' => strtoupper(fake()->bothify('SKU-####-???')), + 'quantity' => fake()->numberBetween(1, 3), + 'unit_price_amount' => fake()->numberBetween(999, 19999), + 'line_subtotal_amount' => 0, + 'line_discount_amount' => 0, + 'line_total_amount' => 0, + 'total_amount' => 0, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (OrderLine $line): void { + $line->line_subtotal_amount = $line->unit_price_amount * $line->quantity; + $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount; + $line->total_amount = $line->line_total_amount; + }); + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..57f28c15 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,46 @@ + + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $body = '

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

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

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

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

      '; + + return [ + 'store_id' => Store::factory(), + 'title' => fake()->words(3, true), + 'handle' => fake()->unique()->slug(3), + 'body_html' => $body, + 'content' => $body, + 'status' => PageStatus::Published, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(['status' => PageStatus::Draft, 'published_at' => null]); + } + + public function archived(): static + { + return $this->state(['status' => PageStatus::Draft, 'published_at' => null]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..b34bac89 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,65 @@ + + */ +class PaymentFactory extends Factory +{ + protected $model = Payment::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => OrderFactory::new(), + 'provider' => 'mock', + 'provider_payment_id' => 'mock_'.fake()->unique()->bothify('????????????????????'), + 'method' => PaymentMethod::CreditCard, + 'status' => PaymentStatus::Captured, + 'amount' => fake()->numberBetween(999, 99999), + 'currency' => 'EUR', + 'raw_json_encrypted' => null, + ]; + } + + public function pending(): static + { + return $this->state(['status' => PaymentStatus::Pending]); + } + + public function failed(): static + { + return $this->state(['status' => PaymentStatus::Failed]); + } + + public function refunded(): static + { + return $this->state(['status' => PaymentStatus::Refunded]); + } + + public function creditCard(): static + { + return $this->state(['method' => PaymentMethod::CreditCard]); + } + + public function paypal(): static + { + return $this->state(['method' => PaymentMethod::Paypal]); + } + + public function bankTransfer(): static + { + return $this->state(['method' => PaymentMethod::BankTransfer]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index fa544576..b3ebaf36 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -3,6 +3,7 @@ namespace Database\Factories; use App\Enums\ProductStatus; +use App\Models\Product; use App\Models\Store; use Illuminate\Database\Eloquent\Factories\Factory; @@ -12,11 +13,48 @@ class ProductFactory extends Factory public function definition(): array { - return ['store_id' => Store::factory(), 'title' => fake()->words(3, true), 'handle' => fake()->unique()->slug(3), 'description' => fake()->paragraph(), 'vendor' => fake()->company(), 'product_type' => 'Apparel', 'tags' => ['featured'], 'status' => ProductStatus::Active, 'published_at' => now(), 'sales_count' => 0]; + $description = collect(fake()->paragraphs(2))->map(fn (string $paragraph): string => "

      {$paragraph}

      ")->implode(''); + + return [ + 'store_id' => Store::factory(), + 'title' => fake()->words(3, true), + 'handle' => fake()->unique()->slug(3), + 'description' => strip_tags($description), + 'description_html' => $description, + '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)), + 'status' => ProductStatus::Active, + 'published_at' => now(), + 'sales_count' => 0, + 'metadata' => [], + ]; } public function draft(): static { return $this->state(['status' => ProductStatus::Draft, 'published_at' => null]); } + + public function archived(): static + { + return $this->state(['status' => ProductStatus::Archived]); + } + + public function withVariants(int $count): static + { + return $this->afterCreating(function (Product $product) use ($count): void { + ProductVariantFactory::new()->count($count)->for($product)->create(); + }); + } + + public function withDefaultVariant(int $priceAmount): static + { + return $this->afterCreating(function (Product $product) use ($priceAmount): void { + ProductVariantFactory::new()->for($product)->state([ + 'price_amount' => $priceAmount, + 'is_default' => true, + ])->create(); + }); + } } diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..28c6e6e1 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,41 @@ + + */ +class ProductMediaFactory extends Factory +{ + protected $model = ProductMedia::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => ProductFactory::new(), + 'type' => MediaType::Image, + 'path' => 'products/'.fake()->uuid().'.jpg', + 'storage_key' => null, + 'url' => 'https://images.example.test/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => 100000, + 'checksum' => fake()->sha256(), + 'status' => MediaStatus::Ready, + 'position' => 0, + 'metadata' => [], + ]; + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..9ca1941a --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,28 @@ + + */ +class ProductOptionFactory extends Factory +{ + protected $model = ProductOption::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => ProductFactory::new(), + '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..4fd17c81 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,28 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + protected $model = ProductOptionValue::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOptionFactory::new(), + 'value' => fake()->word(), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php index 08f5b833..0a9ae11f 100644 --- a/database/factories/ProductVariantFactory.php +++ b/database/factories/ProductVariantFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use App\Models\Product; +use App\Enums\VariantStatus; use Illuminate\Database\Eloquent\Factories\Factory; class ProductVariantFactory extends Factory @@ -11,6 +11,47 @@ class ProductVariantFactory extends Factory public function definition(): array { - return ['product_id' => Product::factory(), 'title' => 'Default', 'sku' => strtoupper(fake()->bothify('SKU-####')), 'price_amount' => 2499, 'compare_at_amount' => null, 'cost_amount' => 1000, 'weight_grams' => 250, 'requires_shipping' => true, 'is_default' => true, 'position' => 0]; + $weight = fake()->numberBetween(100, 5000); + + return [ + 'product_id' => ProductFactory::new(), + 'title' => 'Default', + 'sku' => strtoupper(fake()->bothify('SKU-####-???')), + 'barcode' => fake()->ean13(), + 'price_amount' => fake()->numberBetween(999, 19999), + 'compare_at_amount' => null, + 'cost_amount' => null, + 'currency' => 'EUR', + 'weight_grams' => $weight, + 'weight_g' => $weight, + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + 'metadata' => [], + ]; + } + + public function onSale(): static + { + return $this->state([ + 'compare_at_amount' => fake()->numberBetween(20000, 39999), + 'price_amount' => fake()->numberBetween(9999, 19999), + ]); + } + + public function digital(): static + { + return $this->state(['requires_shipping' => false, 'weight_grams' => 0, 'weight_g' => 0]); + } + + public function default(): static + { + return $this->state(['is_default' => true]); + } + + public function archived(): static + { + return $this->state(['status' => VariantStatus::Archived]); } } diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..8c030c8d --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,44 @@ + + */ +class RefundFactory extends Factory +{ + protected $model = Refund::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => OrderFactory::new(), + 'payment_id' => PaymentFactory::new(), + 'amount' => fake()->numberBetween(999, 19999), + 'reason' => fake()->sentence(), + 'status' => RefundStatus::Processed, + 'restock' => false, + 'provider_refund_id' => 'mock_re_'.fake()->unique()->bothify('????????????????????'), + 'lines_json' => [], + ]; + } + + public function pending(): static + { + return $this->state(['status' => RefundStatus::Pending, 'provider_refund_id' => null]); + } + + public function failed(): static + { + return $this->state(['status' => RefundStatus::Failed]); + } +} diff --git a/database/factories/SearchSettingFactory.php b/database/factories/SearchSettingFactory.php new file mode 100644 index 00000000..bee90a75 --- /dev/null +++ b/database/factories/SearchSettingFactory.php @@ -0,0 +1,32 @@ + + */ +class SearchSettingFactory extends Factory +{ + protected $model = SearchSetting::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'synonyms' => [['tee', 't-shirt']], + 'stopwords' => ['the', 'a'], + 'synonyms_json' => [['tee', 't-shirt']], + 'stop_words_json' => ['the', 'a'], + 'enabled' => true, + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..abe0da92 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,44 @@ + + */ +class ShippingRateFactory extends Factory +{ + protected $model = ShippingRate::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'shipping_zone_id' => ShippingZoneFactory::new(), + 'name' => fake()->randomElement(['Standard', 'Express', 'Economy']), + 'type' => 'flat', + 'price_amount' => 499, + 'currency' => 'EUR', + 'config_json' => ['amount' => 499], + 'is_active' => true, + 'estimated_days_min' => 3, + 'estimated_days_max' => 5, + ]; + } + + public function inactive(): static + { + return $this->state(['is_active' => false]); + } + + public function weightBased(): static + { + return $this->state(['type' => 'weight', 'config_json' => ['ranges' => [['min_g' => 0, 'max_g' => 500, 'amount' => 399], ['min_g' => 501, 'max_g' => 2000, 'amount' => 699], ['min_g' => 2001, 'max_g' => null, 'amount' => 1299]]]]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..10b175f3 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,30 @@ + + */ +class ShippingZoneFactory extends Factory +{ + protected $model = ShippingZone::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->country(), + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php index 8de74c44..28fd72a0 100644 --- a/database/factories/StoreDomainFactory.php +++ b/database/factories/StoreDomainFactory.php @@ -22,8 +22,23 @@ public function definition(): array 'store_id' => Store::factory(), 'hostname' => fake()->unique()->domainName(), 'type' => StoreDomainType::Storefront, - 'is_primary' => false, + 'is_primary' => true, 'tls_mode' => 'managed', ]; } + + public function admin(): static + { + return $this->state(['type' => StoreDomainType::Admin]); + } + + public function api(): static + { + return $this->state(['type' => StoreDomainType::Api]); + } + + public function secondary(): static + { + return $this->state(['is_primary' => false]); + } } diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php index 69f21442..a6f85d76 100644 --- a/database/factories/StoreFactory.php +++ b/database/factories/StoreFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\StoreStatus; use App\Models\Organization; use Illuminate\Database\Eloquent\Factories\Factory; @@ -21,12 +22,17 @@ public function definition(): array 'organization_id' => Organization::factory(), 'name' => fake()->company().' Store', 'handle' => fake()->unique()->slug(2), - 'status' => 'active', - 'default_currency' => 'USD', + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', 'default_locale' => 'en', - 'timezone' => 'UTC', + 'timezone' => 'Europe/Berlin', 'primary_domain' => null, 'metadata' => [], ]; } + + public function suspended(): static + { + return $this->state(['status' => StoreStatus::Suspended]); + } } diff --git a/database/factories/StoreInvitationFactory.php b/database/factories/StoreInvitationFactory.php new file mode 100644 index 00000000..062ecc3f --- /dev/null +++ b/database/factories/StoreInvitationFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreInvitationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'role' => 'staff', + 'invited_at' => now(), + 'expires_at' => now()->addDays(7), + 'accepted_at' => null, + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..7a8b81a9 --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,34 @@ + + */ +class TaxSettingsFactory extends Factory +{ + protected $model = TaxSettings::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => ['default_rate_bps' => 1900], + 'default_rate_basis_points' => 1900, + 'rates_json' => ['DE' => 1900], + 'provider_config_json' => [], + ]; + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..e05dcce2 --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,36 @@ + + */ +class ThemeFactory extends Factory +{ + protected $model = Theme::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => 'Default Theme', + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + ]; + } + + public function draft(): static + { + return $this->state(['status' => ThemeStatus::Draft]); + } +} diff --git a/database/factories/ThemeSettingFactory.php b/database/factories/ThemeSettingFactory.php new file mode 100644 index 00000000..b1f64a7b --- /dev/null +++ b/database/factories/ThemeSettingFactory.php @@ -0,0 +1,27 @@ + + */ +class ThemeSettingFactory extends Factory +{ + protected $model = ThemeSetting::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => ThemeFactory::new(), + 'settings_json' => ['primary_color' => fake()->safeHexColor()], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..be7fd955 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -23,11 +23,16 @@ class UserFactory extends Factory */ public function definition(): array { + $password = static::$password ??= Hash::make('password'); + return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password' => $password, + 'password_hash' => $password, + 'status' => 'active', + 'last_login_at' => now()->subDays(fake()->numberBetween(0, 30)), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -56,4 +61,9 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + + public function disabled(): static + { + return $this->state(['status' => 'disabled']); + } } diff --git a/database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php b/database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php new file mode 100644 index 00000000..d7399145 --- /dev/null +++ b/database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php @@ -0,0 +1,28 @@ +json('lines_json')->nullable()->after('restock'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('refunds', function (Blueprint $table): void { + $table->dropColumn('lines_json'); + }); + } +}; diff --git a/database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php b/database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php new file mode 100644 index 00000000..1bceebec --- /dev/null +++ b/database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php @@ -0,0 +1,123 @@ +longText('description_html')->nullable(); + } + + if (! Schema::hasColumn('collections', 'type')) { + $table->string('type')->default('manual')->index(); + } + }); + + Schema::table('themes', function (Blueprint $table): void { + if (! Schema::hasColumn('themes', 'published_at')) { + $table->timestamp('published_at')->nullable()->index(); + } + }); + + Schema::table('navigation_menus', function (Blueprint $table): void { + if (! Schema::hasColumn('navigation_menus', 'title')) { + $table->string('title')->nullable(); + } + }); + + Schema::table('search_queries', function (Blueprint $table): void { + if (! Schema::hasColumn('search_queries', 'filters_json')) { + $table->json('filters_json')->nullable(); + } + }); + + Schema::table('customers', function (Blueprint $table): void { + if (! Schema::hasColumn('customers', 'name')) { + $table->string('name')->nullable(); + } + + if (! Schema::hasColumn('customers', 'marketing_opt_in')) { + $table->boolean('marketing_opt_in')->default(false)->index(); + } + }); + + Schema::table('app_installations', function (Blueprint $table): void { + if (! Schema::hasColumn('app_installations', 'scopes_json')) { + $table->json('scopes_json')->nullable(); + } + + if (! Schema::hasColumn('app_installations', 'installed_at')) { + $table->timestamp('installed_at')->nullable(); + } + }); + + Schema::table('oauth_clients', function (Blueprint $table): void { + if (! Schema::hasColumn('oauth_clients', 'app_id')) { + $table->unsignedBigInteger('app_id')->nullable()->index(); + } + + if (! Schema::hasColumn('oauth_clients', 'redirect_uris_json')) { + $table->json('redirect_uris_json')->nullable(); + } + }); + + Schema::table('oauth_tokens', function (Blueprint $table): void { + if (! Schema::hasColumn('oauth_tokens', 'installation_id')) { + $table->unsignedBigInteger('installation_id')->nullable()->index(); + } + + if (! Schema::hasColumn('oauth_tokens', 'access_token_hash')) { + $table->string('access_token_hash')->nullable()->unique(); + } + + if (! Schema::hasColumn('oauth_tokens', 'refresh_token_hash')) { + $table->string('refresh_token_hash')->nullable(); + } + }); + + Schema::table('webhook_subscriptions', function (Blueprint $table): void { + if (! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) { + $table->text('signing_secret_encrypted')->nullable(); + } + }); + + Schema::table('webhook_deliveries', function (Blueprint $table): void { + if (! Schema::hasColumn('webhook_deliveries', 'last_attempt_at')) { + $table->timestamp('last_attempt_at')->nullable()->index(); + } + }); + } + + public function down(): void + { + foreach ([ + 'description_html' => 'collections', + 'type' => 'collections', + 'published_at' => 'themes', + 'title' => 'navigation_menus', + 'filters_json' => 'search_queries', + 'name' => 'customers', + 'marketing_opt_in' => 'customers', + 'scopes_json' => 'app_installations', + 'installed_at' => 'app_installations', + 'app_id' => 'oauth_clients', + 'redirect_uris_json' => 'oauth_clients', + 'installation_id' => 'oauth_tokens', + 'access_token_hash' => 'oauth_tokens', + 'refresh_token_hash' => 'oauth_tokens', + 'signing_secret_encrypted' => 'webhook_subscriptions', + 'last_attempt_at' => 'webhook_deliveries', + ] as $column => $tableName) { + if (Schema::hasColumn($tableName, $column)) { + Schema::table($tableName, function (Blueprint $table) use ($column): void { + $table->dropColumn($column); + }); + } + } + } +}; diff --git a/database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php b/database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php new file mode 100644 index 00000000..2b4e163b --- /dev/null +++ b/database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php @@ -0,0 +1,52 @@ +unsignedBigInteger('store_id')->nullable(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'email']); + $table->index('email'); + }); + + DB::table('customer_password_reset_tokens')->get()->each(function (object $token): void { + DB::table('customer_password_reset_tokens_scoped')->insert((array) $token); + }); + + Schema::drop('customer_password_reset_tokens'); + Schema::rename('customer_password_reset_tokens_scoped', 'customer_password_reset_tokens'); + } + + public function down(): void + { + if (! Schema::hasTable('customer_password_reset_tokens')) { + return; + } + + Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + DB::table('customer_password_reset_tokens')->select(['email', 'token', 'created_at'])->orderBy('created_at')->get()->each(function (object $token): void { + DB::table('customer_password_reset_tokens_legacy')->insertOrIgnore((array) $token); + }); + + Schema::drop('customer_password_reset_tokens'); + Schema::rename('customer_password_reset_tokens_legacy', 'customer_password_reset_tokens'); + } +}; diff --git a/database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php b/database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php new file mode 100644 index 00000000..04ea0f7d --- /dev/null +++ b/database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php @@ -0,0 +1,269 @@ +migrateThemeSettings(); + $this->migrateCustomerPasswordResetTokens(); + $this->migrateWebhookSubscriptions(); + } + + public function down(): void + { + $this->restoreWebhookSubscriptions(); + $this->restoreCustomerPasswordResetTokens(); + $this->restoreThemeSettings(); + } + + private function migrateThemeSettings(): void + { + if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) { + return; + } + + Schema::create('theme_settings_contract', function (Blueprint $table): void { + $table->unsignedBigInteger('theme_id')->primary(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete(); + }); + + $settingsByTheme = []; + $updatedAtByTheme = []; + + DB::table('theme_settings')->orderBy('theme_id')->orderBy('id')->get()->each(function (object $row) use (&$settingsByTheme, &$updatedAtByTheme): void { + $themeId = (int) $row->theme_id; + $settingsByTheme[$themeId][$row->key] = $this->decodeJson($row->value); + + if ($row->updated_at !== null && (! isset($updatedAtByTheme[$themeId]) || $row->updated_at > $updatedAtByTheme[$themeId])) { + $updatedAtByTheme[$themeId] = $row->updated_at; + } + }); + + foreach ($settingsByTheme as $themeId => $settings) { + DB::table('theme_settings_contract')->insert([ + 'theme_id' => $themeId, + 'settings_json' => $this->encodeJsonObject($settings), + 'updated_at' => $updatedAtByTheme[$themeId] ?? null, + ]); + } + + $this->replaceTable('theme_settings', 'theme_settings_contract'); + } + + private function restoreThemeSettings(): void + { + if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) { + return; + } + + Schema::create('theme_settings_legacy', function (Blueprint $table): void { + $table->id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('key'); + $table->json('value')->nullable(); + $table->timestamps(); + $table->unique(['theme_id', 'key']); + }); + + DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $row): void { + $settings = $this->decodeJson($row->settings_json); + + if (! is_array($settings)) { + return; + } + + foreach ($settings as $key => $value) { + DB::table('theme_settings_legacy')->insert([ + 'theme_id' => $row->theme_id, + 'key' => (string) $key, + 'value' => $this->encodeJson($value), + 'created_at' => null, + 'updated_at' => $row->updated_at, + ]); + } + }); + + $this->replaceTable('theme_settings', 'theme_settings_legacy'); + } + + private function migrateCustomerPasswordResetTokens(): void + { + if (! Schema::hasTable('customer_password_reset_tokens')) { + return; + } + + Schema::create('customer_password_reset_tokens_contract', function (Blueprint $table): void { + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'email']); + $table->index('email'); + }); + + DB::table('customer_password_reset_tokens') + ->whereNotNull('store_id') + ->orderBy('store_id') + ->orderBy('email') + ->get() + ->each(function (object $row): void { + DB::table('customer_password_reset_tokens_contract')->insert([ + 'store_id' => $row->store_id, + 'email' => $row->email, + 'token' => $row->token, + 'created_at' => $row->created_at, + ]); + }); + + $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_contract'); + } + + private function restoreCustomerPasswordResetTokens(): void + { + if (! Schema::hasTable('customer_password_reset_tokens')) { + return; + } + + Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void { + $table->unsignedBigInteger('store_id')->nullable(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'email']); + $table->index('email'); + }); + + DB::table('customer_password_reset_tokens')->get()->each(function (object $row): void { + DB::table('customer_password_reset_tokens_legacy')->insert([ + 'store_id' => $row->store_id, + 'email' => $row->email, + 'token' => $row->token, + 'created_at' => $row->created_at, + ]); + }); + + $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_legacy'); + } + + private function migrateWebhookSubscriptions(): void + { + if (! Schema::hasTable('webhook_subscriptions') || Schema::hasColumn('webhook_subscriptions', 'secret_encrypted') === false) { + return; + } + + Schema::create('webhook_subscriptions_contract', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('event'); + $table->string('event_type'); + $table->text('target_url'); + $table->unsignedBigInteger('app_installation_id')->nullable()->index(); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active')->index(); + $table->unsignedInteger('consecutive_failures')->default(0); + $table->timestamps(); + $table->index(['store_id', 'event']); + $table->index(['store_id', 'event_type']); + }); + + DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void { + DB::table('webhook_subscriptions_contract')->insert([ + 'id' => $row->id, + 'store_id' => $row->store_id, + 'event' => $row->event, + 'event_type' => $row->event_type ?? $row->event, + 'target_url' => $row->target_url, + 'app_installation_id' => $row->app_installation_id ?? null, + 'signing_secret_encrypted' => $row->signing_secret_encrypted ?? $row->secret_encrypted, + 'status' => $row->status, + 'consecutive_failures' => $row->consecutive_failures, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + }); + + $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_contract'); + } + + private function restoreWebhookSubscriptions(): void + { + if (! Schema::hasTable('webhook_subscriptions') || ! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) { + return; + } + + Schema::create('webhook_subscriptions_legacy', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('event'); + $table->string('event_type')->nullable(); + $table->text('target_url'); + $table->unsignedBigInteger('app_installation_id')->nullable()->index(); + $table->text('secret_encrypted'); + $table->string('status')->default('active'); + $table->unsignedInteger('consecutive_failures')->default(0); + $table->timestamps(); + $table->index(['store_id', 'event']); + }); + + DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void { + DB::table('webhook_subscriptions_legacy')->insert([ + 'id' => $row->id, + 'store_id' => $row->store_id, + 'event' => $row->event, + 'event_type' => $row->event_type, + 'target_url' => $row->target_url, + 'app_installation_id' => $row->app_installation_id, + 'secret_encrypted' => $row->signing_secret_encrypted, + 'status' => $row->status, + 'consecutive_failures' => $row->consecutive_failures, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + }); + + $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_legacy'); + } + + private function replaceTable(string $table, string $replacement): void + { + Schema::disableForeignKeyConstraints(); + + try { + Schema::drop($table); + Schema::rename($replacement, $table); + } finally { + Schema::enableForeignKeyConstraints(); + } + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || ! is_string($value)) { + return $value; + } + + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return $value; + } + } + + private function encodeJson(mixed $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + /** @param array $settings */ + private function encodeJsonObject(array $settings): string + { + return $this->encodeJson($settings === [] ? (object) [] : $settings); + } +}; diff --git a/database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php b/database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php new file mode 100644 index 00000000..6d5f50c5 --- /dev/null +++ b/database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php @@ -0,0 +1,52 @@ +whereNotNull('settings')->orderBy('id')->get()->each(function (object $theme): void { + $settings = is_string($theme->settings) + ? json_decode($theme->settings, true) + : $theme->settings; + + DB::table('theme_settings')->updateOrInsert( + ['theme_id' => $theme->id], + ['settings_json' => json_encode(is_array($settings) ? $settings : new \stdClass), 'updated_at' => now()], + ); + }); + + Schema::table('themes', function (Blueprint $table): void { + $table->dropColumn('settings'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('themes', 'settings')) { + return; + } + + Schema::table('themes', function (Blueprint $table): void { + $table->json('settings')->nullable(); + }); + + DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $settings): void { + DB::table('themes')->where('id', $settings->theme_id)->update(['settings' => $settings->settings_json]); + }); + } +}; diff --git a/database/migrations/2026_08_21_000335_create_store_invitations_table.php b/database/migrations/2026_08_21_000335_create_store_invitations_table.php new file mode 100644 index 00000000..ccc69faa --- /dev/null +++ b/database/migrations/2026_08_21_000335_create_store_invitations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('role'); + $table->timestamp('invited_at'); + $table->timestamp('expires_at'); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'email']); + $table->index(['store_id', 'expires_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_invitations'); + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..af8001d5 --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,16 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + + foreach ([ + ['store' => 'acme-fashion', 'title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description' => 'Discover the latest additions to our store.'], + ['store' => 'acme-fashion', 'title' => 'T-Shirts', 'handle' => 't-shirts', 'description' => 'Premium cotton tees for every occasion.'], + ['store' => 'acme-fashion', 'title' => 'Pants & Jeans', 'handle' => 'pants-jeans', 'description' => 'Find the perfect fit from our denim and trouser range.'], + ['store' => 'acme-fashion', 'title' => 'Sale', 'handle' => 'sale', 'description' => 'Great deals on selected items.'], + ['store' => 'acme-electronics', 'title' => 'Featured', 'handle' => 'featured', 'description' => 'Our most popular technology.'], + ['store' => 'acme-electronics', 'title' => 'Accessories', 'handle' => 'accessories', 'description' => 'The extras that complete your setup.'], + ] as $collection) { + Collection::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $stores[$collection['store']]->getKey(), 'handle' => $collection['handle']], + ['title' => $collection['title'], 'description' => '

      '.$collection['description'].'

      ', 'status' => 'active', 'image_url' => null], + ); + } + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..0841fa63 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,78 @@ + 'customer@acme.test', 'first_name' => 'John', 'last_name' => 'Doe', 'marketing_opt_in' => true], + ['email' => 'jane@example.com', 'first_name' => 'Jane', 'last_name' => 'Smith', 'marketing_opt_in' => false], + ['email' => 'michael@example.com', 'first_name' => 'Michael', 'last_name' => 'Brown', 'marketing_opt_in' => true], + ['email' => 'sarah@example.com', 'first_name' => 'Sarah', 'last_name' => 'Wilson', 'marketing_opt_in' => false], + ['email' => 'david@example.com', 'first_name' => 'David', 'last_name' => 'Lee', 'marketing_opt_in' => true], + ['email' => 'emma@example.com', 'first_name' => 'Emma', 'last_name' => 'Garcia', 'marketing_opt_in' => false], + ['email' => 'james@example.com', 'first_name' => 'James', 'last_name' => 'Taylor', 'marketing_opt_in' => false], + ['email' => 'lisa@example.com', 'first_name' => 'Lisa', 'last_name' => 'Anderson', 'marketing_opt_in' => true], + ['email' => 'robert@example.com', 'first_name' => 'Robert', 'last_name' => 'Martinez', 'marketing_opt_in' => false], + ['email' => 'anna@example.com', 'first_name' => 'Anna', 'last_name' => 'Thomas', 'marketing_opt_in' => true], + ]; + $electronicsCustomers = [ + ['email' => 'techfan@example.com', 'first_name' => 'Tech', 'last_name' => 'Fan', 'marketing_opt_in' => false], + ['email' => 'gadgetlover@example.com', 'first_name' => 'Gadget', 'last_name' => 'Lover', 'marketing_opt_in' => true], + ]; + + foreach ($fashionCustomers as $position => $customerData) { + $customer = $this->seedCustomer('acme-fashion', $customerData); + $this->seedAddresses($customer, $position); + } + + foreach ($electronicsCustomers as $position => $customerData) { + $customer = $this->seedCustomer('acme-electronics', $customerData); + $this->seedAddresses($customer, $position + 10); + } + } + + private function seedCustomer(string $storeHandle, array $customerData): Customer + { + $storeId = \App\Models\Store::query()->where('handle', $storeHandle)->value('id'); + $password = Hash::make('password'); + + return Customer::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $storeId, 'email' => $customerData['email']], + ['first_name' => $customerData['first_name'], 'last_name' => $customerData['last_name'], 'password_hash' => $password, 'status' => 'active', 'email_verified_at' => now(), 'metadata' => ['marketing_opt_in' => $customerData['marketing_opt_in']]], + ); + } + + private function seedAddresses(Customer $customer, int $position): void + { + $addresses = match ($customer->email) { + 'customer@acme.test' => [ + ['label' => 'Home', 'is_default' => true, 'address' => $this->address('John', 'Doe', 'Hauptstrasse 1', 'Berlin', '10115', '+49 30 12345678')], + ['label' => 'Work', 'is_default' => false, 'address' => $this->address('John', 'Doe', 'Friedrichstrasse 100, 3rd Floor', 'Berlin', '10117', '+49 30 87654321', 'Acme Corp')], + ], + 'jane@example.com' => [['label' => 'Home', 'is_default' => true, 'address' => $this->address('Jane', 'Smith', 'Schillerstrasse 45', 'Munich', '80336', '', '', 'Bavaria', 'BY')]], + default => [['label' => 'Home', 'is_default' => true, 'address' => $this->address($customer->first_name, $customer->last_name, $position < 10 ? 'Hauptstrasse '.($position + 10) : 'Teststrasse '.($position + 1), $position % 2 === 0 ? 'Berlin' : 'Hamburg', $position < 10 ? '10115' : '20095', '')]], + }; + + foreach ($addresses as $address) { + $customer->addresses()->updateOrCreate( + ['label' => $address['label']], + ['address_json' => $address['address'], 'is_default' => $address['is_default']], + ); + } + } + + private function address(string $firstName, string $lastName, string $address1, string $city, string $zip, string $phone, string $company = '', string $province = '', string $provinceCode = ''): array + { + return ['first_name' => $firstName, 'last_name' => $lastName, 'company' => $company, 'address1' => $address1, 'address2' => '', 'city' => $city, 'province' => $province, 'province_code' => $provinceCode, 'country' => 'Germany', 'country_code' => 'DE', 'zip' => $zip, 'phone' => $phone]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9e5ec70e..84f69730 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -11,6 +11,25 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - $this->call(ShopSeeder::class); + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, + ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..1a1197f4 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,33 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $discounts = [ + ['code' => 'WELCOME10', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'usage_count' => 3, 'rules_json' => ['min_purchase_amount' => 2000], 'status' => 'active'], + ['code' => 'FLAT5', 'value_type' => DiscountValueType::Fixed, 'value_amount' => 500, 'usage_count' => 0, 'rules_json' => [], 'status' => 'active'], + ['code' => 'FREESHIP', 'value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0, 'usage_count' => 1, 'rules_json' => [], 'status' => 'active'], + ['code' => 'EXPIRED20', 'value_type' => DiscountValueType::Percent, 'value_amount' => 20, 'usage_count' => 0, 'rules_json' => [], 'status' => 'expired', 'starts_at' => now()->subYears(2), 'ends_at' => now()->subYear()], + ['code' => 'MAXED', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'usage_count' => 5, 'usage_limit' => 5, 'rules_json' => [], 'status' => 'active'], + ]; + + foreach ($discounts as $discount) { + Discount::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'code' => $discount['code']], + ['type' => 'code', 'value_type' => $discount['value_type'], 'value_amount' => $discount['value_amount'], 'status' => $discount['status'], 'usage_limit' => $discount['usage_limit'] ?? null, 'usage_count' => $discount['usage_count'], 'starts_at' => $discount['starts_at'] ?? now()->subYear(), 'ends_at' => $discount['ends_at'] ?? now()->addYear(), 'rules_json' => $discount['rules_json']], + ); + } + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..e9be7a0f --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,16 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + $customers = Customer::withoutGlobalScopes()->whereIn('store_id', $stores->pluck('id'))->get()->keyBy('email'); + $products = Product::withoutGlobalScopes()->with('variants')->whereIn('store_id', $stores->pluck('id'))->get()->keyBy('handle'); + $discount = \App\Models\Discount::withoutGlobalScopes()->where('store_id', $stores['acme-fashion']->getKey())->where('code', 'WELCOME10')->firstOrFail(); + + foreach ($this->fashionOrders($discount->getKey()) as $orderData) { + $this->seedOrder($orderData, $customers, $products); + } + + foreach ($this->electronicsOrders() as $orderData) { + $this->seedOrder($orderData, $customers, $products); + } + } + + private function seedOrder(array $orderData, $customers, $products): void + { + $customer = $customers->get($orderData['customer']); + $address = $customer?->addresses()->where('is_default', true)->first()?->address_json; + + $order = Order::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $orderData['store_id'], 'order_number' => $orderData['order_number']], + ['customer_id' => $customer?->getKey(), 'currency' => 'EUR', 'status' => $orderData['status'], 'financial_status' => $orderData['financial_status'], 'fulfillment_status' => $orderData['fulfillment_status'], 'payment_method' => $orderData['payment_method'], 'email' => $customer?->email ?? $orderData['customer'], 'shipping_address_json' => $address, 'billing_address_json' => $address, 'subtotal_amount' => $orderData['subtotal_amount'], 'discount_amount' => $orderData['discount_amount'], 'shipping_amount' => $orderData['shipping_amount'], 'tax_amount' => $orderData['tax_amount'], 'total_amount' => $orderData['total_amount'], 'placed_at' => $orderData['placed_at'], 'metadata' => ['seed_fixture' => true]], + ); + + foreach ($order->fulfillments as $fulfillment) { + $fulfillment->lines()->delete(); + } + $order->fulfillments()->delete(); + $order->refunds()->delete(); + $order->payments()->delete(); + $order->lines()->delete(); + + $lineModels = []; + foreach ($orderData['lines'] as $lineData) { + $product = $products->get($lineData['handle']); + $variant = $product?->variants->firstWhere('title', $lineData['variant']) ?? $product?->variants->first(); + $discountAmount = $lineData['discount_amount'] ?? 0; + $subtotal = $variant->price_amount * $lineData['quantity']; + + $lineModels[] = $order->lines()->create([ + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'product_title' => $product->title, + 'title_snapshot' => $product->title, + 'variant_title' => $variant->title, + 'sku' => $variant->sku, + 'sku_snapshot' => $variant->sku, + 'quantity' => $lineData['quantity'], + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => $discountAmount, + 'line_total_amount' => $subtotal - $discountAmount, + 'total_amount' => $subtotal - $discountAmount, + 'tax_lines_json' => [], + 'discount_allocations_json' => isset($lineData['discount_amount']) ? [['discount_id' => $lineData['discount_id'], 'amount' => $discountAmount]] : [], + ]); + } + + $payment = $order->payments()->create(['provider' => 'mock', 'provider_payment_id' => $orderData['provider_payment_id'], 'method' => $orderData['payment_method'], 'status' => $orderData['payment_status'], 'amount' => $orderData['total_amount'], 'currency' => 'EUR']); + + if (isset($orderData['refund'])) { + $refundLines = []; + foreach ($orderData['refund']['line_indexes'] as $lineIndex) { + $refundLines[] = ['order_line_id' => $lineModels[$lineIndex]->getKey(), 'quantity' => $orderData['lines'][$lineIndex]['quantity']]; + } + + $order->refunds()->create(['payment_id' => $payment->getKey(), 'amount' => $orderData['refund']['amount'], 'reason' => $orderData['refund']['reason'], 'status' => RefundStatus::Processed, 'restock' => false, 'provider_refund_id' => $orderData['refund']['provider_refund_id'], 'lines_json' => $refundLines]); + } + + if (isset($orderData['fulfillment'])) { + $fulfillmentData = $orderData['fulfillment']; + $fulfillment = $order->fulfillments()->create(['status' => $fulfillmentData['status'], 'tracking_company' => $fulfillmentData['tracking_company'], 'tracking_number' => $fulfillmentData['tracking_number'], 'tracking_url' => $fulfillmentData['tracking_number'] === null ? null : 'https://tracking.example.com/'.$fulfillmentData['tracking_number'], 'shipped_at' => $fulfillmentData['shipped_at'], 'delivered_at' => $fulfillmentData['delivered_at'], 'fulfilled_at' => $fulfillmentData['status'] === FulfillmentShipmentStatus::Delivered ? $fulfillmentData['delivered_at'] : null]); + + foreach ($fulfillmentData['line_indexes'] as $lineIndex) { + $fulfillment->lines()->create(['order_line_id' => $lineModels[$lineIndex]->getKey(), 'quantity' => $orderData['lines'][$lineIndex]['quantity']]); + } + } + } + + private function fashionOrders(int $discountId): array + { + $base = ['store_id' => Store::query()->where('handle', 'acme-fashion')->value('id')]; + + return [ + array_merge($base, ['order_number' => '#1001', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(2), 'lines' => [['handle' => 'classic-cotton-t-shirt', 'variant' => 'S / White', 'quantity' => 2]], 'subtotal_amount' => 4998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 798, 'total_amount' => 5497, 'provider_payment_id' => 'mock_test_order1001', 'payment_status' => PaymentStatus::Captured]), + array_merge($base, ['order_number' => '#1002', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(10), 'lines' => [['handle' => 'organic-hoodie', 'variant' => 'M', 'quantity' => 1], ['handle' => 'classic-cotton-t-shirt', 'variant' => 'L / Black', 'quantity' => 1]], 'subtotal_amount' => 8498, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1357, 'total_amount' => 8997, 'provider_payment_id' => 'mock_test_order1002', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'DHL', 'tracking_number' => 'DHL1234567890', 'shipped_at' => now()->subDays(8), 'delivered_at' => now()->subDays(2), 'line_indexes' => [0, 1]]]), + array_merge($base, ['order_number' => '#1003', 'customer' => 'jane@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Partial, 'placed_at' => now()->subDays(5), 'lines' => [['handle' => 'premium-slim-fit-jeans', 'variant' => '32 / Blue', 'quantity' => 1], ['handle' => 'leather-belt', 'variant' => 'L/XL / Brown', 'quantity' => 1]], 'subtotal_amount' => 11498, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1836, 'total_amount' => 11997, 'provider_payment_id' => 'mock_test_order1003', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Shipped, 'tracking_company' => 'DHL', 'tracking_number' => 'DHL9876543210', 'shipped_at' => now()->subDays(3), 'delivered_at' => null, 'line_indexes' => [0]]]), + array_merge($base, ['order_number' => '#1004', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Cancelled, 'financial_status' => FinancialStatus::Refunded, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(15), 'lines' => [['handle' => 'classic-cotton-t-shirt', 'variant' => 'M / Navy', 'quantity' => 1]], 'subtotal_amount' => 2499, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 399, 'total_amount' => 2998, 'provider_payment_id' => 'mock_test_order1004', 'payment_status' => PaymentStatus::Refunded, 'refund' => ['amount' => 2998, 'reason' => 'Customer requested cancellation', 'provider_refund_id' => 'mock_re_test_order1004', 'line_indexes' => [0]]]), + array_merge($base, ['order_number' => '#1005', 'customer' => 'jane@example.com', 'payment_method' => PaymentMethod::BankTransfer, 'status' => OrderStatus::Pending, 'financial_status' => FinancialStatus::Pending, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subHours(2), 'lines' => [['handle' => 'leather-belt', 'variant' => 'S/M / Black', 'quantity' => 1]], 'subtotal_amount' => 3499, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 559, 'total_amount' => 3998, 'provider_payment_id' => 'mock_test_order1005', 'payment_status' => PaymentStatus::Pending]), + array_merge($base, ['order_number' => '#1006', 'customer' => 'michael@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'running-sneakers', 'variant' => 'EU 42 / Black', 'quantity' => 1]], 'subtotal_amount' => 11999, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1916, 'total_amount' => 12498, 'provider_payment_id' => 'mock_test_order1006', 'payment_status' => PaymentStatus::Captured]), + array_merge($base, ['order_number' => '#1007', 'customer' => 'sarah@example.com', 'payment_method' => PaymentMethod::Paypal, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(20), 'lines' => [['handle' => 'v-neck-linen-tee', 'variant' => 'M / Beige', 'quantity' => 2], ['handle' => 'wool-scarf', 'variant' => 'Grey', 'quantity' => 1]], 'subtotal_amount' => 9997, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1596, 'total_amount' => 10496, 'provider_payment_id' => 'mock_test_order1007', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'DHL', 'tracking_number' => 'DHL1112223334', 'shipped_at' => now()->subDays(18), 'delivered_at' => now()->subDays(12), 'line_indexes' => [0, 1]]]), + array_merge($base, ['order_number' => '#1008', 'customer' => 'david@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::PartiallyRefunded, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(12), 'lines' => [['handle' => 'cargo-pants', 'variant' => '32 / Khaki', 'quantity' => 1], ['handle' => 'graphic-print-tee', 'variant' => 'L', 'quantity' => 1]], 'subtotal_amount' => 8498, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1357, 'total_amount' => 8997, 'provider_payment_id' => 'mock_test_order1008', 'payment_status' => PaymentStatus::Captured, 'refund' => ['amount' => 2999, 'reason' => 'Item returned', 'provider_refund_id' => 'mock_re_test_order1008', 'line_indexes' => [1]], 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'UPS', 'tracking_number' => 'UPS5556667778', 'shipped_at' => now()->subDays(10), 'delivered_at' => now()->subDays(4), 'line_indexes' => [0, 1]]]), + array_merge($base, ['order_number' => '#1009', 'customer' => 'emma@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(3), 'lines' => [['handle' => 'canvas-tote-bag', 'variant' => 'Natural', 'quantity' => 1], ['handle' => 'bucket-hat', 'variant' => 'S/M / Black', 'quantity' => 1]], 'subtotal_amount' => 4498, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 718, 'total_amount' => 4997, 'provider_payment_id' => 'mock_test_order1009', 'payment_status' => PaymentStatus::Captured]), + array_merge($base, ['order_number' => '#1010', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::Paypal, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'cashmere-overcoat', 'variant' => 'M / Camel', 'quantity' => 1]], 'subtotal_amount' => 49999, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 7983, 'total_amount' => 50498, 'provider_payment_id' => 'mock_test_order1010', 'payment_status' => PaymentStatus::Captured]), + array_merge($base, ['order_number' => '#1011', 'customer' => 'james@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(25), 'lines' => [['handle' => 'striped-polo-shirt', 'variant' => 'XL', 'quantity' => 1]], 'subtotal_amount' => 2799, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 447, 'total_amount' => 3298, 'provider_payment_id' => 'mock_test_order1011', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => 'FedEx', 'tracking_number' => 'FX9998887776', 'shipped_at' => now()->subDays(23), 'delivered_at' => now()->subDays(20), 'line_indexes' => [0]]]), + array_merge($base, ['order_number' => '#1012', 'customer' => 'lisa@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(4), 'lines' => [['handle' => 'chino-shorts', 'variant' => '34 / Navy', 'quantity' => 2]], 'subtotal_amount' => 7998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1277, 'total_amount' => 8497, 'provider_payment_id' => 'mock_test_order1012', 'payment_status' => PaymentStatus::Captured]), + array_merge($base, ['order_number' => '#1013', 'customer' => 'robert@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'wide-leg-trousers', 'variant' => 'M', 'quantity' => 1], ['handle' => 'wool-scarf', 'variant' => 'Burgundy', 'quantity' => 1]], 'subtotal_amount' => 7998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1277, 'total_amount' => 8497, 'provider_payment_id' => 'mock_test_order1013', 'payment_status' => PaymentStatus::Captured]), + array_merge($base, ['order_number' => '#1014', 'customer' => 'anna@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(14), 'lines' => [['handle' => 'gift-card', 'variant' => '50 EUR', 'quantity' => 1]], 'subtotal_amount' => 5000, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 798, 'total_amount' => 5000, 'provider_payment_id' => 'mock_test_order1014', 'payment_status' => PaymentStatus::Captured, 'fulfillment' => ['status' => FulfillmentShipmentStatus::Delivered, 'tracking_company' => null, 'tracking_number' => null, 'shipped_at' => now()->subDays(14), 'delivered_at' => now()->subDays(14), 'line_indexes' => [0]]]), + array_merge($base, ['order_number' => '#1015', 'customer' => 'customer@acme.test', 'payment_method' => PaymentMethod::BankTransfer, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now(), 'lines' => [['handle' => 'classic-cotton-t-shirt', 'variant' => 'M / White', 'quantity' => 1, 'discount_amount' => 250, 'discount_id' => $discountId], ['handle' => 'graphic-print-tee', 'variant' => 'M', 'quantity' => 1, 'discount_amount' => 300, 'discount_id' => $discountId]], 'subtotal_amount' => 5498, 'discount_amount' => 550, 'shipping_amount' => 499, 'tax_amount' => 790, 'total_amount' => 5447, 'provider_payment_id' => 'mock_test_order1015', 'payment_status' => PaymentStatus::Captured]), + ]; + } + + private function electronicsOrders(): array + { + $storeId = Store::query()->where('handle', 'acme-electronics')->value('id'); + + return [ + ['store_id' => $storeId, 'order_number' => '#5001', 'customer' => 'techfan@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Fulfilled, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Fulfilled, 'placed_at' => now()->subDays(6), 'lines' => [['handle' => 'pro-laptop-15', 'variant' => '512GB', 'quantity' => 1], ['handle' => 'usb-c-cable-2m', 'variant' => 'Default', 'quantity' => 1]], 'subtotal_amount' => 121298, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 0, 'total_amount' => 121298, 'provider_payment_id' => 'mock_test_order5001', 'payment_status' => PaymentStatus::Captured], + ['store_id' => $storeId, 'order_number' => '#5002', 'customer' => 'gadgetlover@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDays(2), 'lines' => [['handle' => 'wireless-headphones', 'variant' => 'Black', 'quantity' => 1]], 'subtotal_amount' => 14999, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 0, 'total_amount' => 14999, 'provider_payment_id' => 'mock_test_order5002', 'payment_status' => PaymentStatus::Captured], + ['store_id' => $storeId, 'order_number' => '#5003', 'customer' => 'techfan@example.com', 'payment_method' => PaymentMethod::BankTransfer, 'status' => OrderStatus::Pending, 'financial_status' => FinancialStatus::Pending, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subHours(4), 'lines' => [['handle' => 'monitor-stand', 'variant' => 'Default', 'quantity' => 1]], 'subtotal_amount' => 4999, 'discount_amount' => 0, 'shipping_amount' => 0, 'tax_amount' => 0, 'total_amount' => 4999, 'provider_payment_id' => 'mock_test_order5003', 'payment_status' => PaymentStatus::Pending], + ]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php index 0c0493cd..10ae161e 100644 --- a/database/seeders/OrganizationSeeder.php +++ b/database/seeders/OrganizationSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Organization; use Illuminate\Database\Seeder; class OrganizationSeeder extends Seeder @@ -11,6 +12,9 @@ class OrganizationSeeder extends Seeder */ public function run(): void { - // + Organization::query()->updateOrCreate( + ['slug' => 'acme-corp'], + ['name' => 'Acme Corp', 'billing_email' => 'billing@acme.test', 'status' => 'active'], + ); } } diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..28a2418d --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,16 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + $collections = Collection::withoutGlobalScopes()->whereIn('store_id', $stores->pluck('id'))->get()->keyBy(fn (Collection $collection): string => $collection->store_id.':'.$collection->handle); + + foreach ($this->fashionProducts() as $productData) { + $product = $this->seedProduct($stores['acme-fashion'], $productData); + $this->assignCollections($product, $productData['collections'], $collections); + } + + foreach ($this->electronicsProducts() as $productData) { + $product = $this->seedProduct($stores['acme-electronics'], $productData); + $this->assignCollections($product, $productData['collections'], $collections); + } + } + + private function seedProduct(Store $store, array $productData): Product + { + $product = Product::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => $productData['handle']], + ['title' => $productData['title'], 'description' => strip_tags($productData['description_html']), 'description_html' => $productData['description_html'], 'vendor' => $productData['vendor'], 'product_type' => $productData['product_type'], 'tags' => $productData['tags'], 'status' => $productData['status'], 'published_at' => $productData['published_at'], 'sales_count' => 0, 'metadata' => []], + ); + + $optionValues = []; + foreach ($productData['options'] as $position => $optionData) { + $option = $product->options()->updateOrCreate(['position' => $position], ['name' => $optionData['name']]); + $optionValues[$optionData['name']] = []; + + foreach ($optionData['values'] as $valuePosition => $value) { + $optionValue = $option->values()->updateOrCreate(['position' => $valuePosition], ['value' => $value]); + $optionValues[$optionData['name']][$value] = $optionValue->getKey(); + } + } + + foreach ($this->combinations($productData['options']) as $position => $combination) { + $variantTitle = $combination === [] ? 'Default' : implode(' / ', array_values($combination)); + $price = is_array($productData['price']) ? $productData['price'][$position] : $productData['price']; + $sku = $productData['sku_prefix'].'-'.str_pad((string) ($position + 1), 3, '0', STR_PAD_LEFT); + $variant = ProductVariant::query()->updateOrCreate( + ['product_id' => $product->getKey(), 'position' => $position], + ['title' => $variantTitle, 'sku' => $sku, 'barcode' => null, 'price_amount' => $price, 'compare_at_amount' => $productData['compare_at'], 'currency' => 'EUR', 'cost_amount' => null, 'weight_grams' => $productData['weight_g'], 'weight_g' => $productData['weight_g'], 'requires_shipping' => $productData['requires_shipping'], 'is_default' => $position === 0, 'status' => $productData['variant_status'], 'metadata' => []], + ); + + $variantOptionIds = []; + foreach ($combination as $optionName => $value) { + $variantOptionIds[] = $optionValues[$optionName][$value]; + } + $variant->optionValues()->sync($variantOptionIds); + + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + ['store_id' => $store->getKey(), 'quantity_on_hand' => $productData['inventory'], 'quantity_reserved' => 0, 'policy' => $productData['policy']], + ); + } + + return $product->refresh(); + } + + private function assignCollections(Product $product, array $collectionHandles, $collections): void + { + $assignments = []; + + foreach ($collectionHandles as $position => $handle) { + $collection = $collections->get($product->store_id.':'.$handle); + if ($collection !== null) { + $assignments[$collection->getKey()] = ['position' => $position]; + } + } + + foreach ($collections->filter(fn (Collection $collection): bool => $collection->store_id === $product->store_id) as $collection) { + if (array_key_exists($collection->getKey(), $assignments)) { + $collection->products()->syncWithoutDetaching([$product->getKey() => $assignments[$collection->getKey()]]); + } else { + $collection->products()->detach($product->getKey()); + } + } + } + + private function combinations(array $options): array + { + if ($options === []) { + return [[]]; + } + + $combinations = [[]]; + foreach ($options as $option) { + $next = []; + foreach ($combinations as $combination) { + foreach ($option['values'] as $value) { + $next[] = array_merge($combination, [$option['name'] => $value]); + } + } + $combinations = $next; + } + + return $combinations; + } + + private function fashionProducts(): array + { + return [ + $this->product('Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 'ACME-CTSH', 'Acme Basics', 'T-Shirts', ['new', 'popular'], 2499, null, 200, 15, ['Size' => ['S', 'M', 'L', 'XL'], 'Color' => ['White', 'Black', 'Navy']], ['new-arrivals', 't-shirts']), + $this->product('Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 'ACME-JEANS', 'Acme Denim', 'Pants', ['new', 'sale'], 7999, 9999, 800, 8, ['Size' => ['28', '30', '32', '34', '36'], 'Color' => ['Blue', 'Black']], ['new-arrivals', 'pants-jeans', 'sale']), + $this->product('Organic Hoodie', 'organic-hoodie', 'ACME-HOOD', 'Acme Basics', 'Hoodies', ['new', 'trending'], 5999, null, 500, 20, ['Size' => ['S', 'M', 'L', 'XL']], ['new-arrivals']), + $this->product('Leather Belt', 'leather-belt', 'ACME-BELT', 'Acme Accessories', 'Accessories', ['popular'], 3499, null, 150, 25, ['Size' => ['S/M', 'L/XL'], 'Color' => ['Brown', 'Black']], []), + $this->product('Running Sneakers', 'running-sneakers', 'ACME-RUN', 'Acme Sport', 'Shoes', ['trending'], 11999, null, 600, 5, ['Size' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44'], 'Color' => ['White', 'Black']], ['new-arrivals']), + $this->product('Graphic Print Tee', 'graphic-print-tee', 'ACME-GTEE', 'Acme Basics', 'T-Shirts', ['new'], 2999, null, 210, 18, ['Size' => ['S', 'M', 'L', 'XL']], ['t-shirts']), + $this->product('V-Neck Linen Tee', 'v-neck-linen-tee', 'ACME-LTEE', 'Acme Basics', 'T-Shirts', ['popular'], 3499, null, 180, 12, ['Size' => ['S', 'M', 'L'], 'Color' => ['Beige', 'Olive', 'Sky Blue']], ['t-shirts']), + $this->product('Striped Polo Shirt', 'striped-polo-shirt', 'ACME-POLO', 'Acme Basics', 'T-Shirts', ['sale'], 2799, 3999, 250, 10, ['Size' => ['S', 'M', 'L', 'XL']], ['t-shirts', 'sale']), + $this->product('Cargo Pants', 'cargo-pants', 'ACME-CARGO', 'Acme Workwear', 'Pants', ['popular'], 5499, null, 700, 14, ['Size' => ['30', '32', '34', '36'], 'Color' => ['Khaki', 'Olive', 'Black']], ['pants-jeans']), + $this->product('Chino Shorts', 'chino-shorts', 'ACME-SHORT', 'Acme Basics', 'Pants', ['new', 'trending'], 3999, null, 350, 16, ['Size' => ['30', '32', '34', '36'], 'Color' => ['Navy', 'Sand']], ['pants-jeans', 'new-arrivals']), + $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'ACME-TROUSER', 'Acme Denim', 'Pants', ['sale'], 4999, 6999, 550, 7, ['Size' => ['S', 'M', 'L']], ['pants-jeans', 'sale']), + $this->product('Wool Scarf', 'wool-scarf', 'ACME-SCARF', 'Acme Accessories', 'Accessories', ['popular'], 2999, null, 120, 30, ['Color' => ['Grey', 'Burgundy', 'Navy']], []), + $this->product('Canvas Tote Bag', 'canvas-tote-bag', 'ACME-TOTE', 'Acme Accessories', 'Accessories', ['trending'], 1999, null, 300, 40, ['Color' => ['Natural', 'Black']], []), + $this->product('Bucket Hat', 'bucket-hat', 'ACME-HAT', 'Acme Accessories', 'Accessories', ['new', 'trending'], 2499, null, 80, 22, ['Size' => ['S/M', 'L/XL'], 'Color' => ['Beige', 'Black', 'Olive']], ['new-arrivals']), + $this->product('Unreleased Winter Jacket', 'unreleased-winter-jacket', 'ACME-WJACKET', 'Acme Outerwear', 'Jackets', ['limited'], 14999, null, 900, 0, ['Size' => ['S', 'M', 'L', 'XL']], [], ProductStatus::Draft, now()->subMonths(6)), + $this->product('Discontinued Raincoat', 'discontinued-raincoat', 'ACME-RAIN', 'Acme Outerwear', 'Jackets', [], 8999, null, 400, 3, ['Size' => ['M', 'L']], [], ProductStatus::Archived, now()->subMonths(6)), + $this->product('Limited Edition Sneakers', 'limited-edition-sneakers', 'ACME-LIMITED', 'Acme Sport', 'Shoes', ['limited'], 15999, null, 650, 0, ['Size' => ['EU 40', 'EU 42', 'EU 44']], []), + $this->product('Backorder Denim Jacket', 'backorder-denim-jacket', 'ACME-BACKORDER', 'Acme Denim', 'Jackets', ['popular'], 9999, null, 750, 0, ['Size' => ['S', 'M', 'L', 'XL']], [], ProductStatus::Active, null, InventoryPolicy::Continue), + $this->product('Gift Card', 'gift-card', 'ACME-GIFT', 'Acme Fashion', 'Gift Cards', ['popular'], [2500, 5000, 10000], null, 0, 9999, ['Amount' => ['25 EUR', '50 EUR', '100 EUR']], [], ProductStatus::Active, null, InventoryPolicy::Deny, false), + $this->product('Cashmere Overcoat', 'cashmere-overcoat', 'ACME-CASHMERE', 'Acme Premium', 'Jackets', ['limited', 'new'], 49999, null, 1200, 3, ['Size' => ['S', 'M', 'L'], 'Color' => ['Camel', 'Charcoal']], ['new-arrivals']), + ]; + } + + private function electronicsProducts(): array + { + return [ + $this->product('Pro Laptop 15', 'pro-laptop-15', 'TECH-LAPTOP', 'TechCorp', 'Laptops', ['featured'], [99999, 119999, 149999], null, 1800, 10, ['Storage' => ['256GB', '512GB', '1TB']], ['featured']), + $this->product('Wireless Headphones', 'wireless-headphones', 'TECH-HEADPHONES', 'AudioMax', 'Audio', ['popular'], 14999, null, 250, 25, ['Color' => ['Black', 'Silver']], ['featured']), + $this->product('USB-C Cable 2m', 'usb-c-cable-2m', 'TECH-CABLE', 'CablePro', 'Cables', ['popular'], 1299, null, 50, 200, [], ['accessories']), + $this->product('Mechanical Keyboard', 'mechanical-keyboard', 'TECH-KEYBOARD', 'KeyTech', 'Peripherals', ['featured'], 12999, null, 1100, 15, ['Switch Type' => ['Red', 'Blue', 'Brown']], ['featured']), + $this->product('Monitor Stand', 'monitor-stand', 'TECH-STAND', 'DeskGear', 'Accessories', ['popular'], 4999, null, 2500, 30, [], ['accessories']), + ]; + } + + private function product(string $title, string $handle, string $skuPrefix, string $vendor, string $productType, array $tags, int|array $price, ?int $compareAt, int $weight, int $inventory, array $options, array $collections, ProductStatus $status = ProductStatus::Active, $publishedAt = null, InventoryPolicy $policy = InventoryPolicy::Deny, bool $requiresShipping = true): array + { + return ['title' => $title, 'handle' => $handle, 'sku_prefix' => $skuPrefix, 'vendor' => $vendor, 'product_type' => $productType, 'tags' => $tags, 'description_html' => '

      '.str_replace('.', '. ', $title).' is made for comfortable everyday use with thoughtful details.

      ', 'status' => $status, 'published_at' => $publishedAt ?? now(), 'price' => $price, 'compare_at' => $compareAt, 'weight_g' => $weight, 'inventory' => $inventory, 'policy' => $policy, 'requires_shipping' => $requiresShipping, 'variant_status' => $status === ProductStatus::Archived ? VariantStatus::Archived : VariantStatus::Active, 'options' => collect($options)->map(fn (array $values, string $name): array => ['name' => $name, 'values' => $values])->values()->all(), 'collections' => $collections]; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..18d2a0d9 --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,16 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + + $fashionZones = [ + ['name' => 'Domestic', 'countries_json' => ['DE'], 'rates' => [['name' => 'Standard Shipping', 'amount' => 499], ['name' => 'Express Shipping', 'amount' => 999]]], + ['name' => 'EU', 'countries_json' => ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], 'rates' => [['name' => 'EU Standard', 'amount' => 899]]], + ['name' => 'Rest of World', 'countries_json' => ['US', 'GB', 'CA', 'AU'], 'rates' => [['name' => 'International', 'amount' => 1499]]], + ]; + + foreach ($fashionZones as $zoneData) { + $zone = ShippingZone::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $stores['acme-fashion']->getKey(), 'name' => $zoneData['name']], + ['countries_json' => $zoneData['countries_json'], 'regions_json' => []], + ); + + $this->seedRates($zone, $zoneData['rates']); + } + + $electronicsZone = ShippingZone::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $stores['acme-electronics']->getKey(), 'name' => 'Germany'], + ['countries_json' => ['DE'], 'regions_json' => []], + ); + + $this->seedRates($electronicsZone, [['name' => 'Standard', 'amount' => 0]]); + } + + private function seedRates(ShippingZone $zone, array $rates): void + { + foreach ($rates as $rate) { + ShippingRate::query()->updateOrCreate( + ['shipping_zone_id' => $zone->getKey(), 'name' => $rate['name']], + ['zone_id' => $zone->getKey(), 'type' => 'flat', 'price_amount' => $rate['amount'], 'currency' => 'EUR', 'config_json' => ['amount' => $rate['amount']], 'is_active' => true, 'estimated_days_min' => $rate['amount'] === 0 ? 0 : ($rate['amount'] >= 999 ? 1 : 3), 'estimated_days_max' => $rate['amount'] === 0 ? 0 : ($rate['amount'] >= 999 ? 2 : 5)], + ); + } + } +} diff --git a/database/seeders/ShopSeeder.php b/database/seeders/ShopSeeder.php index f0739783..c3007269 100644 --- a/database/seeders/ShopSeeder.php +++ b/database/seeders/ShopSeeder.php @@ -2,119 +2,69 @@ namespace Database\Seeders; -use App\Enums\DiscountValueType; use App\Enums\InventoryPolicy; -use App\Enums\PaymentMethod; -use App\Enums\PaymentStatus; use App\Enums\ProductStatus; -use App\Enums\StoreDomainType; -use App\Enums\StoreUserRole; -use App\Models\Collection; -use App\Models\Discount; +use App\Enums\VariantStatus; use App\Models\InventoryItem; -use App\Models\NavigationItem; -use App\Models\NavigationMenu; -use App\Models\Order; -use App\Models\Organization; -use App\Models\Page; use App\Models\Product; -use App\Models\ShippingRate; -use App\Models\ShippingZone; use App\Models\Store; use App\Models\StoreDomain; -use App\Models\StoreSettings; -use App\Models\TaxSettings; -use App\Models\Theme; -use App\Models\User; use Illuminate\Database\Seeder; class ShopSeeder extends Seeder { public function run(): void { - $organization = Organization::firstOrCreate(['slug' => 'acme-commerce'], ['name' => 'Acme Commerce', 'billing_email' => 'billing@acme.test', 'status' => 'active']); - $store = Store::firstOrCreate(['handle' => 'acme-fashion'], ['organization_id' => $organization->getKey(), 'name' => 'Acme Fashion', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'status' => 'active']); - StoreDomain::firstOrCreate(['hostname' => 'acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => true, 'tls_mode' => 'managed']); - StoreDomain::firstOrCreate(['hostname' => 'shop.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => false, 'tls_mode' => 'managed']); - StoreDomain::firstOrCreate(['hostname' => 'admin.acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Admin, 'is_primary' => true, 'tls_mode' => 'managed']); - StoreSettings::updateOrCreate(['store_id' => $store->getKey()], ['settings_json' => ['announcement' => 'Free shipping on orders over €50', 'hero_heading' => 'Everyday pieces, thoughtfully made.'], 'general_json' => ['store_name' => 'Acme Fashion']]); - $domestic = ShippingZone::updateOrCreate(['store_id' => $store->getKey(), 'name' => 'Domestic'], ['countries_json' => ['DE'], 'regions_json' => []]); - ShippingRate::updateOrCreate(['shipping_zone_id' => $domestic->getKey(), 'name' => 'Standard Shipping'], ['type' => 'flat', 'price_amount' => 499, 'currency' => 'EUR', 'is_active' => true, 'estimated_days_min' => 3, 'estimated_days_max' => 5]); - TaxSettings::updateOrCreate(['store_id' => $store->getKey()], ['mode' => 'exclusive', 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900]]); - Theme::firstOrCreate(['store_id' => $store->getKey(), 'name' => 'Acme Default'], ['status' => 'published', 'settings' => ['hero_heading' => 'Everyday pieces, thoughtfully made.']]); - Page::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'about'], ['title' => 'About', 'content' => '

      Acme Fashion makes thoughtful everyday pieces for modern wardrobes.

      ', 'status' => 'published', 'published_at' => now()]); - $mainMenu = NavigationMenu::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'main'], ['name' => 'Main menu']); - $mainMenu->items()->delete(); - NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'Collections', 'type' => 'link', 'url' => '/collections', 'position' => 1]); - NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'About', 'type' => 'link', 'url' => '/pages/about', 'position' => 2]); + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, + ]); - $admin = User::firstOrCreate(['email' => 'admin@acme.test'], ['name' => 'Acme Admin', 'password' => 'password', 'email_verified_at' => now(), 'status' => 'active']); - $store->users()->syncWithoutDetaching([$admin->getKey() => ['role' => StoreUserRole::Owner->value]]); - $customer = \App\Models\Customer::firstOrCreate(['store_id' => $store->getKey(), 'email' => 'customer@acme.test'], ['first_name' => 'Jamie', 'last_name' => 'Customer', 'password_hash' => 'password', 'email_verified_at' => now(), 'status' => 'active']); - - $tShirts = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 't-shirts'], ['title' => 'T-Shirts', 'description' => 'Soft, everyday essentials.', 'status' => 'active']); - $newArrivals = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 'new-arrivals'], ['title' => 'New Arrivals', 'description' => 'Fresh pieces for the season.', 'status' => 'active']); - $classic = $this->product($store, 'Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 2499, 80, InventoryPolicy::Deny, ['S', 'M', 'L', 'XL'], ['Black', 'White', 'Navy']); - $jeans = $this->product($store, 'Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 7999, 35, InventoryPolicy::Deny, ['28', '30', '32', '34'], ['Indigo']); - $draft = $this->product($store, 'Coming Soon Jacket', 'coming-soon-jacket', 12999, 0, InventoryPolicy::Deny, ['M'], ['Black'], ProductStatus::Draft); - $soldOut = $this->product($store, 'Sold Out Limited Tee', 'sold-out-limited-tee', 3999, 0, InventoryPolicy::Deny, ['M'], ['White']); - $backorder = $this->product($store, 'Relaxed Backorder Hoodie', 'relaxed-backorder-hoodie', 6999, 0, InventoryPolicy::Continue, ['M', 'L'], ['Navy']); - $tShirts->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $soldOut->getKey() => ['position' => 2]]); - $newArrivals->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $jeans->getKey() => ['position' => 2], $backorder->getKey() => ['position' => 3]]); - - foreach ([ - ['code' => 'WELCOME10', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10], - ['code' => 'FLAT5', 'value_type' => DiscountValueType::Fixed, 'value_amount' => 500], - ['code' => 'FREESHIP', 'value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0], - ['code' => 'EXPIRED20', 'value_type' => DiscountValueType::Percent, 'value_amount' => 20, 'ends_at' => now()->subDay()], - ['code' => 'MAXED', 'value_type' => DiscountValueType::Percent, 'value_amount' => 15, 'usage_limit' => 1, 'usage_count' => 1], - ] as $discount) { - Discount::updateOrCreate(['store_id' => $store->getKey(), 'code' => $discount['code']], array_merge(['type' => 'code', 'status' => 'active', 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), 'usage_limit' => null, 'usage_count' => 0, 'rules_json' => []], $discount)); - } - - $order = Order::withoutGlobalScopes()->firstOrCreate(['store_id' => $store->getKey(), 'order_number' => '#1001'], ['customer_id' => $customer->getKey(), 'currency' => 'EUR', 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', 'payment_method' => PaymentMethod::CreditCard, 'email' => $customer->email, 'subtotal_amount' => 2499, 'shipping_amount' => 499, 'tax_amount' => 0, 'total_amount' => 2998, 'placed_at' => now()->subDay()]); - if ($order->lines()->count() === 0) { - $variant = $classic->variants()->first(); - $order->lines()->create(['product_id' => $classic->getKey(), 'variant_id' => $variant->getKey(), 'product_title' => $classic->title, 'variant_title' => $variant->title, 'sku' => $variant->sku, 'quantity' => 1, 'unit_price_amount' => $variant->price_amount, 'line_subtotal_amount' => $variant->price_amount, 'line_total_amount' => $variant->price_amount]); - $order->payments()->create(['provider' => 'mock', 'provider_payment_id' => 'mock_seed_1001', 'method' => PaymentMethod::CreditCard, 'status' => PaymentStatus::Captured, 'amount' => $order->total_amount]); - } + $this->seedLegacyCommerceFixtures(); } - private function product(Store $store, string $title, string $handle, int $price, int $quantity, InventoryPolicy $policy, array $sizes, array $colors, ProductStatus $status = ProductStatus::Active): Product + private function seedLegacyCommerceFixtures(): void { - $product = Product::withoutGlobalScopes()->firstOrCreate(['store_id' => $store->getKey(), 'handle' => $handle], ['title' => $title, 'description' => 'Designed for comfortable everyday wear.', 'vendor' => 'Acme', 'product_type' => 'Apparel', 'tags' => ['featured'], 'status' => $status, 'published_at' => $status === ProductStatus::Active ? now() : null]); - - if ($product->variants()->count() === 0) { - foreach ($sizes as $sizeIndex => $size) { - foreach ($colors as $colorIndex => $color) { - $variant = $product->variants()->create(['title' => $color.' / '.$size, 'sku' => strtoupper('ACME-'.substr($handle, 0, 5).'-'.$size.'-'.$colorIndex), 'price_amount' => $price, 'compare_at_amount' => $price + 500, 'weight_grams' => 250, 'requires_shipping' => true, 'is_default' => $sizeIndex === 0 && $colorIndex === 0, 'position' => ($sizeIndex * count($colors)) + $colorIndex]); - InventoryItem::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'variant_id' => $variant->getKey(), 'quantity_on_hand' => $quantity, 'quantity_reserved' => 0, 'policy' => $policy]); - } - } - } - - if ($product->options()->count() === 0) { - $sizeOption = $product->options()->create(['name' => 'Size', 'position' => 1]); - $colorOption = $product->options()->create(['name' => 'Color', 'position' => 2]); + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $classic = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + $classicVariant = $classic->variants()->orderBy('position')->firstOrFail(); - foreach ($sizes as $position => $size) { - $sizeOption->values()->create(['value' => $size, 'position' => $position + 1]); - } + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $classicVariant->getKey()], + ['store_id' => $store->getKey(), 'quantity_on_hand' => 80, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny], + ); - foreach ($colors as $position => $color) { - $colorOption->values()->create(['value' => $color, 'position' => $position + 1]); - } - } + StoreDomain::query()->updateOrCreate( + ['hostname' => 'shop.test'], + ['store_id' => $store->getKey(), 'type' => 'storefront', 'is_primary' => false, 'tls_mode' => 'managed'], + ); - $optionValues = $product->options()->with('values')->get()->flatMap(fn ($option) => $option->values)->keyBy('value'); - foreach ($product->variants as $variant) { - [$color, $size] = array_pad(array_map('trim', explode('/', $variant->title, 2)), 2, null); - $variant->optionValues()->syncWithoutDetaching(array_values(array_filter([ - $optionValues->get($color)?->getKey(), - $optionValues->get($size)?->getKey(), - ]))); - } + $legacyProduct = Product::withoutGlobalScopes()->where('store_id', $store->getKey())->where('handle', 'limited-edition-sneakers')->firstOrFail(); + $legacyProduct->update(['handle' => 'sold-out-limited-tee', 'title' => 'Sold Out Limited Tee', 'description' => '

      A sold-out limited edition tee.

      ', 'description_html' => '

      A sold-out limited edition tee.

      ', 'vendor' => 'Acme Sport', 'product_type' => 'T-Shirts', 'tags' => ['limited'], 'status' => ProductStatus::Active, 'published_at' => now(), 'sales_count' => 0, 'metadata' => ['compatibility_alias_for' => 'limited-edition-sneakers']]); + $variant = $legacyProduct->variants()->updateOrCreate( + ['position' => 0], + ['title' => 'Default', 'sku' => 'ACME-LEGACY-SOLD-OUT', 'price_amount' => 3999, 'compare_at_amount' => null, 'currency' => 'EUR', 'weight_grams' => 250, 'weight_g' => 250, 'requires_shipping' => true, 'is_default' => true, 'status' => VariantStatus::Active, 'metadata' => []], + ); - return $product->load('variants.inventory'); + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->getKey()], + ['store_id' => $store->getKey(), 'quantity_on_hand' => 0, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny], + ); } } diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php index 62def160..cb6677aa 100644 --- a/database/seeders/StoreDomainSeeder.php +++ b/database/seeders/StoreDomainSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Store; use Illuminate\Database\Seeder; class StoreDomainSeeder extends Seeder @@ -11,6 +12,19 @@ class StoreDomainSeeder extends Seeder */ public function run(): void { - // + $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + + foreach ([ + ['store' => 'acme-fashion', 'hostname' => 'acme-fashion.test', 'type' => 'storefront', 'is_primary' => true], + ['store' => 'acme-fashion', 'hostname' => 'admin.acme-fashion.test', 'type' => 'admin', 'is_primary' => false], + ['store' => 'acme-electronics', 'hostname' => 'acme-electronics.test', 'type' => 'storefront', 'is_primary' => true], + ] as $domain) { + $store = $stores->get($domain['store']); + + $store?->domains()->updateOrCreate( + ['hostname' => $domain['hostname']], + ['type' => $domain['type'], 'is_primary' => $domain['is_primary'], 'tls_mode' => 'managed'], + ); + } } } diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php index 713693e2..9fbf4116 100644 --- a/database/seeders/StoreSeeder.php +++ b/database/seeders/StoreSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Models\Organization; +use App\Models\Store; use Illuminate\Database\Seeder; class StoreSeeder extends Seeder @@ -11,6 +13,16 @@ class StoreSeeder extends Seeder */ public function run(): void { - // + $organization = Organization::query()->where('slug', 'acme-corp')->firstOrFail(); + + foreach ([ + ['name' => 'Acme Fashion', 'handle' => 'acme-fashion'], + ['name' => 'Acme Electronics', 'handle' => 'acme-electronics'], + ] as $store) { + Store::query()->updateOrCreate( + ['handle' => $store['handle']], + ['organization_id' => $organization->getKey(), 'name' => $store['name'], 'status' => 'active', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'metadata' => []], + ); + } } } diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php index 3ad0f640..0b14bd3a 100644 --- a/database/seeders/StoreSettingsSeeder.php +++ b/database/seeders/StoreSettingsSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Models\Store; +use App\Models\StoreSettings; use Illuminate\Database\Seeder; class StoreSettingsSeeder extends Seeder @@ -11,6 +13,17 @@ class StoreSettingsSeeder extends Seeder */ public function run(): void { - // + $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get(); + + foreach ($stores as $store) { + $name = $store->handle === 'acme-fashion' ? 'Acme Fashion' : 'Acme Electronics'; + $email = $store->handle === 'acme-fashion' ? 'hello@acme-fashion.test' : 'hello@acme-electronics.test'; + $start = $store->handle === 'acme-fashion' ? 1001 : 5001; + + StoreSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []], + ); + } } } diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..23727b9b --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,34 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle'); + $users = User::query()->whereIn('email', ['admin@acme.test', 'staff@acme.test', 'support@acme.test', 'manager@acme.test', 'admin2@acme.test'])->get()->keyBy('email'); + + foreach ([ + ['email' => 'admin@acme.test', 'store' => 'acme-fashion', 'role' => 'owner'], + ['email' => 'staff@acme.test', 'store' => 'acme-fashion', 'role' => 'staff'], + ['email' => 'support@acme.test', 'store' => 'acme-fashion', 'role' => 'support'], + ['email' => 'manager@acme.test', 'store' => 'acme-fashion', 'role' => 'admin'], + ['email' => 'admin2@acme.test', 'store' => 'acme-electronics', 'role' => 'owner'], + ] as $membership) { + $store = $stores->get($membership['store']); + $user = $users->get($membership['email']); + + if ($store !== null && $user !== null) { + $store->users()->syncWithoutDetaching([$user->getKey() => ['role' => $membership['role']]]); + } + } + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..7f9f0616 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,23 @@ +whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) { + TaxSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + ['mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, 'config_json' => ['default_rate_bps' => 1900], 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900], 'provider_config_json' => []], + ); + } + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..cad3b16f --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,28 @@ +get() as $store) { + $theme = Theme::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'name' => 'Baseline'], + ['version' => '1.0.0', 'status' => 'published', 'published_at' => now()], + ); + + $theme->settings()->updateOrCreate( + ['theme_id' => $theme->getKey()], + ['settings_json' => ['brand' => ['primary' => '#18181b'], 'layout' => ['container' => 'wide']]], + ); + } + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..04f6643b --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,31 @@ + 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()], + ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)], + ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()], + ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()], + ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()], + ] as $user) { + $password = Hash::make('password'); + + User::query()->updateOrCreate( + ['email' => $user['email']], + ['name' => $user['name'], 'password' => $password, 'password_hash' => $password, 'status' => 'active', 'email_verified_at' => now(), 'last_login_at' => $user['last_login_at']], + ); + } + } +} diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..8048cf06 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,2 @@ + +Page not found · Shop@vite(['resources/css/app.css', 'resources/js/app.js'])

      404

      We couldn’t find that page.

      The link may be outdated or the page may have moved.

      Return to shop
      diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..dfb8e5d6 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,2 @@ + +Store unavailable · Shop@vite(['resources/css/app.css', 'resources/js/app.js'])

      Temporarily unavailable

      We’ll be back soon.

      This storefront is temporarily unavailable. Please try again in a few minutes.

      Try again
      diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 76bfe061..1991032d 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -1,5 +1,18 @@ - - {{ $title ?? 'Admin · '.($currentStore?->name ?? 'Shop') }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles - @livewireScripts + + {{ $title ?? 'Admin · '.($currentStore?->name ?? 'Shop') }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles + +
      +
      +
      + +
      Store Admin
      {{ $slot }}
      +
      + @fluxScripts + @livewireScripts + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index d367f0d6..4635f3a4 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -1 +1 @@ -{{ $title ?? 'Sign in' }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles
      {{ $slot }}
      @livewireScripts +{{ $title ?? 'Sign in' }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles
      {{ $slot }}
      @fluxScripts @livewireScripts diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php index bf3f2749..ae89fad4 100644 --- a/resources/views/layouts/storefront.blade.php +++ b/resources/views/layouts/storefront.blade.php @@ -1,3 +1,4 @@ +@php($currentStore = $currentStore ?? null) @@ -8,9 +9,9 @@ @livewireStyles - Skip to content -
      - Free shipping on orders over €50 + Skip to main content +
      + {{ data_get($currentStore?->settings?->settings_json, 'announcement', 'Free shipping on orders over €50') }}
      Account - Cart +
      @@ -37,6 +38,11 @@
      © {{ now()->year }} {{ $currentStore?->name ?? 'Shop' }}. All rights reserved.
      + @if ($currentStore) + + @endif + + @fluxScripts @livewireScripts diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php index 4b68a488..1b4ab35a 100644 --- a/resources/views/livewire/admin/analytics/index.blade.php +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -1,3 +1 @@ -
      - {{-- The only way to do great work is to love what you do. - Steve Jobs --}} -
      +

      Insights

      Analytics

      Monitor traffic, conversion, orders, and revenue.

      @foreach([['Visitors', $summary['visits']], ['Orders', $summary['orders']], ['Revenue', '€'.number_format($summary['revenue'] / 100, 2)], ['Conversion', $summary['visits'] > 0 ? number_format($summary['orders'] / $summary['visits'] * 100, 2).'%' : '0%']] as [$label, $value])

      {{ $label }}

      {{ $value }}

      @endforeach

      Conversion funnel

      Visits
      {{ number_format($summary['visits']) }}
      Add to cart
      {{ number_format($summary['add_to_cart']) }}
      Checkout started
      {{ number_format($summary['checkout_started']) }}
      Checkout completed
      {{ number_format($summary['checkout_completed']) }}

      Daily performance

      @forelse($days->reverse() as $day)@empty@endforelse
      DateVisitsOrdersRevenue
      {{ $day->date->toDateString() }}{{ $day->visits_count }}{{ $day->orders_count }}€{{ number_format($day->revenue_amount / 100, 2) }}
      No analytics data for this period.
      diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php index 3cfb0793..2f1808b5 100644 --- a/resources/views/livewire/admin/apps/index.blade.php +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -1,3 +1 @@ -
      - {{-- It is never too late to be what you might have been. - George Eliot --}} -
      +

      Platform

      Apps

      Review installed integrations for this store.

      @forelse($installations as $installation)

      {{ $installation->app?->name ?? 'App' }}

      {{ $installation->app?->description }}

      {{ ucfirst($installation->status) }}
      Uninstall
      @empty
      No apps installed.
      @endforelse
      @if($availableApps->isNotEmpty())

      Available apps

      @foreach($availableApps as $app)

      {{ $app->name }}

      {{ $app->description }}

      Installation is managed by the app OAuth flow.

      @endforeach
      @endif
      diff --git a/resources/views/livewire/admin/auth/login.blade.php b/resources/views/livewire/admin/auth/login.blade.php index 5d6be419..4cd754ea 100644 --- a/resources/views/livewire/admin/auth/login.blade.php +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -1 +1,12 @@ -

      Acme Fashion

      Sign in

      Admin access

      @error('email')

      {{ $message }}

      @enderror
      @error('password')

      {{ $message }}

      @enderror
      Forgot password?
      +
      +

      Acme Fashion

      +

      Sign in

      +

      Admin access

      +
      +
      @error('email')

      {{ $message }}

      @enderror
      +
      @error('password')

      {{ $message }}

      @enderror
      + + +
      + Forgot password? +
      diff --git a/resources/views/livewire/admin/collections/create.blade.php b/resources/views/livewire/admin/collections/create.blade.php index 5ef6ee27..ccbefb9d 100644 --- a/resources/views/livewire/admin/collections/create.blade.php +++ b/resources/views/livewire/admin/collections/create.blade.php @@ -1,3 +1 @@ -
      - {{-- Walk as if you are kissing the Earth with your feet. - Thich Nhat Hanh --}} -
      + diff --git a/resources/views/livewire/admin/collections/edit.blade.php b/resources/views/livewire/admin/collections/edit.blade.php index 401ee286..f5ab2f8d 100644 --- a/resources/views/livewire/admin/collections/edit.blade.php +++ b/resources/views/livewire/admin/collections/edit.blade.php @@ -1,3 +1 @@ -
      - {{-- I have not failed. I've just found 10,000 ways that won't work. - Thomas Edison --}} -
      + 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..a79028ae --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,10 @@ +
      +

      Catalog

      {{ $collection ? 'Edit collection' : 'Create collection' }}

      +
      + + + + +
      CancelSave collection
      + +
      diff --git a/resources/views/livewire/admin/collections/index.blade.php b/resources/views/livewire/admin/collections/index.blade.php index b1e7a31c..83fd602f 100644 --- a/resources/views/livewire/admin/collections/index.blade.php +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -1,3 +1,20 @@ -
      - {{-- It is not the man who has too little, but the man who craves more, that is poor. - Seneca --}} +
      +
      +

      Catalog

      Collections

      Group products into curated storefront collections.

      + Create collection +
      +
      + + +
      +
      + + @forelse($collections as $collection) + + @empty + + @endforelse +
      CollectionStatusProductsActions
      {{ $collection->title }}
      /collections/{{ $collection->handle }}
      {{ ucfirst($collection->status->value) }}{{ $collection->products_count }}Delete
      No collections match your filters.
      +
      +
      {{ $collections->links() }}
      diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php index ea8afa4d..b26d5762 100644 --- a/resources/views/livewire/admin/customers/index.blade.php +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -1 +1,6 @@ -

      Customers

      @foreach ($customers as $customer)@endforeach
      CustomerOrdersJoined
      {{ $customer->name }}

      {{ $customer->email }}

      {{ $customer->orders_count }}{{ $customer->created_at->format('M j, Y') }}
      {{ $customers->links() }}
      +
      +

      Relationships

      CustomersReview customer accounts, order history, and lifetime value.
      + +
      @forelse ($customers as $customer)@empty@endforelse
      Customers
      NameEmailOrdersTotal spentCreated
      {{ $customer->name ?: 'Unnamed customer' }}{{ $customer->email }}{{ number_format($customer->orders_count) }}{{ app('current_store')->default_currency }} {{ number_format(($customer->orders_sum_total_amount ?? 0) / 100, 2) }}{{ $customer->created_at?->format('M j, Y') }}
      No customers match your search.
      +
      {{ $customers->links() }}
      +
      diff --git a/resources/views/livewire/admin/customers/show.blade.php b/resources/views/livewire/admin/customers/show.blade.php index 3525048e..9bef976a 100644 --- a/resources/views/livewire/admin/customers/show.blade.php +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -1 +1,12 @@ -
      ← Customers

      {{ $customer->name }}

      {{ $customer->email }}

      Order history

      @forelse ($customer->orders as $order){{ $order->order_number }}€{{ number_format($order->total_amount / 100, 2) }}@empty

      No orders.

      @endforelse

      Addresses

      @forelse ($customer->addresses as $address)
      {{ $address->address_json['address1'] ?? '' }}, {{ $address->address_json['city'] ?? '' }}
      @empty

      No addresses.

      @endforelse
      +
      +
      ← Customers{{ $customer->name ?: 'Customer' }}{{ $customer->email }}
      @if ($message)
      {{ $message }}
      @endif
      +
      +
      +
      Customer informationEdit
      @if ($editingCustomer)
      Save customerCancel
      @else
      Name
      {{ $customer->name ?: '—' }}
      Email
      {{ $customer->email }}
      Created
      {{ $customer->created_at?->format('M j, Y') }}
      Marketing
      {{ data_get($customer->metadata, 'marketing_opt_in', false) ? 'Opted in' : 'Not opted in' }}
      @endif
      +
      Order history
      @forelse ($orders as $order)@empty@endforelse
      OrderDateStatusTotal
      {{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ ucfirst($order->financial_status->value) }}{{ $order->currency }} {{ number_format($order->total_amount / 100, 2) }}
      No orders for this customer.
      {{ $orders->links() }}
      +
      +
      AddressesAdd address
      @forelse ($customer->addresses as $address)

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

      @php($storedAddress = $address->address_json ?? [])
      {{ $storedAddress['line1'] ?? $storedAddress['address1'] ?? '' }}
      {{ $storedAddress['line2'] ?? $storedAddress['address2'] ?? '' }}
      {{ $storedAddress['city'] ?? '' }} {{ $storedAddress['state'] ?? $storedAddress['province'] ?? '' }} {{ $storedAddress['zip'] ?? $storedAddress['postal_code'] ?? '' }}
      {{ $storedAddress['country'] ?? $storedAddress['country_code'] ?? '' }}
      EditDelete
      @unless ($address->is_default)@endunless
      @empty

      No saved addresses.

      @endforelse
      +
      + +
      {{ $editingAddress ? 'Edit address' : 'Add address' }}
      CancelSave address
      +
      diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php index 0d00941e..3dba292e 100644 --- a/resources/views/livewire/admin/dashboard.blade.php +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -1 +1,6 @@ -

      Overview

      Dashboard

      Add product

      Total sales

      €{{ number_format($sales / 100, 2) }}

      Orders

      {{ $orderCount }}

      Products

      {{ $productCount }}

      Average order value

      €{{ number_format($orderCount ? ($sales / $orderCount) / 100 : 0, 2) }}

      +
      +

      Overview

      Dashboard

      Performance for the selected period.

      Add product
      +

      Total sales

      €{{ number_format($sales / 100, 2) }}

      Orders

      {{ $orderCount }}

      Visitors

      {{ number_format($visitors) }}

      Add to cart

      {{ number_format($addToCart) }}

      Average order value

      €{{ number_format($orderCount ? ($sales / $orderCount) / 100 : 0, 2) }}

      +

      Sales trend

      {{ $analytics->count() }} recorded days
      {{ $analytics->first()?->date?->toFormattedDateString() ?? 'Start' }}Revenue in EUR

      Conversion funnel

      Visitors
      {{ number_format($visitors) }}
      Added to cart
      {{ number_format($addToCart) }}
      Checkout started
      {{ number_format($checkoutStarted) }}
      Orders
      {{ number_format($orderCount) }}
      +

      Recent orders

      View all

      Top products

      @forelse ($topProducts as $product)
      {{ $product->product_title }}{{ $product->units }} sold · €{{ number_format($product->revenue / 100, 2) }}
      @empty

      No product sales yet.

      @endforelse
      +
      diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php index 44e73cee..0e9b39d0 100644 --- a/resources/views/livewire/admin/developers/index.blade.php +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -1,3 +1,24 @@ -
      - {{-- Simplicity is the essence of happiness. - Cedric Bledsoe --}} +
      +

      Developers

      API & webhooks

      Create scoped API tokens and connect external systems to store events.

      + + @if ($plainTextToken) + + @endif + +
      +

      Create API token

      +
      + + + +
      Create token
      + +
      @forelse ($tokens as $token)@empty@endforelse
      NameAbilitiesLast usedExpires
      {{ $token->name }}{{ implode(', ', $token->abilities ?? []) }}{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->expires_at?->toFormattedDateString() ?? 'Default expiry' }}Revoke
      No API tokens yet.
      +
      + +
      +

      Webhooks

      +
      Add webhook
      +
      @forelse($subscriptions as $subscription)@empty@endforelse
      EventEndpointStatus
      {{ $subscription->event }}{{ $subscription->target_url }}{{ ucfirst($subscription->status) }}{{ $subscription->status === 'active' ? 'Pause' : 'Resume' }}
      No webhook subscriptions.
      +
      diff --git a/resources/views/livewire/admin/discounts/form.blade.php b/resources/views/livewire/admin/discounts/form.blade.php index 470b084b..f627ccee 100644 --- a/resources/views/livewire/admin/discounts/form.blade.php +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -1 +1,11 @@ -
      ← Discounts

      Create discount

      @if ($message)
      {{ $message }}
      @endif
      +
      +
      ← Discounts{{ $discount ? 'Edit discount' : 'Create discount' }}Set the offer, eligibility, usage limits, and schedule.
      + @if ($message)
      {{ $message }}
      @endif +
      +
      Type
      @if ($type === 'code')
      Generate
      @endif
      +
      Value
      @if ($valueType !== 'free_shipping')@endif
      +
      Eligibility
      @if ($productSearch && $productResults->isNotEmpty())
      @foreach ($productResults as $product)@endforeach
      @endif
      @foreach ($selectedProducts as $product){{ $product->title }}@endforeach
      @if ($collectionSearch && $collectionResults->isNotEmpty())
      @foreach ($collectionResults as $collection)@endforeach
      @endif
      @foreach ($selectedCollections as $collection){{ $collection->title }}@endforeach
      +
      Schedule and usage
      +
      CancelSave discount
      +
      +
      diff --git a/resources/views/livewire/admin/discounts/index.blade.php b/resources/views/livewire/admin/discounts/index.blade.php index 5d7360e7..41529e30 100644 --- a/resources/views/livewire/admin/discounts/index.blade.php +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -1 +1,6 @@ -

      Marketing

      Discounts

      Create discount
      @foreach ($discounts as $discount)@endforeach
      CodeValueStatus
      {{ $discount->code }}{{ $discount->value_type->value === 'percent' ? $discount->value_amount.'%' : ($discount->value_type->value === 'fixed' ? '€'.number_format($discount->value_amount / 100, 2) : 'Free shipping') }}{{ $discount->isAvailable() ? 'Active' : 'Expired' }}
      +
      +

      Marketing

      DiscountsCreate offers, target products, and monitor usage.
      Create discount
      +
      +
      @forelse ($discounts as $discount) @php($discountStatus = $discount->starts_at?->isFuture() ? 'scheduled' : (($discount->ends_at?->isPast() || ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit)) ? 'expired' : ($discount->status === 'active' ? 'active' : 'disabled'))) @empty@endforelse
      Discounts
      CodeTypeValueUsageStatusDatesActions
      {{ $discount->code ?: 'Automatic' }}{{ $discount->type->value === 'automatic' ? 'Automatic' : 'Code' }}{{ $discount->value_type->value === 'percent' ? $discount->value_amount.'%' : ($discount->value_type->value === 'fixed' ? app('current_store')->default_currency.' '.number_format($discount->value_amount / 100, 2) : 'Free shipping') }}{{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'unlimited' }}{{ ucfirst($discountStatus) }}
      {{ $discount->starts_at?->format('M j, Y') ?? 'Any time' }}
      {{ $discount->ends_at?->format('M j, Y') ?? 'No end date' }}
      Delete
      No discounts match these filters.
      +
      {{ $discounts->links() }}
      +
      diff --git a/resources/views/livewire/admin/inventory/index.blade.php b/resources/views/livewire/admin/inventory/index.blade.php index ae010909..c66df4d8 100644 --- a/resources/views/livewire/admin/inventory/index.blade.php +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -1,3 +1 @@ -
      - {{-- Let all your things have their places; let each part of your business have its time. - Benjamin Franklin --}} -
      +

      Catalog

      Inventory

      Track on-hand, reserved, and available quantities by variant.

      @forelse($items as $item)@empty@endforelse
      Product / variantSKUOn handReservedPolicySave
      {{ $item->variant?->product?->title }}
      {{ $item->variant?->title }}
      {{ $item->variant?->sku ?: '—' }}{{ $item->quantity_reserved }}Save
      No inventory matches your filters.
      {{ $items->links() }}
      diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php index d70074ae..bf22a86c 100644 --- a/resources/views/livewire/admin/navigation/index.blade.php +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -1,3 +1 @@ -
      - {{-- Nothing in life is to be feared, it is only to be understood. Now is the time to understand more, so that we may fear less. - Maria Skłodowska-Curie --}} -
      +

      Online store

      Navigation

      Manage menus and links shown across the storefront.

      Menus

      @foreach($menus as $menu)@endforeach
      Save menu
      @if($menu)

      {{ $menu->name }} items

      @forelse($menu->items as $item)

      {{ $item->label }}

      {{ $item->url }}

      Remove
      @empty

      No links yet.

      @endforelse
      Add link
      @endif
      diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php index 5c95a5ff..e0014c15 100644 --- a/resources/views/livewire/admin/orders/index.blade.php +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -1 +1,57 @@ -

      Commerce

      Orders

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

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

      {{ $order->customer?->name ?? $order->email }}{{ ucfirst($order->status->value) }}{{ ucfirst($order->financial_status->value) }}€{{ number_format($order->total_amount / 100, 2) }}
      No orders found.
      {{ $orders->links() }}
      +
      +
      +
      +

      Commerce

      + Orders + Search, review, and manage every order for this store. +
      +
      Updating…
      +
      + +
      + +
      + + + +
      + + + + + @foreach (['order_number' => 'Order', 'placed_at' => 'Date'] as $field => $label) + + @endforeach + + + + + + + + @forelse ($orders as $order) + @php + $financialStatus = $order->financial_status->value; + $fulfillmentStatus = $order->fulfillment_status->value; + @endphp + + + + + + + + + @empty + + @endforelse + +
      Orders
      CustomerPaymentFulfillment
      {{ $order->order_number }}{{ $order->placed_at?->format('M j, Y g:i A') ?? '—' }}
      {{ $order->customer?->name ?: 'Guest' }}
      {{ $order->customer?->email ?: $order->email }}
      {{ str_replace('_', ' ', ucfirst($financialStatus)) }}{{ str_replace('_', ' ', ucfirst($fulfillmentStatus)) }}{{ $order->currency }} {{ number_format($order->total_amount / 100, 2) }}
      No orders match these filters.
      +
      + +
      {{ $orders->links() }}
      +
      diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php index 5adbbbd6..5e0fc536 100644 --- a/resources/views/livewire/admin/orders/show.blade.php +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -1 +1,62 @@ -
      ← Orders

      {{ $order->order_number }}

      {{ ucfirst($order->financial_status->value) }} · {{ ucfirst($order->fulfillment_status->value) }}

      @if ($message){{ $message }}@endif

      Line items

      @foreach ($order->lines as $line)
      {{ $line->product_title }} · {{ $line->variant_title }} × {{ $line->quantity }}€{{ number_format($line->line_total_amount / 100, 2) }}
      @endforeach
      Total€{{ number_format($order->total_amount / 100, 2) }}

      Fulfillments

      @forelse ($order->fulfillments as $fulfillment)

      {{ ucfirst($fulfillment->status) }}

      @empty

      No fulfillments yet.

      @endforelse
      +
      +
      +
      ← Orders
      {{ $order->order_number }}{{ str_replace('_', ' ', ucfirst($order->financial_status->value)) }}{{ str_replace('_', ' ', ucfirst($order->fulfillment_status->value)) }}
      Placed {{ $order->placed_at?->format('M j, Y g:i A') ?? '—' }}
      + @if ($message)
      {{ $message }}
      @endif +
      + + @if ($errors->has('payment') || $errors->has('fulfillment') || $errors->has('fulfillmentLines') || $errors->has('refundAmount')) + +
        @foreach (['payment', 'fulfillment', 'fulfillmentLines', 'refundAmount'] as $errorKey) @foreach ($errors->get($errorKey) as $error)
      • {{ $error }}
      • @endforeach @endforeach
      +
      + @endif + +
      +
      +
      +
      + @if ($order->payment_method === 'bank_transfer' && $order->financial_status->value === 'pending')Confirm payment@endif + @if ($order->fulfillment_status->value !== 'fulfilled')Create fulfillment@endif + @if (in_array($order->financial_status->value, ['paid', 'partially_refunded'], true))Refund@endif +
      + @if (! in_array($order->financial_status->value, ['paid', 'partially_refunded'], true))Payment must be confirmed before items can be fulfilled. Current financial status: {{ $order->financial_status->value }}.@endif +
      + +
      + Order timeline +
        +
      1. Order placed

        {{ $order->placed_at?->format('M j, Y g:i A') ?? '—' }}

      2. + @foreach ($order->payments as $payment) + @if ($payment->status->value === 'captured')
      3. Payment received

        {{ $payment->updated_at?->format('M j, Y g:i A') }}

      4. @endif + @endforeach + @foreach ($order->fulfillments as $fulfillment) +
      5. Fulfillment {{ $fulfillment->status }}

        {{ $fulfillment->updated_at?->format('M j, Y g:i A') }}

      6. + @endforeach + @foreach ($order->refunds->where('status', 'processed') as $refund)
      7. Refunded {{ $order->currency }} {{ number_format($refund->amount / 100, 2) }}

        {{ $refund->updated_at?->format('M j, Y g:i A') }}

      8. @endforeach +
      +
      + +
      +
      Order lines
      +
      @foreach ($order->lines as $line)@endforeach
      ProductSKUQtyUnit priceTotal

      {{ $line->product_title }}

      {{ $line->variant_title }}

      {{ $line->sku ?: '—' }}{{ $line->quantity }}{{ $order->currency }} {{ number_format($line->unit_price_amount / 100, 2) }}{{ $order->currency }} {{ number_format($line->line_total_amount / 100, 2) }}
      +
      Subtotal
      {{ $order->currency }} {{ number_format($order->subtotal_amount / 100, 2) }}
      Discount
      -{{ $order->currency }} {{ number_format($order->discount_amount / 100, 2) }}
      Shipping
      {{ $order->currency }} {{ number_format($order->shipping_amount / 100, 2) }}
      Tax
      {{ $order->currency }} {{ number_format($order->tax_amount / 100, 2) }}
      Total
      {{ $order->currency }} {{ number_format($order->total_amount / 100, 2) }}
      +
      + +
      Payment details@forelse ($order->payments as $payment)
      Method: {{ str_replace('_', ' ', ucfirst($payment->method->value)) }}Status: {{ ucfirst($payment->status->value) }}Amount: {{ $payment->currency }} {{ number_format($payment->amount / 100, 2) }}Reference: {{ $payment->provider_payment_id ?: '—' }}
      @empty

      No payment record exists.

      @endforelse
      + +
      Fulfillments@forelse ($order->fulfillments as $fulfillment)
      {{ ucfirst($fulfillment->status) }}

      {{ $fulfillment->tracking_company ?: 'No carrier' }} {{ $fulfillment->tracking_number }}

      @if ($fulfillment->status === 'pending')Mark as shipped@elseif ($fulfillment->status === 'shipped')Mark as delivered@endif
        @foreach ($fulfillment->lines as $fulfillmentLine)
      • {{ $fulfillmentLine->orderLine?->product_title }} × {{ $fulfillmentLine->quantity }}
      • @endforeach
      @if ($fulfillment->tracking_url)Track shipment@endif
      @empty

      No fulfillments yet.

      @endforelse
      +
      + + +
      + + +
      Create fulfillment
      @foreach ($order->lines as $line) @if (($fulfillmentLines[$line->id] ?? 0) > 0)
      @endif @endforeach
      CancelCreate fulfillment
      +
      + + +
      Refund order
      @foreach ($order->lines as $line)
      @endforeach
      CancelCreate refund
      +
      +
      diff --git a/resources/views/livewire/admin/pages/create.blade.php b/resources/views/livewire/admin/pages/create.blade.php index 50d23fe5..62c97a00 100644 --- a/resources/views/livewire/admin/pages/create.blade.php +++ b/resources/views/livewire/admin/pages/create.blade.php @@ -1,3 +1 @@ -
      - {{-- The whole future lies in uncertainty: live immediately. - Seneca --}} -
      + diff --git a/resources/views/livewire/admin/pages/edit.blade.php b/resources/views/livewire/admin/pages/edit.blade.php index 005bffa0..b587f07a 100644 --- a/resources/views/livewire/admin/pages/edit.blade.php +++ b/resources/views/livewire/admin/pages/edit.blade.php @@ -1,3 +1 @@ -
      - {{-- Do what you can, with what you have, where you are. - Theodore Roosevelt --}} -
      + 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..b05c4c23 --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1 @@ +

      Online store

      {{ $page ? 'Edit page' : 'Create page' }}

      CancelSave page
      diff --git a/resources/views/livewire/admin/pages/index.blade.php b/resources/views/livewire/admin/pages/index.blade.php index 7e910999..5bfceae2 100644 --- a/resources/views/livewire/admin/pages/index.blade.php +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -1,3 +1,5 @@ -
      - {{-- Breathing in, I calm body and mind. Breathing out, I smile. - Thich Nhat Hanh --}} +
      +

      Online store

      Pages

      Publish the content pages linked from your storefront.

      Create page
      +
      +
      @forelse($pages as $page)@empty@endforelse
      PageStatusUpdatedActions
      {{ $page->title }}
      /pages/{{ $page->handle }}
      {{ ucfirst($page->status->value) }}{{ $page->updated_at?->diffForHumans() }}Delete
      No pages match your filters.
      {{ $pages->links() }}
      diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php index 4d88ecee..c9cd74e3 100644 --- a/resources/views/livewire/admin/products/index.blade.php +++ b/resources/views/livewire/admin/products/index.blade.php @@ -1 +1 @@ -

      Catalog

      Products

      Add product
      @if ($message)
      {{ $message }}
      @endif
      @forelse ($products as $product)@empty@endforelse
      ProductStatusPriceActions
      {{ $product->title }}

      {{ $product->vendor }}

      {{ ucfirst($product->status->value) }}€{{ number_format(($product->defaultVariant()?->price_amount ?? 0) / 100, 2) }}@if ($product->status->value !== 'archived')@endif
      No products found.
      {{ $products->links() }}
      +

      Catalog

      Products

      Add product
      @if ($message)
      {{ $message }}
      @endif
      @if (count($selectedIds))Archive selected ({{ count($selectedIds) }})@endif
      @forelse ($products as $product)@empty@endforelse
      ProductStatusInventoryPriceActions
      {{ $product->title }}

      {{ $product->vendor }}

      {{ ucfirst($product->status->value) }}{{ $product->variants->sum(fn ($variant) => $variant->inventory?->availableQuantity() ?? 0) }}€{{ number_format(($product->defaultVariant()?->price_amount ?? 0) / 100, 2) }}@if ($product->status->value !== 'archived')@endif
      No products found.
      {{ $products->links() }}
      diff --git a/resources/views/livewire/admin/search/settings.blade.php b/resources/views/livewire/admin/search/settings.blade.php index 9568d334..34338a59 100644 --- a/resources/views/livewire/admin/search/settings.blade.php +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -1,3 +1 @@ -
      - {{-- We must ship. - Taylor Otwell --}} -
      +

      Search

      Search settings

      Control indexing and query normalization for this store.

      @if($message)
      {{ $message }}
      @endif
      Save settingsRebuild search index
      diff --git a/resources/views/livewire/admin/settings/domains.blade.php b/resources/views/livewire/admin/settings/domains.blade.php new file mode 100644 index 00000000..116a8270 --- /dev/null +++ b/resources/views/livewire/admin/settings/domains.blade.php @@ -0,0 +1,3 @@ +
      Connected domainsAdd domain
      @forelse ($domains as $domain)@empty@endforelse
      HostnameTypePrimaryTLSActions
      {{ $domain->hostname }}{{ ucfirst($domain->type->value) }}@if ($domain->is_primary)Primary@elseSet primary@endif{{ ucfirst($domain->tls_mode ?: 'managed') }}Delete
      No custom domains configured.
      +
      Add domain
      CancelAdd domain
      +
      diff --git a/resources/views/livewire/admin/settings/general.blade.php b/resources/views/livewire/admin/settings/general.blade.php index 8aea02c0..73f93107 100644 --- a/resources/views/livewire/admin/settings/general.blade.php +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -1 +1,11 @@ -

      Configuration

      Store Settings

      @if ($message)
      {{ $message }}
      @endif
      +
      +

      Configuration

      Store settingsManage store details and defaults used throughout the storefront.
      + @if ($message)
      {{ $message }}
      @endif +
      +
      Store detailsBasic information about your store.
      +
      DefaultsCurrency, language, and timezone settings.
      @foreach ($currencies as $code => $label)@endforeach@foreach ($locales as $code => $label)@endforeach@foreach ($timezones as $timezoneOption)@endforeach
      +
      Save settings
      +
      +
      DomainsManage the hostnames that point to this store.
      + +
      diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php index 8a6f5a1b..da91606e 100644 --- a/resources/views/livewire/admin/settings/shipping.blade.php +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -1 +1,8 @@ -
      ← Settings

      Shipping settings

      @if ($message)
      {{ $message }}
      @endif
      @foreach ($zones as $zone)

      {{ $zone->name }}

      @foreach ($zone->rates as $rate)

      {{ $rate->name }} · €{{ number_format($rate->price_amount / 100, 2) }}

      @endforeach
      @endforeach
      +
      +
      ← SettingsShippingDefine zones and rates used during checkout.
      Add zone
      + @if ($message)
      {{ $message }}
      @endif +
      @forelse ($zones as $zone)
      {{ $zone->name }}

      {{ $zone->countries_json ? implode(', ', $zone->countries_json) : 'All countries' }}

      EditDelete
      @forelse ($zone->rates as $rate)@empty@endforelse
      NameTypeConfigActiveActions
      {{ $rate->name }}{{ ucfirst($rate->type) }}{{ $rate->currency }} {{ number_format($rate->price_amount / 100, 2) }}EditDelete
      No rates configured.
      Add rate
      @empty
      No shipping zones configured.
      @endforelse
      +
      Test shipping addressEnter an address to see which zone and rates match.
      @foreach ($countries as $code => $country)@endforeach
      Test address
      @if ($testResult)
      @if ($testResult['zone'])

      Matched zone: {{ $testResult['zone'] }}

        @foreach ($testResult['rates'] as $rate)
      • {{ $rate['name'] }} — {{ $rate['currency'] }} {{ number_format($rate['price_amount'] / 100, 2) }}
      • @endforeach
      @else No shipping zone matches this address.@endif
      @endif
      +
      {{ $editingZone ? 'Edit shipping zone' : 'Add shipping zone' }}
      Countries
      @foreach ($countries as $code => $country)@endforeach
      CancelSave zone
      +
      {{ $editingRate ? 'Edit shipping rate' : 'Add shipping rate' }}@if ($rateType === 'carrier')Carrier-calculated rates require a carrier integration.@else@if (in_array($rateType, ['weight', 'price'], true))
      @endif @endif
      CancelSave rate
      +
      diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php index 2e634e87..a9e74c14 100644 --- a/resources/views/livewire/admin/settings/taxes.blade.php +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -1 +1,10 @@ -
      ← Settings

      Tax Settings

      @if ($message)
      {{ $message }}
      @endif

      1900 basis points = 19%.

      +
      +
      ← SettingsTaxesChoose manual rates or configure a tax provider.
      + @if ($message)
      {{ $message }}
      @endif +
      +
      Mode selection
      + @if ($mode === 'manual')
      Manual ratesAdd rate
      @foreach ($manualRates as $index => $manualRate)
      Remove
      @endforeach
      @else
      Provider configuration@if ($providerKeyConfigured)

      An API key is configured. Leave the field blank to keep it.

      @endif
      @endif +

      When enabled, listed prices include tax and tax is calculated backwards from the price.

      +
      Save tax settings
      +
      +
      diff --git a/resources/views/livewire/admin/themes/editor.blade.php b/resources/views/livewire/admin/themes/editor.blade.php index 607bcaf3..35128ae8 100644 --- a/resources/views/livewire/admin/themes/editor.blade.php +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -1,3 +1 @@ -
      - {{-- Simplicity is the ultimate sophistication. - Leonardo da Vinci --}} -
      +

      Theme editor

      {{ $theme->name }}

      Edit JSON-backed theme settings with a live preview link.

      Back to themes
      Save settings

      Storefront preview

      Open the storefront in another tab to preview published theme changes.

      Open storefront
      diff --git a/resources/views/livewire/admin/themes/index.blade.php b/resources/views/livewire/admin/themes/index.blade.php index c164f2a9..13eafddb 100644 --- a/resources/views/livewire/admin/themes/index.blade.php +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -1,3 +1 @@ -
      - {{-- Order your soul. Reduce your wants. - Augustine --}} -
      +

      Online store

      Themes

      Preview, customize, and publish the storefront theme.

      @forelse($themes as $theme)

      {{ $theme->name }}

      Version {{ $theme->version }}

      {{ ucfirst($theme->status->value) }}
      Customize@if($theme->status->value !== 'published')Publish@endifDuplicate@if($theme->status->value !== 'published')Delete@endif
      @empty
      No themes have been created.
      @endforelse
      diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php index 85cf2061..eaf1d4e4 100644 --- a/resources/views/livewire/storefront/account/auth/login.blade.php +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -1 +1,12 @@ -

      Welcome back

      Log in

      @error('email')

      {{ $message }}

      @enderror
      @error('password')

      {{ $message }}

      @enderror

      New here? Create an account

      Forgot password?
      +
      +

      Welcome back

      +

      Log in

      +
      +
      @error('email')

      {{ $message }}

      @enderror
      +
      @error('password')

      {{ $message }}

      @enderror
      + + +
      +

      New here? Create an account

      + Forgot password? +
      diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php index 3701b11d..94850f71 100644 --- a/resources/views/livewire/storefront/account/auth/register.blade.php +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -1 +1,16 @@ -

      Join us

      Create your account

      @error('*')

      {{ $message }}

      @enderror
      +
      +

      Join us

      +

      Create your account

      +
      +
      +
      +
      +
      +
      +
      +
      + + @error('*')

      {{ $message }}

      @enderror + +
      +
      diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php new file mode 100644 index 00000000..e8f33d8a --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,10 @@ +
      + + +
      diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php index 46e491ac..9010550c 100644 --- a/resources/views/livewire/storefront/cart/show.blade.php +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -1 +1,165 @@ -

      Shopping bag

      Your Cart

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

      Your cart is empty

      Add something you love to get started.

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

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

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

      {{ $line->quantity }}

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

      @endforeach
      @endif
      +
      +
      +
      +

      Shopping bag

      +

      Your cart

      +

      {{ $cart->itemCount() }} {{ $cart->itemCount() === 1 ? 'item' : 'items' }}

      +
      + + Continue shopping + +
      + + @if ($message) +

      {{ $message }}

      + @endif + + @error('cart') + + @enderror + + @if ($cart->lines->isEmpty()) +
      + +

      Your cart is empty

      +

      Add something you love to get started.

      + + Browse collections + +
      + @else +
      +
      +

      Items in your cart

      + +
      + @foreach ($cart->lines as $line) +
      +
      + @if ($line->variant->product->media->first()?->url) + {{ $line->variant->product->title }} + @else + + @endif + +
      + {{ $line->variant->product->title }} +

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

      +

      {{ $this->formatMoney($line->unit_price_amount) }} each

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

      {{ $this->formatMoney($line->line_total_amount) }}

      + +
      +
      +
      + @endforeach +
      + + +
      + + +
      + @endif +
      diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php index 9da42b0b..63054c88 100644 --- a/resources/views/livewire/storefront/checkout/confirmation.blade.php +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -1 +1,98 @@ -

      Thank you

      Order confirmed

      Your order number is {{ $order->order_number }}.

      Total€{{ number_format($order->total_amount / 100, 2) }}
      Payment{{ $order->financial_status->value === 'pending' ? 'Bank transfer pending' : 'Paid' }}
      Continue shopping
      +
      +
      + +

      Thank you for your order

      +

      Order confirmed

      +

      Order {{ $order->order_number }}

      +

      We’ve sent a confirmation to {{ $order->email }}.

      +
      + +
      +
      +
      +

      Order summary

      +
      +
      + @foreach ($order->lines as $line) +
      + @if ($line->variant?->product?->media?->first()?->url) + {{ $line->product_title }} + @else + + @endif +
      +

      {{ $line->product_title }}

      +

      {{ $line->variant_title }} · Quantity {{ $line->quantity }}

      +
      +

      {{ $this->formatMoney($line->line_total_amount) }}

      +
      + @endforeach +
      +
      + +
      +
      +

      Shipping address

      + @if ($order->shipping_address_json) +
      + {{ $order->shipping_address_json['first_name'] ?? '' }} {{ $order->shipping_address_json['last_name'] ?? '' }}
      + {{ $order->shipping_address_json['address1'] ?? '' }}
      + @if (! empty($order->shipping_address_json['address2'])){{ $order->shipping_address_json['address2'] }}
      @endif + {{ $order->shipping_address_json['postal_code'] ?? '' }} {{ $order->shipping_address_json['city'] ?? '' }}
      + {{ $order->shipping_address_json['country_code'] ?? '' }} +
      + @else +

      No shipping address is required for this order.

      + @endif +
      + +
      +

      Payment

      +

      {{ $this->paymentLabel() }}

      +

      + @if ($order->financial_status->value === 'pending') + Payment pending + @elseif ($order->financial_status->value === 'paid') + Payment received + @else + {{ ucfirst($order->financial_status->value) }} + @endif +

      +
      +
      + + @if ($this->isBankTransfer()) +
      +

      Bank transfer instructions

      +

      Please transfer the total amount to the following account. Complete your transfer within 7 days; your order will be processed once payment is confirmed.

      +
      +
      Bank
      Mock Bank AG
      +
      IBAN
      DE89 3704 0044 0532 0130 00
      +
      BIC
      COBADEFFXXX
      +
      Amount
      {{ $this->formatMoney($order->total_amount) }}
      +
      Reference
      {{ $order->order_number }}
      +
      +
      + @endif + +
      +

      Order totals

      +
      +
      Subtotal
      {{ $this->formatMoney($order->subtotal_amount) }}
      + @if ($order->discount_amount > 0) +
      Discount
      -{{ $this->formatMoney($order->discount_amount) }}
      + @endif +
      Shipping
      {{ $this->formatMoney($order->shipping_amount) }}
      +
      Tax
      {{ $this->formatMoney($order->tax_amount) }}
      +
      Total
      {{ $this->formatMoney($order->total_amount) }}
      +
      +
      +
      + +
      + Continue shopping + @if ($this->canViewAccountOrder()) + View order + @endif +
      +
      diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php index ecad0ad5..9ade3853 100644 --- a/resources/views/livewire/storefront/checkout/show.blade.php +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -1 +1,400 @@ -

      Secure checkout

      Checkout

      1. Contact and shipping address

      2. Shipping method

      @if ($rates->isEmpty())

      Enter an address to see available shipping methods.

      @else
      @foreach ($rates as $rate)@endforeach
      @endif

      3. Payment

      @foreach (['credit_card' => 'Credit card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank transfer'] as $value => $label)@endforeach
      @if ($paymentMethod === 'credit_card')@endif
      @if ($message)

      {{ $message }}

      @endif
      +
      +
      +

      Secure checkout

      +

      Complete your order

      +

      Your information is used only to process and deliver this order.

      +
      + +
      + +
      + +
      +
      +
      +
      + + @if ($activeStep > 1) + + @endif +
      + + @if ($activeStep === 1) +
      +
      + + +

      We’ll send your order confirmation here.

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

      Already have an account? Log in

      + +
      + @endif +
      + +
      +
      + + @if ($checkout->shipping_address_json !== null && $activeStep !== 2) + + @endif +
      + + @if ($activeStep === 2) +
      + @if ($savedAddresses->isNotEmpty()) +
      + + +
      + @endif + +
      + Shipping address +
      +
      + + + @error('shippingAddress.first_name')@enderror +
      +
      + + + @error('shippingAddress.last_name')@enderror +
      +
      + + + @error('shippingAddress.address1')@enderror +
      +
      + + +
      +
      + + + @error('shippingAddress.city')@enderror +
      +
      + + + @error('shippingAddress.state')@enderror +
      +
      + + + @error('shippingAddress.postal_code')@enderror +
      +
      + + + @error('shippingAddress.country_code')@enderror +
      +
      + + +
      +
      +
      + + + + @if (! $billingSameAsShipping) +
      + Billing address +
      +
      + + + @error('billingAddress.first_name')@enderror +
      +
      + + + @error('billingAddress.last_name')@enderror +
      +
      + + + @error('billingAddress.address1')@enderror +
      +
      + + +
      +
      + + + @error('billingAddress.city')@enderror +
      +
      + + + @error('billingAddress.state')@enderror +
      +
      + + + @error('billingAddress.postal_code')@enderror +
      +
      + + + @error('billingAddress.country_code')@enderror +
      +
      +
      + @endif + + +
      + @elseif ($checkout->shipping_address_json === null) +

      Complete your contact information first.

      + @endif +
      + +
      +
      + + @if ($activeStep > 3 && $this->requiresShipping()) + + @endif +
      + + @if ($activeStep === 3) +
      + @if ($rates->isEmpty()) + + @else +
      + Select a shipping method +
      + @foreach ($rates as $rate) + + @endforeach +
      +
      + @error('shippingRateId')@enderror + + @endif +
      + @elseif ($checkout->shipping_address_json === null) +

      Complete your shipping address first.

      + @endif +
      + +
      +
      + +
      + + @if ($activeStep === 4) +
      +
      + Select a payment method +
      + @foreach (['credit_card' => 'Credit card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank transfer'] as $value => $label) + + @endforeach +
      +
      + @error('paymentMethod')@enderror + + @if ($paymentMethod === 'credit_card') +
      +
      + + + @error('cardNumber')@enderror +
      +
      + + + @error('cardholderName')@enderror +
      +
      + + + @error('cardExpiry')@enderror +
      +
      + + + @error('cardCvc')@enderror +
      +
      + @elseif ($paymentMethod === 'paypal') +

      Your PayPal payment will be processed securely onsite. No external redirect is required.

      + @else +

      After placing your order, you will receive bank transfer instructions. Your order will be held for 7 days while we await your payment.

      + @endif + + @error('payment') + + @enderror + @if ($message) +

      {{ $message }}

      + @endif + + +
      + @elseif ($activeStep < 4) +

      Choose your shipping method first.

      + @endif +
      +
      + + +
      +
      diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php index 549bc3a8..1ad50e9b 100644 --- a/resources/views/livewire/storefront/products/show.blade.php +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -1,3 +1,3 @@ @php($selectedVariant = $product->variants->firstWhere('id', $selectedVariantId)) @php($soldOut = $selectedVariant?->inventory?->availableQuantity() <= 0 && $selectedVariant?->inventory?->policy?->value !== 'continue') -
      @if ($product->media->first()?->url){{ $product->title }}@else
      @endif
      @foreach ($product->media as $media)@endforeach

      {{ $product->vendor }}

      {{ $product->title }}

      €{{ number_format(($selectedVariant?->price_amount ?? 0) / 100, 2) }}

      {{ $product->description }}

      @foreach ($product->options as $option)
      {{ $option->name }}
      @foreach ($option->values as $value)@endforeach
      @endforeach@if ($soldOut)

      Sold out

      @elseif ($selectedVariant?->inventory?->policy?->value === 'continue' && $selectedVariant->availableQuantity() <= 0)

      Available on backorder

      @endif
      @error('quantity')

      {{ $message }}

      @enderror@if ($message)

      {{ $message }}

      @endif
      +
      @if ($product->media->first()?->url){{ $product->title }}@else
      @endif
      @foreach ($product->media as $media)@endforeach

      {{ $product->vendor }}

      {{ $product->title }}

      €{{ number_format(($selectedVariant?->price_amount ?? 0) / 100, 2) }}

      @if(($selectedVariant?->compare_at_amount ?? 0) > ($selectedVariant?->price_amount ?? 0))

      €{{ number_format($selectedVariant->compare_at_amount / 100, 2) }}

      Sale@endif
      {!! app(\App\Support\HtmlSanitizer::class)->sanitize($product->description) !!}
      @foreach ($product->options as $option)
      {{ $option->name }}: {{ $option->values->firstWhere('id', $selectedOptions[$option->id] ?? null)?->value }}
      @foreach ($option->values as $value)@php($available = $product->variants->contains(fn ($variant): bool => $variant->optionValues->contains('id', $value->id)))@endforeach
      @endforeach@if ($soldOut)

      Sold out

      @elseif ($selectedVariant?->inventory?->policy?->value === 'continue' && $selectedVariant->availableQuantity() <= 0)

      Available on backorder

      @endif
      @error('quantity')

      {{ $message }}

      @enderror@if ($message)

      {{ $message }}

      @endif
      diff --git a/routes/api.php b/routes/api.php index 280515c0..ffe38387 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,12 @@ middleware('throttle:checkout'); Route::put('checkouts/{checkoutId}/payment-method', [StorefrontCheckoutController::class, 'paymentMethod'])->middleware('throttle:checkout'); Route::post('checkouts/{checkoutId}/apply-discount', [StorefrontCheckoutController::class, 'applyDiscount'])->middleware('throttle:checkout'); + Route::delete('checkouts/{checkoutId}/discount', [StorefrontCheckoutController::class, 'removeDiscount'])->middleware('throttle:checkout'); Route::post('checkouts/{checkoutId}/pay', [StorefrontCheckoutController::class, 'pay'])->middleware('throttle:checkout'); - Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store']); + Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']); + Route::get('search', [StorefrontSearchController::class, 'index'])->middleware('throttle:search'); + Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->middleware('throttle:search'); + Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store'])->middleware('throttle:analytics'); }); -Route::prefix('admin/v1/stores/{storeId}')->middleware([StartSession::class, 'auth', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void { +Route::prefix('admin/v1')->middleware(['auth:sanctum', 'api.ability', 'throttle:api.admin'])->group(function (): void { + Route::post('platform/organizations', [PlatformController::class, 'storeOrganization']); + Route::post('platform/stores', [PlatformController::class, 'storeStore']); +}); + +Route::prefix('admin/v1/stores/{storeId}')->middleware(['auth:sanctum', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void { + Route::get('me', [PlatformController::class, 'me']); +}); + +Route::prefix('admin/v1/stores/{storeId}')->middleware(['auth:sanctum', 'store.resolve', 'role.check:owner,admin,staff,support', 'api.ability', 'throttle:api.admin'])->group(function (): void { + Route::post('invites', [PlatformController::class, 'invite'])->middleware('role.check:owner,admin'); Route::get('products', [AdminController::class, 'products']); Route::post('products', [AdminController::class, 'storeProduct'])->middleware('role.check:owner,admin,staff'); Route::get('products/{productId}', [AdminController::class, 'showProduct']); Route::put('products/{productId}', [AdminController::class, 'updateProduct'])->middleware('role.check:owner,admin,staff'); Route::delete('products/{productId}', [AdminController::class, 'deleteProduct'])->middleware('role.check:owner,admin,staff'); + Route::post('products/{productId}/media/presign-upload', [PlatformController::class, 'presignMediaUpload'])->middleware('role.check:owner,admin,staff'); + Route::post('products/{productId}/media/{mediaId}/complete', [PlatformController::class, 'completeMediaUpload'])->middleware('role.check:owner,admin,staff'); Route::get('collections', [AdminController::class, 'collections']); Route::post('collections', [AdminController::class, 'storeCollection'])->middleware('role.check:owner,admin,staff'); Route::put('collections/{collectionId}', [AdminController::class, 'updateCollection'])->middleware('role.check:owner,admin,staff'); @@ -37,4 +56,25 @@ Route::get('orders/{orderId}', [AdminController::class, 'showOrder']); Route::get('customers', [AdminController::class, 'customers']); Route::get('discounts', [AdminController::class, 'discounts']); + Route::post('discounts', [AdminController::class, 'storeDiscount'])->middleware('role.check:owner,admin,staff'); + Route::put('discounts/{discountId}', [AdminController::class, 'updateDiscount'])->middleware('role.check:owner,admin,staff'); + Route::delete('discounts/{discountId}', [AdminController::class, 'deleteDiscount'])->middleware('role.check:owner,admin'); + Route::get('shipping/zones', [AdminController::class, 'shippingZones']); + Route::post('shipping/zones', [AdminController::class, 'storeShippingZone'])->middleware('role.check:owner,admin'); + Route::put('shipping/zones/{zoneId}', [AdminController::class, 'updateShippingZone'])->middleware('role.check:owner,admin'); + Route::post('shipping/zones/{zoneId}/rates', [AdminController::class, 'storeShippingRate'])->middleware('role.check:owner,admin'); + Route::get('tax/settings', [AdminController::class, 'taxSettings']); + Route::put('tax/settings', [AdminController::class, 'updateTaxSettings'])->middleware('role.check:owner,admin'); + Route::get('pages', [AdminController::class, 'pages']); + Route::post('pages', [AdminController::class, 'storePage'])->middleware('role.check:owner,admin,staff'); + Route::put('pages/{pageId}', [AdminController::class, 'updatePage'])->middleware('role.check:owner,admin,staff'); + Route::delete('pages/{pageId}', [AdminController::class, 'deletePage'])->middleware('role.check:owner,admin'); + Route::post('themes', [AdminController::class, 'storeTheme'])->middleware('role.check:owner,admin'); + Route::post('themes/{themeId}/publish', [AdminController::class, 'publishTheme'])->middleware('role.check:owner,admin'); + Route::put('themes/{themeId}/settings', [AdminController::class, 'updateThemeSettings'])->middleware('role.check:owner,admin'); + Route::post('search/reindex', [AdminController::class, 'reindex'])->middleware('role.check:owner,admin,staff'); + Route::get('search/status', [AdminController::class, 'searchStatus']); + Route::get('analytics/summary', [AdminController::class, 'analyticsSummary']); + Route::post('orders/{orderId}/fulfillments', [AdminController::class, 'fulfillOrder'])->middleware('role.check:owner,admin,staff'); + Route::post('orders/{orderId}/refunds', [AdminController::class, 'refundOrder'])->middleware('role.check:owner,admin'); }); diff --git a/routes/web.php b/routes/web.php index 225941e2..72de499f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -99,6 +99,9 @@ return redirect()->route('admin.login'); })->name('admin.logout'); +Route::get('/oauth/authorize', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->middleware('auth')->name('oauth.authorize'); +Route::post('/oauth/token', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->name('oauth.token'); + Route::prefix('admin')->middleware(['auth', 'verified', 'store.resolve', 'role.check:owner,admin,staff,support'])->group(function (): void { Route::livewire('/', AdminDashboard::class)->name('admin.dashboard'); Route::livewire('/products', AdminProductsIndex::class)->name('admin.products.index'); @@ -112,21 +115,22 @@ Route::livewire('/discounts/create', AdminDiscountForm::class)->middleware('role.check:owner,admin,staff')->name('admin.discounts.create'); Route::livewire('/discounts/{discount}/edit', AdminDiscountForm::class)->middleware('role.check:owner,admin,staff')->name('admin.discounts.edit'); Route::livewire('/settings', AdminSettingsGeneral::class)->middleware('role.check:owner,admin')->name('admin.settings'); + Route::livewire('/settings/domains', \App\Livewire\Admin\Settings\Domains::class)->middleware('role.check:owner,admin')->name('admin.settings.domains'); Route::livewire('/settings/shipping', AdminSettingsShipping::class)->middleware('role.check:owner,admin')->name('admin.settings.shipping'); Route::livewire('/settings/taxes', AdminSettingsTaxes::class)->middleware('role.check:owner,admin')->name('admin.settings.taxes'); - Route::livewire('/inventory', AdminInventoryIndex::class)->name('admin.inventory'); - Route::livewire('/collections', AdminCollectionsIndex::class)->name('admin.collections'); + Route::livewire('/inventory', AdminInventoryIndex::class)->name('admin.inventory.index'); + Route::livewire('/collections', AdminCollectionsIndex::class)->name('admin.collections.index'); Route::livewire('/collections/create', AdminCollectionsCreate::class)->middleware('role.check:owner,admin,staff')->name('admin.collections.create'); Route::livewire('/collections/{collection}/edit', AdminCollectionsEdit::class)->middleware('role.check:owner,admin,staff')->name('admin.collections.edit'); - Route::livewire('/themes', AdminThemesIndex::class)->name('admin.themes'); + Route::livewire('/themes', AdminThemesIndex::class)->name('admin.themes.index'); Route::livewire('/themes/{theme}/editor', AdminThemesEditor::class)->name('admin.themes.editor'); - Route::livewire('/pages', AdminPagesIndex::class)->name('admin.pages'); + Route::livewire('/pages', AdminPagesIndex::class)->name('admin.pages.index'); Route::livewire('/pages/create', AdminPagesCreate::class)->middleware('role.check:owner,admin')->name('admin.pages.create'); Route::livewire('/pages/{page}/edit', AdminPagesEdit::class)->middleware('role.check:owner,admin')->name('admin.pages.edit'); - Route::livewire('/navigation', AdminNavigationIndex::class)->name('admin.navigation'); - Route::livewire('/apps', AdminAppsIndex::class)->name('admin.apps'); + Route::livewire('/navigation', AdminNavigationIndex::class)->name('admin.navigation.index'); + Route::livewire('/apps', AdminAppsIndex::class)->name('admin.apps.index'); Route::livewire('/apps/{installation}', AdminAppsShow::class)->name('admin.apps.show'); - Route::livewire('/developers', AdminDevelopersIndex::class)->name('admin.developers'); - Route::livewire('/analytics', AdminAnalyticsIndex::class)->name('admin.analytics'); + Route::livewire('/developers', AdminDevelopersIndex::class)->middleware('role.check:owner,admin')->name('admin.developers.index'); + Route::livewire('/analytics', AdminAnalyticsIndex::class)->name('admin.analytics.index'); Route::livewire('/search/settings', AdminSearchSettings::class)->name('admin.search.settings'); }); diff --git a/specs/progress.md b/specs/progress.md index f57398b8..45424975 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -1,29 +1,30 @@ # Implementation Progress -The core self-contained shop implementation is in place and verified with Pest and Playwright. +The self-contained multi-tenant shop is implemented across the database, commerce domain, storefront, admin, API, authentication, seed data, and delivery integrations described in specs 01–09. ## Completed -- [x] Foundation: SQLite configuration, tenant schema/models, store resolution, global tenant scope, roles, and policies. -- [x] Catalog: products, variants, options, inventory, collections, media, product status transitions, and seeded demo data. -- [x] Storefront: home, collections, product detail, variant selection, cart, search, static pages, responsive layouts, and theme scaffolding. -- [x] Cart and checkout: session/customer carts, optimistic cart versions, discount codes, addresses, shipping rates, tax calculation, and checkout expiry. -- [x] Payments and orders: mock card/PayPal/bank-transfer PSP, idempotent payment handling, inventory reservations, order snapshots, confirmation, cancellation, refunds, and fulfillment guards. -- [x] Authentication: Fortify admin authentication plus separate tenant-scoped customer authentication, registration, password reset, email verification, and 2FA support. -- [x] Admin: dashboard, product/order/customer/discount/settings screens, role middleware, resource-aware inventory/collection/theme/page/navigation/app/developer/analytics/search sections, and versioned session-authenticated catalog/collection/order/customer/discount API endpoints. -- [x] Search, analytics, apps, and webhooks: SQLite FTS5 indexing, query logging, analytics aggregation, signed webhook delivery, retries, and subscription pausing. -- [x] Automated coverage: unit and feature tests for pricing, tenancy, authentication, commerce flows, search, analytics, webhooks, and customer sessions. -- [x] Browser acceptance: storefront browsing, product add-to-cart, discount application, address/shipping/payment checkout, order confirmation, customer account, admin login, and admin section smoke checks. +- [x] Foundation: SQLite configuration, tenant schema/models, store resolution, global tenant scope, roles, policies, and Fortify authentication. +- [x] Catalog: products, variants, options, inventory, collections, media, status transitions, search indexing, and deterministic tenant fixtures. +- [x] Storefront: home, collections, product detail, variant selection, responsive layouts, cart, cart drawer, search, static pages, customer accounts, and order history. +- [x] Checkout: session/customer carts, optimistic versions, addresses, shipping rates, taxes, discounts, payment methods, expiry, order snapshots, confirmation, and saved addresses. +- [x] Commerce operations: payment idempotency, inventory reservations, cancellation, refunds with line allocations/restocking, fulfillment guards, and webhook delivery with signatures/retries. +- [x] Admin: dashboard metrics, product and collection CRUD, inventory, orders, customers, discounts, pages, navigation, themes, settings, analytics, apps, search settings, and developer tokens/webhooks. +- [x] API/security: session and Sanctum authentication, token abilities, tenant ownership checks, rate limiting, CORS, scoped customer password reset tokens, audit logging, and error pages. +- [x] Test data: canonical `DatabaseSeeder` plus isolated `ShopSeeder` compatibility fixtures, factories, and idempotency assertions. ## Verification -- `php artisan test`: 67 passing tests, 175 assertions. +- `php artisan test --compact`: 87 passing tests, 296 assertions. +- `vendor/bin/pint --dirty --format agent`: passing. +- PHP lint across application, database, routes, configuration, bootstrap, and tests: passing. +- `php artisan migrate:status --no-interaction`: all migrations applied, including tenant-scoped password reset tokens, webhook contract alignment, normalized theme settings, and store invitations. +- `php artisan view:cache --no-interaction`: passing. - `npm run build`: passing. -- `vendor/bin/pint --dirty --format agent`: run after the final PHP changes. -- Playwright MCP browser checks: no storefront/admin page errors in the completed smoke paths. +- Playwright MCP browser smoke checks: storefront home, product detail, cart drawer, collections, search, cart, responsive mobile layout, admin login/dashboard/products/orders/developers/settings, Flux domain modal, and mobile admin navigation; no application console errors observed. +- The Pest browser plugin is not installed and dependencies were intentionally left unchanged; the browser coverage above was executed manually through Playwright MCP. -## Remaining hardening +## Final audit -- Expand the resource-aware admin sections into full CRUD editors and add token-authenticated admin API coverage when the API authentication dependency is approved for production use. -- Add stronger opaque guest checkout tokens and broader resource-level API ownership tests. -- Add broader browser coverage for refund/fulfillment actions, customer registration/reset flows, and mobile interaction states. +- Independent read-only audit found and closed platform API, nested product persistence, media processing, webhook contract, and domain settings gaps. +- Follow-up read-only verification confirmed the residual media lifecycle and nested product response findings are resolved. diff --git a/tests/Feature/AdminApiTest.php b/tests/Feature/AdminApiTest.php index b7e42012..ee653916 100644 --- a/tests/Feature/AdminApiTest.php +++ b/tests/Feature/AdminApiTest.php @@ -15,14 +15,17 @@ }); test('store members can use the versioned admin catalog API', function (): void { - $this->actingAs($this->admin) + $token = $this->admin->createToken('catalog-manager', ['read-products', 'write-collections'])->plainTextToken; + + $this->withToken($token) ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products") ->assertOk() - ->assertJsonPath('meta.total', 5); + ->assertJsonPath('meta.total', 20); - $this->actingAs($this->admin) + $this->withToken($token) ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", [ 'title' => 'API Collection', + 'type' => 'manual', 'product_ids' => [], ]) ->assertCreated() @@ -31,8 +34,9 @@ test('admin API rejects a store id outside the current tenant', function (): void { $otherStore = Store::factory()->create(); + $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken; - $this->actingAs($this->admin) + $this->withToken($token) ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products") - ->assertNotFound(); + ->assertForbidden(); }); diff --git a/tests/Feature/ApiTokenTest.php b/tests/Feature/ApiTokenTest.php new file mode 100644 index 00000000..88986236 --- /dev/null +++ b/tests/Feature/ApiTokenTest.php @@ -0,0 +1,51 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail(); +}); + +test('admin bearer tokens can read only the abilities they were granted', function (): void { + $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken; + + $this->withToken($token) + ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products") + ->assertOk(); + + $this->withToken($token) + ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked', 'type' => 'manual']) + ->assertForbidden(); +}); + +test('admin bearer tokens can write with the matching ability', function (): void { + $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken; + + $this->withToken($token) + ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection', 'type' => 'manual']) + ->assertCreated() + ->assertJsonPath('data.title', 'Token Collection'); +}); + +test('session-authenticated admins cannot use the admin API', function (): void { + $this->actingAs($this->admin) + ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products") + ->assertUnauthorized(); +}); + +test('admin bearer tokens cannot access stores where the user is not a member', function (): void { + $otherStore = Store::factory()->create(); + $token = $this->admin->createToken('foreign-store-reader', ['read-products'])->plainTextToken; + + $this->withToken($token) + ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products") + ->assertForbidden(); +}); diff --git a/tests/Feature/CommerceFlowTest.php b/tests/Feature/CommerceFlowTest.php index c0c89ba2..8d7ad7d3 100644 --- a/tests/Feature/CommerceFlowTest.php +++ b/tests/Feature/CommerceFlowTest.php @@ -42,6 +42,7 @@ 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', + 'country' => 'Germany', 'country_code' => 'DE', 'postal_code' => '10115', ], @@ -55,6 +56,9 @@ $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [ 'payment_method' => 'credit_card', 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Flow Tester', ])->assertOk()->assertJsonPath('order.financial_status', 'paid'); $order = Order::query()->latest('id')->firstOrFail(); @@ -72,11 +76,11 @@ $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]); $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json(); - $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Declined', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Declined', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'postal_code' => '10115']]); $rate = ShippingRate::query()->firstOrFail(); $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]); - $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'credit_card', 'card_number' => '4000000000000002'])->assertUnprocessable(); + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'credit_card', 'card_number' => '4000000000000002', 'card_expiry' => '12/28', 'card_cvc' => '123', 'card_holder' => 'Declined Tester'])->assertUnprocessable(); expect(InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe($inventoryBefore) ->and(Order::query()->where('email', 'declined@example.test')->exists())->toBeFalse(); @@ -90,12 +94,29 @@ $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertConflict(); }); -test('guest cart API resources are bound to the current session', function (): void { - $firstCart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); - $secondCart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); +test('guest cart ids authorize stateless access within the current tenant', function (): void { + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); - $this->getJson("http://shop.test/api/storefront/v1/carts/{$firstCart['id']}")->assertNotFound(); - $this->getJson("http://shop.test/api/storefront/v1/carts/{$secondCart['id']}")->assertOk(); + $this->flushSession(); + + $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}") + ->assertOk() + ->assertJsonPath('id', $cart['id']); +}); + +test('guest cart ids cannot cross tenant boundaries', function (): void { + $otherStore = Store::factory()->create(); + $otherCart = \App\Models\Cart::withoutEvents(fn (): \App\Models\Cart => \App\Models\Cart::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->getKey(), + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => 'active', + ])); + + $this->flushSession(); + + $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}") + ->assertNotFound(); }); test('cart and checkout APIs return domain errors as unprocessable responses', function (): void { @@ -122,7 +143,7 @@ $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]); $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json(); - $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Bank', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Bank', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'postal_code' => '10115']]); $rate = ShippingRate::query()->firstOrFail(); $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]); diff --git a/tests/Feature/CommerceValidationAndCleanupTest.php b/tests/Feature/CommerceValidationAndCleanupTest.php new file mode 100644 index 00000000..f119894f --- /dev/null +++ b/tests/Feature/CommerceValidationAndCleanupTest.php @@ -0,0 +1,151 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $this->store); + $this->variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); +}); + +test('checkout addresses validate every required nested field', function (): void { + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [ + 'variant_id' => $this->variant->getKey(), + 'quantity' => 1, + 'cart_version' => 1, + ])->assertCreated(); + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart['id'], + 'email' => 'address-validation@example.test', + ])->assertCreated()->json(); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [ + 'shipping_address' => ['first_name' => 'Incomplete'], + ])->assertUnprocessable()->assertJsonValidationErrors([ + 'shipping_address.last_name', + 'shipping_address.address1', + 'shipping_address.city', + 'shipping_address.country', + 'shipping_address.country_code', + 'shipping_address.postal_code', + ]); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => false, + ])->assertUnprocessable()->assertJsonValidationErrors([ + 'billing_address', + 'billing_address.first_name', + ]); +}); + +test('credit card payments require all card fields while other payment methods do not', function (): void { + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [ + 'variant_id' => $this->variant->getKey(), + 'quantity' => 1, + 'cart_version' => 1, + ])->assertCreated(); + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart['id'], + 'email' => 'card-validation@example.test', + ])->assertCreated()->json(); + $address = [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ]; + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => $address])->assertOk(); + $rate = ShippingRate::query()->firstOrFail(); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()])->assertOk(); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4242 4242 4242 4242', + ])->assertUnprocessable()->assertJsonValidationErrors([ + 'card_expiry', + 'card_cvc', + 'card_holder', + ]); +}); + +test('abandoned cart cleanup does not release inventory owned by another checkout', function (): void { + $carts = app(CartService::class); + $inventory = InventoryItem::query()->where('variant_id', $this->variant->getKey())->firstOrFail(); + + $expiredCart = $carts->create($this->store); + $carts->addLine($expiredCart, $this->variant->getKey(), 1); + $expiredCheckout = Checkout::withoutGlobalScopes()->create([ + 'store_id' => $this->store->getKey(), + 'cart_id' => $expiredCart->getKey(), + 'email' => 'expired@example.test', + 'status' => CheckoutStatus::PaymentSelected, + 'expires_at' => now()->subMinute(), + ]); + app(InventoryService::class)->reserve($inventory, 1); + + app(ExpireAbandonedCheckouts::class)->handle(app(InventoryService::class)); + + $activeCart = $carts->create($this->store); + $carts->addLine($activeCart, $this->variant->getKey(), 1); + $activeCheckout = Checkout::withoutGlobalScopes()->create([ + 'store_id' => $this->store->getKey(), + 'cart_id' => $activeCart->getKey(), + 'email' => 'active@example.test', + 'status' => CheckoutStatus::PaymentSelected, + 'expires_at' => now()->addHour(), + ]); + app(InventoryService::class)->reserve($inventory->refresh(), 1); + DB::table('carts')->where('id', $expiredCart->getKey())->update(['updated_at' => now()->subDays(15)]); + + app(CleanupAbandonedCarts::class)->handle(app(InventoryService::class)); + + expect($expiredCheckout->refresh()->status)->toBe(CheckoutStatus::Expired) + ->and($activeCheckout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($expiredCart->refresh()->status->value)->toBe('abandoned') + ->and($inventory->refresh()->quantity_reserved)->toBe(1); +}); + +test('commerce rate limiters match the specification', function (): void { + $request = Request::create('/api/storefront/v1/search', 'GET', [], [], [], ['REMOTE_ADDR' => '192.0.2.1']); + $search = RateLimiter::limiter('search'); + $webhooks = RateLimiter::limiter('webhooks'); + + expect($search)->not->toBeNull() + ->and($webhooks)->not->toBeNull() + ->and($search($request)->maxAttempts)->toBe(30) + ->and($webhooks($request)->maxAttempts)->toBe(100) + ->and($search($request)->decaySeconds)->toBe(60) + ->and($webhooks($request)->decaySeconds)->toBe(60); +}); diff --git a/tests/Feature/ContractBehaviorTest.php b/tests/Feature/ContractBehaviorTest.php new file mode 100644 index 00000000..91ce8e12 --- /dev/null +++ b/tests/Feature/ContractBehaviorTest.php @@ -0,0 +1,117 @@ +keyBy('name'); + $foreignKeys = collect(DB::select("PRAGMA foreign_key_list('theme_settings')")); + + expect($columns->keys()->all())->toBe(['theme_id', 'settings_json', 'updated_at']) + ->and($columns['theme_id']['nullable'])->toBeFalse() + ->and($columns['settings_json']['default'])->toBe("'{}'") + ->and($foreignKeys->pluck('table')->all())->toContain('themes'); + + $theme = Theme::factory()->create(); + $setting = $theme->themeSettings()->create([ + 'settings_json' => ['primary_color' => '#1a1a2e', 'dark_mode' => true], + ]); + + expect($setting)->toBeInstanceOf(ThemeSetting::class) + ->and($setting->getKey())->toBe($theme->getKey()) + ->and($theme->fresh()->settingsRows->settings_json)->toBe([ + 'primary_color' => '#1a1a2e', + 'dark_mode' => true, + ]); +}); + +test('customer password reset tokens are non-null foreign-keyed and store scoped', function (): void { + $columns = collect(Schema::getColumns('customer_password_reset_tokens'))->keyBy('name'); + $foreignKeys = collect(DB::select("PRAGMA foreign_key_list('customer_password_reset_tokens')")); + + expect($columns['store_id']['nullable'])->toBeFalse() + ->and($foreignKeys->pluck('table')->all())->toContain('stores'); + + $firstStore = Store::factory()->create(); + $secondStore = Store::factory()->create(); + $firstCustomer = Customer::factory()->create(['store_id' => $firstStore->getKey(), 'email' => 'same@example.test']); + $secondCustomer = Customer::factory()->create(['store_id' => $secondStore->getKey(), 'email' => 'same@example.test']); + + app()->instance('current_store', $firstStore); + $firstToken = Password::broker('customers')->createToken($firstCustomer); + + expect(DB::table('customer_password_reset_tokens')->where('store_id', $firstStore->getKey())->where('email', $firstCustomer->email)->exists())->toBeTrue(); + + app()->instance('current_store', $secondStore); + + expect(Password::broker('customers')->tokenExists($secondCustomer, $firstToken))->toBeFalse(); + + $secondToken = Password::broker('customers')->createToken($secondCustomer); + + expect(Password::broker('customers')->tokenExists($secondCustomer, $secondToken))->toBeTrue(); +}); + +test('security defaults enable encrypted sessions, CORS credentials, and ninety-day audit retention', function (): void { + $sessionEncryptEnvironment = $_ENV['SESSION_ENCRYPT'] ?? null; + $sessionEncryptServer = $_SERVER['SESSION_ENCRYPT'] ?? null; + $hasEnvironmentValue = array_key_exists('SESSION_ENCRYPT', $_ENV); + $hasServerValue = array_key_exists('SESSION_ENCRYPT', $_SERVER); + + unset($_ENV['SESSION_ENCRYPT'], $_SERVER['SESSION_ENCRYPT']); + putenv('SESSION_ENCRYPT'); + $sessionConfig = require config_path('session.php'); + + if ($hasEnvironmentValue) { + $_ENV['SESSION_ENCRYPT'] = $sessionEncryptEnvironment; + } + + if ($hasServerValue) { + $_SERVER['SESSION_ENCRYPT'] = $sessionEncryptServer; + } + + expect($sessionConfig['encrypt'])->toBeTrue() + ->and(config('cors.supports_credentials'))->toBeTrue() + ->and(config('logging.channels.audit.days'))->toBe(90); +}); + +test('webhook secrets are encrypted under the contract name and sign the exact JSON request', function (): void { + Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]); + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $subscription = WebhookSubscription::create([ + 'event' => 'order.created', + 'target_url' => 'https://hooks.test/orders', + 'signing_secret_encrypted' => 'test-secret', + 'status' => 'active', + ]); + + expect($subscription->signing_secret_encrypted)->toBe('test-secret') + ->and($subscription->getHidden())->toContain('signing_secret_encrypted') + ->and(DB::table('webhook_subscriptions')->where('id', $subscription->getKey())->value('signing_secret_encrypted'))->not->toBe('test-secret'); + + (new WebhookService)->dispatch($store, 'order.created', ['order_id' => 1001]); + + Http::assertSent(function ($request): bool { + $timestamp = $request->header('X-Platform-Timestamp')[0]; + $body = $request->body(); + + return $request->header('Content-Type')[0] === 'application/json' + && $request->header('X-Platform-Event')[0] === 'order.created' + && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', $timestamp.'.'.$body, 'test-secret'); + }); + + expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered'); +}); diff --git a/tests/Feature/PlatformApiAndMediaTest.php b/tests/Feature/PlatformApiAndMediaTest.php new file mode 100644 index 00000000..243a5c5c --- /dev/null +++ b/tests/Feature/PlatformApiAndMediaTest.php @@ -0,0 +1,138 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + app()->instance('current_store', $this->store); +}); + +test('platform API creates organizations, stores, invitations, and membership responses', function (): void { + $token = $this->admin->createToken('platform-manager', ['manage-platform'])->plainTextToken; + + $organization = $this->withToken($token)->postJson('http://shop.test/api/admin/v1/platform/organizations', [ + 'name' => 'Northwind', + 'billing_email' => 'billing@northwind.test', + ])->assertCreated()->json('data'); + + $createdStore = $this->withToken($token)->postJson('http://shop.test/api/admin/v1/platform/stores', [ + 'organization_id' => $organization['id'], + 'name' => 'Northwind Shop', + 'handle' => 'northwind-shop', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ])->assertCreated()->json('data'); + + $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/invites", [ + 'email' => 'new-staff@example.test', + 'role' => 'staff', + ])->assertCreated()->assertJsonPath('data.role', 'staff'); + + $this->withToken($this->admin->createToken('membership-reader', ['read-products'])->plainTextToken) + ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/me") + ->assertOk() + ->assertJsonPath('data.store_id', $this->store->getKey()) + ->assertJsonPath('data.role', 'owner'); + + expect(Organization::query()->whereKey($organization['id'])->exists())->toBeTrue() + ->and(Store::query()->whereKey($createdStore['id'])->value('handle'))->toBe('northwind-shop') + ->and(StoreInvitation::query()->where('email', 'new-staff@example.test')->exists())->toBeTrue(); +}); + +test('product API persists nested options variants inventory and collections', function (): void { + $token = $this->admin->createToken('catalog-writer', ['write-products'])->plainTextToken; + $collection = \App\Models\Collection::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'title' => 'Nested Collection', 'handle' => 'nested-collection', 'type' => 'manual', 'status' => 'active']); + + $response = $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products", [ + 'title' => 'Nested Catalog Product', + 'status' => 'draft', + 'options' => [['name' => 'Color', 'position' => 1]], + 'variants' => [[ + 'sku' => 'NESTED-BLUE', + 'price_amount' => 2500, + 'currency' => 'EUR', + 'requires_shipping' => true, + 'is_default' => true, + 'option_values' => [['option_name' => 'Color', 'value' => 'Blue']], + 'inventory' => ['quantity_on_hand' => 50, 'policy' => 'deny'], + ]], + 'collections' => [$collection->getKey()], + ])->assertCreated(); + + $product = Product::withoutGlobalScopes()->where('title', 'Nested Catalog Product')->with(['options.values', 'variants.inventory', 'variants.optionValues', 'collections'])->firstOrFail(); + + expect($response->json('data.options.0.name'))->toBe('Color') + ->and($product->variants->first()->inventory->quantity_on_hand)->toBe(50) + ->and($product->variants->first()->optionValues->first()->value)->toBe('Blue') + ->and($product->collections->contains($collection))->toBeTrue(); + + $presigned = $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products/{$product->getKey()}/media/presign-upload", [ + 'filename' => 'product-image.jpg', + 'content_type' => 'image/jpeg', + 'byte_size' => 1200, + ])->assertCreated()->json(); + + expect($presigned['method'])->toBe('PUT')->and($presigned['media_id'])->toBeInt(); +}); + +test('media processing records metadata, dimensions, derivatives, and failure state', function (): void { + Storage::fake('public'); + $product = Product::query()->firstOrFail(); + $key = 'stores/'.$this->store->getKey().'/products/'.$product->getKey().'/media/source.jpg'; + $image = imagecreatetruecolor(640, 400); + ob_start(); + imagejpeg($image, null, 90); + $contents = ob_get_clean(); + imagedestroy($image); + Storage::disk('public')->put($key, $contents); + + $media = ProductMedia::create(['product_id' => $product->getKey(), 'type' => 'image', 'path' => $key, 'storage_key' => $key, 'mime_type' => 'image/jpeg', 'status' => 'processing']); + (new ProcessMediaUpload($media))->handle(); + + expect($media->refresh()->status)->toBe('ready') + ->and($media->width)->toBe(640) + ->and($media->height)->toBe(400) + ->and($media->metadata['variants'])->toHaveKeys(['original', 'thumbnail', 'medium', 'large']) + ->and($media->checksum)->toBe(hash('sha256', $contents)); + + $failed = ProductMedia::create(['product_id' => $product->getKey(), 'type' => 'image', 'path' => 'missing.jpg', 'status' => 'processing']); + $exception = new RuntimeException('missing upload'); + (new ProcessMediaUpload($failed))->failed($exception); + + expect($failed->refresh()->status)->toBe('failed') + ->and($failed->metadata['error'])->toBe('missing upload'); +}); + +test('developer webhook creation writes the normalized event type', function (): void { + $this->actingAs($this->admin); + + Livewire::test(DeveloperSettings::class) + ->set('event', 'order.created') + ->set('targetUrl', 'https://hooks.example.test/orders') + ->call('createWebhook') + ->assertHasNoErrors(); + + expect(WebhookSubscription::query()->where('target_url', 'https://hooks.example.test/orders')->value('event_type'))->toBe('order.created'); +}); + +test('deferred OAuth endpoints return not implemented responses', function (): void { + $this->postJson('http://shop.test/oauth/token')->assertStatus(501); +}); diff --git a/tests/Feature/SearchAnalyticsWebhookTest.php b/tests/Feature/SearchAnalyticsWebhookTest.php index 1df44515..18a0a467 100644 --- a/tests/Feature/SearchAnalyticsWebhookTest.php +++ b/tests/Feature/SearchAnalyticsWebhookTest.php @@ -64,7 +64,7 @@ test('webhooks are signed and delivered with platform headers', function (): void { Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]); - $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'secret_encrypted' => 'test-secret', 'status' => 'active']); + $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'test-secret', 'status' => 'active']); (new WebhookService)->dispatch($this->store, 'order.created', ['order_id' => 1001]); diff --git a/tests/Feature/SeedDataTest.php b/tests/Feature/SeedDataTest.php new file mode 100644 index 00000000..5d1a749f --- /dev/null +++ b/tests/Feature/SeedDataTest.php @@ -0,0 +1,95 @@ +forgetInstance('current_store'); +}); + +it('seeds the complete deterministic tenant fixture through DatabaseSeeder', function (): void { + $this->seed(DatabaseSeeder::class); + + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $fashionProducts = Product::withoutGlobalScopes()->where('store_id', $fashion->getKey()); + $electronicsProducts = Product::withoutGlobalScopes()->where('store_id', $electronics->getKey()); + $fashionCustomers = Customer::withoutGlobalScopes()->where('store_id', $fashion->getKey()); + $electronicsCustomers = Customer::withoutGlobalScopes()->where('store_id', $electronics->getKey()); + $fashionOrders = Order::withoutGlobalScopes()->where('store_id', $fashion->getKey()); + $electronicsOrders = Order::withoutGlobalScopes()->where('store_id', $electronics->getKey()); + + expect(Organization::query()->count())->toBe(1) + ->and(Store::query()->count())->toBe(2) + ->and(StoreDomain::query()->where('store_id', $fashion->getKey())->count())->toBe(2) + ->and(StoreDomain::query()->where('store_id', $electronics->getKey())->count())->toBe(1) + ->and($fashionProducts->count())->toBe(20) + ->and($electronicsProducts->count())->toBe(5) + ->and((clone $fashionProducts)->where('handle', 'limited-edition-sneakers')->exists())->toBeTrue() + ->and((clone $fashionProducts)->where('handle', 'sold-out-limited-tee')->exists())->toBeFalse() + ->and($fashionCustomers->count())->toBe(10) + ->and($electronicsCustomers->count())->toBe(2) + ->and($fashionOrders->count())->toBe(15) + ->and($electronicsOrders->count())->toBe(3) + ->and(Discount::withoutGlobalScopes()->where('store_id', $fashion->getKey())->count())->toBe(5); + + $classic = (clone $fashionProducts)->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + $pendingOrder = (clone $fashionOrders)->where('order_number', '#1005')->firstOrFail(); + $digitalOrder = (clone $fashionOrders)->where('order_number', '#1014')->firstOrFail(); + $discountOrder = (clone $fashionOrders)->where('order_number', '#1015')->firstOrFail(); + + expect($classic->variants()->count())->toBe(12) + ->and($classic->variants()->where('is_default', true)->count())->toBe(1) + ->and(InventoryItem::withoutGlobalScopes()->whereIn('variant_id', $classic->variants()->pluck('id'))->count())->toBe(12) + ->and($pendingOrder->financial_status->value)->toBe('pending') + ->and($pendingOrder->payments()->firstOrFail()->status->value)->toBe('pending') + ->and($digitalOrder->fulfillment_status->value)->toBe('fulfilled') + ->and($digitalOrder->lines()->firstOrFail()->variant->requires_shipping)->toBeFalse() + ->and($discountOrder->discount_amount)->toBe(550) + ->and($discountOrder->lines()->sum('line_discount_amount'))->toBe(550); + + $countsBeforeReseed = [ + 'stores' => Store::query()->count(), + 'products' => Product::withoutGlobalScopes()->count(), + 'customers' => Customer::withoutGlobalScopes()->count(), + 'orders' => Order::withoutGlobalScopes()->count(), + 'inventory' => InventoryItem::withoutGlobalScopes()->count(), + ]; + + $this->seed(DatabaseSeeder::class); + + expect([ + 'stores' => Store::query()->count(), + 'products' => Product::withoutGlobalScopes()->count(), + 'customers' => Customer::withoutGlobalScopes()->count(), + 'orders' => Order::withoutGlobalScopes()->count(), + 'inventory' => InventoryItem::withoutGlobalScopes()->count(), + ])->toBe($countsBeforeReseed); +}); + +it('keeps ShopSeeder legacy CommerceFlow compatibility isolated from DatabaseSeeder', function (): void { + $this->seed(ShopSeeder::class); + + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $products = Product::withoutGlobalScopes()->where('store_id', $fashion->getKey()); + $legacyProduct = (clone $products)->where('handle', 'sold-out-limited-tee')->firstOrFail(); + $classic = (clone $products)->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + $classicVariant = $classic->variants()->orderBy('position')->firstOrFail(); + + expect($products->count())->toBe(20) + ->and($products->where('handle', 'limited-edition-sneakers')->exists())->toBeFalse() + ->and($legacyProduct->title)->toBe('Sold Out Limited Tee') + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $legacyProduct->defaultVariant()->getKey())->value('quantity_on_hand'))->toBe(0) + ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $classicVariant->getKey())->value('quantity_on_hand'))->toBe(80) + ->and(StoreDomain::query()->where('hostname', 'shop.test')->where('store_id', $fashion->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Unit/DomainServicesTest.php b/tests/Unit/DomainServicesTest.php index 91fd730c..7e1ae047 100644 --- a/tests/Unit/DomainServicesTest.php +++ b/tests/Unit/DomainServicesTest.php @@ -79,7 +79,7 @@ $refund = app(RefundService::class)->create($order, $payment, [$line->getKey() => 1], 'Damaged item', true); - expect($refund->amount)->toBe($line->line_total_amount) + expect($refund->amount)->toBe(intdiv($line->line_total_amount, $line->quantity)) ->and($inventory->refresh()->quantity_on_hand)->toBe($before + 1) ->and($order->refresh()->financial_status->value)->toBe('partially_refunded'); }); From 86ddb27815e7dfcf737f1fe9b6a25336d0d75252 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 21 Aug 2026 03:15:40 +0200 Subject: [PATCH 7/9] Complete shop platform hardening and UX flows --- app/Events/CheckoutAddressed.php | 14 +++ app/Events/CheckoutCompleted.php | 15 +++ app/Events/CheckoutExpired.php | 14 +++ app/Events/CheckoutShippingSelected.php | 14 +++ app/Events/FulfillmentCreated.php | 14 +++ app/Events/ProductCreated.php | 14 +++ app/Events/ProductDeleted.php | 14 +++ app/Events/ProductUpdated.php | 14 +++ app/Exceptions/PaymentDeclinedException.php | 13 ++ app/Http/Controllers/Api/AdminController.php | 77 ++++++++++-- .../Controllers/Api/PlatformController.php | 7 ++ .../Api/StorefrontAnalyticsController.php | 21 +--- .../Api/StorefrontCartController.php | 1 + .../Api/StorefrontCheckoutController.php | 18 ++- .../Api/StorefrontOrderController.php | 4 +- .../Api/StorefrontSearchController.php | 49 +++++++- app/Http/Middleware/EnsureApiAbility.php | 11 +- app/Http/Middleware/ResolveStore.php | 12 +- .../Requests/CreateFulfillmentRequest.php | 6 +- .../Requests/CreateOrderExportRequest.php | 33 +++++ app/Http/Requests/CreateRefundRequest.php | 6 +- .../Requests/StoreAnalyticsEventsRequest.php | 63 ++++++++++ app/Http/Requests/StoreInvitationRequest.php | 2 +- .../Requests/StoreShippingRateRequest.php | 2 +- .../Requests/StoreShippingZoneRequest.php | 2 +- app/Http/Requests/StoreThemeRequest.php | 2 +- .../Requests/UpdateTaxSettingsRequest.php | 2 +- .../Requests/UpdateThemeSettingsRequest.php | 2 +- app/Jobs/AggregateAnalytics.php | 14 +-- app/Jobs/CancelUnpaidBankTransferOrders.php | 15 ++- app/Jobs/DeliverWebhook.php | 2 +- app/Jobs/ExpireAbandonedCheckouts.php | 2 + app/Jobs/GenerateOrderExport.php | 74 +++++++++++ app/Jobs/ProcessMediaUpload.php | 53 ++++++-- app/Listeners/DispatchWebhooks.php | 44 +++++++ app/Listeners/RecordAuthenticationEvent.php | 31 +++++ app/Livewire/Admin/Developers/Index.php | 6 +- app/Livewire/Storefront/CartDrawer.php | 115 ++++++++++++++++++ app/Livewire/Storefront/Collections/Show.php | 30 +++-- app/Livewire/Storefront/Products/Show.php | 10 ++ app/Livewire/Storefront/Search/Modal.php | 7 ++ app/Models/OrderExport.php | 26 ++++ app/Models/Payment.php | 6 +- app/Models/ThemeFile.php | 2 +- app/Models/User.php | 17 +++ app/Observers/ProductObserver.php | 6 + app/Providers/AppServiceProvider.php | 45 +++++++ app/Services/CheckoutService.php | 17 ++- app/Services/FulfillmentService.php | 2 + app/Services/MockPaymentProvider.php | 4 +- app/Services/OrderService.php | 15 ++- app/Services/PaymentService.php | 23 +++- app/Services/PricingEngine.php | 66 +++++++--- app/Services/SearchService.php | 27 ++-- app/Services/ShippingCalculator.php | 22 ++-- app/Services/TaxCalculator.php | 10 +- app/ValueObjects/PaymentResult.php | 2 +- bootstrap/app.php | 12 ++ config/cors.php | 2 +- config/session.php | 2 +- database/factories/OrderExportFactory.php | 26 ++++ database/factories/PaymentFactory.php | 2 +- database/factories/UserFactory.php | 1 + ...8_21_002127_create_order_exports_table.php | 37 ++++++ ...orm_admin_and_payment_raw_json_columns.php | 44 +++++++ database/seeders/AnalyticsSeeder.php | 9 +- database/seeders/NavigationSeeder.php | 19 ++- database/seeders/PageSeeder.php | 16 ++- database/seeders/SearchSettingsSeeder.php | 9 +- database/seeders/UserSeeder.php | 2 +- resources/views/layouts/admin.blade.php | 44 ++++++- resources/views/layouts/storefront.blade.php | 21 ++-- .../livewire/storefront/cart-drawer.blade.php | 9 +- .../storefront/collections/show.blade.php | 2 +- .../views/livewire/storefront/home.blade.php | 6 +- .../storefront/products/show.blade.php | 61 +++++++++- routes/api.php | 22 ++-- specs/progress.md | 10 +- tests/Feature/ApiTokenTest.php | 20 +++ tests/Feature/CommerceFlowTest.php | 27 +++- tests/Feature/ContractBehaviorTest.php | 3 +- .../OrderExportsAndDomainEventsTest.php | 50 ++++++++ tests/Feature/PlatformApiAndMediaTest.php | 2 +- tests/Feature/SearchAnalyticsWebhookTest.php | 17 ++- tests/Feature/Tenancy/ResolveStoreTest.php | 10 ++ 85 files changed, 1445 insertions(+), 179 deletions(-) create mode 100644 app/Events/CheckoutAddressed.php create mode 100644 app/Events/CheckoutCompleted.php create mode 100644 app/Events/CheckoutExpired.php create mode 100644 app/Events/CheckoutShippingSelected.php create mode 100644 app/Events/FulfillmentCreated.php 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/Exceptions/PaymentDeclinedException.php create mode 100644 app/Http/Requests/CreateOrderExportRequest.php create mode 100644 app/Http/Requests/StoreAnalyticsEventsRequest.php create mode 100644 app/Jobs/GenerateOrderExport.php create mode 100644 app/Listeners/DispatchWebhooks.php create mode 100644 app/Listeners/RecordAuthenticationEvent.php create mode 100644 app/Models/OrderExport.php create mode 100644 database/factories/OrderExportFactory.php create mode 100644 database/migrations/2026_08_21_002127_create_order_exports_table.php create mode 100644 database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php create mode 100644 tests/Feature/OrderExportsAndDomainEventsTest.php diff --git a/app/Events/CheckoutAddressed.php b/app/Events/CheckoutAddressed.php new file mode 100644 index 00000000..4238163b --- /dev/null +++ b/app/Events/CheckoutAddressed.php @@ -0,0 +1,14 @@ +where('store_id', $storeId)->findOrFail($zoneId); $data = $request->validated(); - return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'is_active' => $data['is_active'] ?? true])], 201); + $config = $data['config_json']; + $price = (int) ($data['price_amount'] ?? $config['price_amount'] ?? 0); + $currency = $data['currency'] ?? $config['currency'] ?? app('current_store')->default_currency; + + return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'price_amount' => $price, 'currency' => $currency, 'config_json' => $config, 'is_active' => $data['is_active'] ?? true])], 201); } public function taxSettings(int $storeId): JsonResponse @@ -255,7 +265,7 @@ public function updateTaxSettings(UpdateTaxSettingsRequest $request, int $storeI { $this->assertStore($storeId); $data = $request->validated(); - $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data); + $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], [...$data, 'provider_config_json' => $data['config_json']]); return response()->json(['data' => $settings]); } @@ -301,8 +311,33 @@ public function storeTheme(StoreThemeRequest $request, int $storeId): JsonRespon { $this->assertStore($storeId); $data = $request->validated(); - $theme = Theme::withoutGlobalScopes()->create(['store_id' => $storeId, 'name' => $data['name'], 'version' => $data['version'] ?? null, 'status' => 'draft']); - $theme->settings()->create(['settings_json' => $data['settings'] ?? []]); + /** @var UploadedFile $file */ + $file = $request->file('file'); + $archive = new \ZipArchive; + abort_unless($archive->open($file->getRealPath()) === true, 422, 'The theme archive is invalid.'); + $manifest = []; + $paths = []; + + for ($index = 0; $index < $archive->numFiles; $index++) { + $path = $archive->getNameIndex($index); + abort_if($path === false || str_contains($path, '..') || str_starts_with($path, '/'), 422, 'The theme archive contains an invalid path.'); + if (! str_ends_with($path, '/')) { + $paths[] = $path; + } + if ($path === 'theme.json') { + $manifest = json_decode((string) $archive->getFromIndex($index), true) ?: []; + } + } + abort_if($paths === [], 422, 'The theme archive is empty.'); + abort_if($manifest === [] || ! is_string($manifest['name'] ?? null) || ! is_string($manifest['version'] ?? null), 422, 'The theme archive has an invalid manifest.'); + abort_if(! collect($paths)->contains(fn (string $path): bool => str_starts_with($path, 'templates/') || str_ends_with($path, '.blade.php')), 422, 'The theme archive is missing storefront templates.'); + $theme = Theme::withoutGlobalScopes()->create(['store_id' => $storeId, 'name' => $data['name'] ?? ($manifest['name'] ?? 'Uploaded theme'), 'version' => $manifest['version'] ?? '1.0.0', 'status' => 'draft']); + foreach ($paths as $path) { + $contents = $archive->getFromName($path); + $theme->files()->create(['path' => $path, 'storage_key' => 'themes/'.$theme->getKey().'/'.$path, 'sha256' => hash('sha256', (string) $contents), 'byte_size' => strlen((string) $contents), 'content' => $contents]); + } + $archive->close(); + $theme->settings()->create(['settings_json' => []]); return response()->json(['data' => $theme->load('settings')], 201); } @@ -312,7 +347,7 @@ public function publishTheme(int $storeId, int $themeId): JsonResponse $this->assertStore($storeId); $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); Theme::withoutGlobalScopes()->where('store_id', $storeId)->update(['status' => 'draft']); - $theme->update(['status' => 'published']); + $theme->update(['status' => 'published', 'published_at' => now()]); return response()->json(['data' => $theme->refresh()]); } @@ -321,7 +356,7 @@ public function updateThemeSettings(UpdateThemeSettingsRequest $request, int $st { $this->assertStore($storeId); $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); - $theme->settings()->updateOrCreate(['theme_id' => $theme->getKey()], ['settings_json' => $request->validated()['settings']]); + $theme->settings()->updateOrCreate(['theme_id' => $theme->getKey()], ['settings_json' => $request->validated()['settings_json']]); return response()->json(['data' => $theme->refresh()->load('settings')]); } @@ -344,9 +379,35 @@ public function searchStatus(int $storeId): JsonResponse public function analyticsSummary(Request $request, int $storeId): JsonResponse { $this->assertStore($storeId); - $days = \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $storeId)->whereBetween('date', [$request->input('from', now()->subDays(29)->toDateString()), $request->input('to', now()->toDateString())])->get(); + $data = $request->validate(['from' => ['required', 'date_format:Y-m-d'], 'to' => ['required', 'date_format:Y-m-d', 'after_or_equal:from'], 'granularity' => ['sometimes', 'in:day,week,month']]); + $from = CarbonImmutable::createFromFormat('Y-m-d', $data['from'])->startOfDay(); + $to = CarbonImmutable::createFromFormat('Y-m-d', $data['to'])->endOfDay(); + abort_if($from->diffInDays($to) > 365, 422, 'The analytics range may not exceed 365 days.'); + $days = \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $storeId)->whereBetween('date', [$from->toDateString(), $to->toDateString()])->orderBy('date')->get(); + $orders = (int) $days->sum('orders_count'); + $revenue = (int) $days->sum('revenue_amount'); + $visits = (int) $days->sum('visits_count'); + $topProducts = DB::table('order_lines')->join('orders', 'orders.id', '=', 'order_lines.order_id')->where('orders.store_id', $storeId)->whereBetween('orders.placed_at', [$from, $to])->whereIn('orders.financial_status', ['paid', 'partially_refunded'])->select('order_lines.product_id', 'order_lines.product_title as title')->selectRaw('sum(order_lines.quantity) as units_sold')->selectRaw('sum(order_lines.line_total_amount) as revenue_amount')->groupBy('order_lines.product_id', 'order_lines.product_title')->orderByDesc('units_sold')->limit(10)->get(); + + return response()->json(['data' => ['period' => ['from' => $from->toDateString(), 'to' => $to->toDateString()], 'summary' => ['orders_count' => $orders, 'revenue_amount' => $revenue, 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, 'visits_count' => $visits, 'add_to_cart_count' => (int) $days->sum('add_to_cart_count'), 'checkout_started_count' => (int) $days->sum('checkout_started_count'), 'conversion_rate' => $visits > 0 ? round($orders / $visits, 4) : 0, 'currency' => app('current_store')->default_currency], 'daily' => $days, 'top_products' => $topProducts]]); + } + + public function createOrderExport(CreateOrderExportRequest $request, int $storeId): JsonResponse + { + $this->assertStore($storeId); + $data = $request->validated(); + $export = OrderExport::withoutGlobalScopes()->create(['store_id' => $storeId, 'format' => $data['format'] ?? 'csv', 'filters_json' => $data['filters'] ?? [], 'status' => 'queued']); + GenerateOrderExport::dispatch($export); + + return response()->json(['export_id' => $export->getKey(), 'status' => 'queued', 'created_at' => $export->created_at], 202); + } + + public function showOrderExport(int $storeId, int $exportId): JsonResponse + { + $this->assertStore($storeId); + $export = OrderExport::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($exportId); - return response()->json(['data' => ['visits' => (int) $days->sum('visits_count'), 'orders' => (int) $days->sum('orders_count'), 'revenue_amount' => (int) $days->sum('revenue_amount'), 'checkout_completed' => (int) $days->sum('checkout_completed_count'), 'days' => $days]]); + return response()->json(['data' => ['id' => $export->getKey(), 'status' => $export->status, 'format' => $export->format, 'row_count' => $export->row_count, 'download_url' => $export->download_url, 'download_expires_at' => $export->download_expires_at, 'created_at' => $export->created_at, 'completed_at' => $export->completed_at, 'error_message' => $export->error_message]]); } private function assertStore(int $storeId): void diff --git a/app/Http/Controllers/Api/PlatformController.php b/app/Http/Controllers/Api/PlatformController.php index 6ae57c23..6833039d 100644 --- a/app/Http/Controllers/Api/PlatformController.php +++ b/app/Http/Controllers/Api/PlatformController.php @@ -23,6 +23,7 @@ class PlatformController extends Controller { public function storeOrganization(CreateOrganizationRequest $request): JsonResponse { + $this->assertPlatformAdministrator(); $data = $request->validated(); $slug = Str::slug($data['name']); $suffix = 1; @@ -35,6 +36,7 @@ public function storeOrganization(CreateOrganizationRequest $request): JsonRespo public function storeStore(CreatePlatformStoreRequest $request): JsonResponse { + $this->assertPlatformAdministrator(); $data = $request->validated(); $store = Store::create([...$data, 'status' => 'active']); $store->users()->syncWithoutDetaching([ @@ -134,6 +136,11 @@ private function store(int $storeId): Store return Store::query()->findOrFail($storeId); } + private function assertPlatformAdministrator(): void + { + abort_unless(request()->user('sanctum')?->isPlatformAdmin(), 403, 'Platform administrator access is required.'); + } + /** @return array */ private function permissionsFor(string $role): array { diff --git a/app/Http/Controllers/Api/StorefrontAnalyticsController.php b/app/Http/Controllers/Api/StorefrontAnalyticsController.php index da64edfd..470e34e6 100644 --- a/app/Http/Controllers/Api/StorefrontAnalyticsController.php +++ b/app/Http/Controllers/Api/StorefrontAnalyticsController.php @@ -3,35 +3,22 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreAnalyticsEventsRequest; use App\Services\AnalyticsService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; class StorefrontAnalyticsController extends Controller { public function __construct(private readonly AnalyticsService $analytics) {} - public function store(Request $request): JsonResponse + public function store(StoreAnalyticsEventsRequest $request): JsonResponse { - $data = $request->validate([ - 'events' => ['sometimes', 'array', 'min:1', 'max:100'], - 'events.*.type' => ['required_with:events', 'string'], - 'events.*.properties' => ['nullable', 'array'], - 'events.*.session_id' => ['nullable', 'string', 'max:255'], - 'events.*.client_event_id' => ['nullable', 'string', 'max:255'], - 'events.*.occurred_at' => ['nullable', 'date'], - 'type' => ['required_without:events', 'string'], - 'properties' => ['nullable', 'array'], - 'session_id' => ['nullable', 'string', 'max:255'], - 'client_event_id' => ['nullable', 'string', 'max:255'], - 'occurred_at' => ['nullable', 'date'], - ]); - $events = $data['events'] ?? [$data]; + $events = $request->validated()['events']; $stored = []; foreach ($events as $event) { $stored[] = $this->analytics->track(app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'] ?? ($request->hasSession() ? $request->session()->getId() : null), $request->user('customer')?->getKey(), $event['client_event_id'] ?? null, isset($event['occurred_at']) ? new \DateTimeImmutable($event['occurred_at']) : null); } - return response()->json(['ids' => collect($stored)->map->getKey()->all(), 'accepted' => count($stored)], 202); + return response()->json(['accepted' => count($stored), 'rejected' => 0], 202); } } diff --git a/app/Http/Controllers/Api/StorefrontCartController.php b/app/Http/Controllers/Api/StorefrontCartController.php index 44e7eccd..1a5c53bb 100644 --- a/app/Http/Controllers/Api/StorefrontCartController.php +++ b/app/Http/Controllers/Api/StorefrontCartController.php @@ -86,6 +86,7 @@ private function cart(int $cartId): Cart { $cart = Cart::query() ->where('store_id', app('current_store')->getKey()) + ->where('status', 'active') ->with(['lines.variant.product.media', 'lines.variant.inventory']) ->findOrFail($cartId); $customerId = request()->user('customer')?->getKey(); diff --git a/app/Http/Controllers/Api/StorefrontCheckoutController.php b/app/Http/Controllers/Api/StorefrontCheckoutController.php index 7e7707ac..0514aa9f 100644 --- a/app/Http/Controllers/Api/StorefrontCheckoutController.php +++ b/app/Http/Controllers/Api/StorefrontCheckoutController.php @@ -5,6 +5,7 @@ use App\Enums\PaymentMethod; use App\Exceptions\InsufficientInventoryException; use App\Exceptions\InvalidDiscountException; +use App\Exceptions\PaymentDeclinedException; use App\Http\Controllers\Controller; use App\Http\Requests\ApplyDiscountRequest; use App\Http\Requests\SetCheckoutAddressRequest; @@ -25,7 +26,7 @@ public function __construct(private readonly CheckoutService $checkouts, private public function store(Request $request): JsonResponse { $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]); - $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']); + $cart = Cart::query()->where('status', 'active')->with('lines')->findOrFail($data['cart_id']); $customerId = $request->user('customer')?->getKey(); if ($customerId !== null) { abort_unless((int) $cart->customer_id === (int) $customerId, 404); @@ -58,7 +59,9 @@ public function address(SetCheckoutAddressRequest $request, int $checkoutId): Js try { $checkout = $this->checkouts->setAddress($checkout, $data['shipping_address'] ?? [], $data['billing_address'] ?? null, $useShippingAsBilling); } catch (\LogicException $exception) { - return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); + return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 409); + } catch (PaymentDeclinedException $exception) { + return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422); } return response()->json($this->payload($checkout)); @@ -114,15 +117,20 @@ public function pay(Request $request, int $checkoutId): JsonResponse $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data); } catch (InsufficientInventoryException $exception) { return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422); + } catch (PaymentDeclinedException $exception) { + return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422); } catch (\LogicException $exception) { return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422); } - if ($order === null) { - return response()->json(['message' => 'Payment failed.', 'code' => 'payment_failed'], 422); + $order = $order->load('checkout'); + $payload = ['checkout_id' => $order->checkout_id, 'status' => 'completed', 'order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'payment_method' => $order->payment_method, 'total_amount' => $order->total_amount, 'currency' => $order->currency]]; + + if ($order->financial_status->value === 'pending') { + $payload['bank_transfer_instructions'] = ['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]; } - return response()->json(['order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'total_amount' => $order->total_amount], 'message' => $order->financial_status->value === 'pending' ? 'Bank transfer instructions generated.' : 'Order confirmed.']); + return response()->json($payload); } public function paymentMethod(Request $request, int $checkoutId): JsonResponse diff --git a/app/Http/Controllers/Api/StorefrontOrderController.php b/app/Http/Controllers/Api/StorefrontOrderController.php index df7f874b..242cf759 100644 --- a/app/Http/Controllers/Api/StorefrontOrderController.php +++ b/app/Http/Controllers/Api/StorefrontOrderController.php @@ -15,8 +15,8 @@ public function show(Request $request, string $orderNumber): JsonResponse $customerId = $request->user('customer')?->getKey(); $token = (string) $request->query('token', ''); $expected = hash_hmac('sha256', $order->order_number, (string) config('app.key')); - abort_unless(($customerId !== null && (int) $order->customer_id === (int) $customerId) || ($token !== '' && hash_equals($expected, $token)), 404); + abort_unless(($customerId !== null && (int) $order->customer_id === (int) $customerId) || ($token !== '' && hash_equals($expected, $token)), 401, 'A valid order access token is required.'); - return response()->json(['data' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'fulfillment_status' => $order->fulfillment_status, 'currency' => $order->currency, 'email' => $order->email, '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, 'lines' => $order->lines->map(fn ($line): array => ['id' => $line->id, 'title' => $line->title_snapshot, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'total_amount' => $line->line_total_amount])->all(), 'payments' => $order->payments->map(fn ($payment): array => ['method' => $payment->method, 'status' => $payment->status, 'amount' => $payment->amount])->all(), 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => ['id' => $fulfillment->id, 'status' => $fulfillment->status, 'tracking_number' => $fulfillment->tracking_number])->all()]]); + return response()->json(['order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'fulfillment_status' => $order->fulfillment_status, 'email' => $order->email, 'currency' => $order->currency, 'placed_at' => $order->placed_at, 'lines' => $order->lines->map(fn ($line): array => ['title_snapshot' => $line->title_snapshot, 'variant_title' => $line->variant_title, 'sku_snapshot' => $line->sku_snapshot, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'total_amount' => $line->line_total_amount])->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, 'billing_address' => $order->billing_address_json, 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => ['id' => $fulfillment->id, 'status' => $fulfillment->status, 'tracking_company' => $fulfillment->tracking_company, 'tracking_number' => $fulfillment->tracking_number, 'tracking_url' => $fulfillment->tracking_url])->all()]); } } diff --git a/app/Http/Controllers/Api/StorefrontSearchController.php b/app/Http/Controllers/Api/StorefrontSearchController.php index dc3ec092..90e3ec5c 100644 --- a/app/Http/Controllers/Api/StorefrontSearchController.php +++ b/app/Http/Controllers/Api/StorefrontSearchController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Collection; use App\Services\SearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -13,17 +14,53 @@ public function __construct(private readonly SearchService $search) {} public function index(Request $request): JsonResponse { - $data = $request->validate(['q' => ['nullable', 'string', 'max:200'], 'query' => ['nullable', 'string', 'max:200'], 'vendor' => ['nullable', 'string'], 'min_price' => ['nullable', 'integer', 'min:0'], 'max_price' => ['nullable', 'integer', 'min:0'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:50']]); - $query = $data['q'] ?? $data['query'] ?? ''; - $results = $this->search->search(app('current_store'), $query, array_filter(['vendor' => $data['vendor'] ?? null, 'min_price' => $data['min_price'] ?? null, 'max_price' => $data['max_price'] ?? null], fn ($value): bool => $value !== null), $data['per_page'] ?? 12); + $data = $request->validate([ + 'q' => ['required', 'string', 'min:1', 'max:200'], + 'filters' => ['nullable', 'json'], + 'sort' => ['nullable', 'in:relevance,price_asc,price_desc,newest,best_selling'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + $filters = ($data['filters'] ?? null) === null ? [] : json_decode($data['filters'], true, 512, JSON_THROW_ON_ERROR); + abort_unless(is_array($filters) && ($filters === [] || ! array_is_list($filters)), 422, 'The filters parameter must be a JSON object.'); + $results = $this->search->search(app('current_store'), $data['q'], $filters, $data['per_page'] ?? 24, $data['page'] ?? 1, $data['sort'] ?? 'relevance'); + $items = collect($results->items()); + $prices = $items->map(fn ($product): int => (int) ($product->defaultVariant()?->price_amount ?? 0))->filter(); + $vendors = $items->pluck('vendor')->filter()->countBy()->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count])->values(); + $tags = $items->flatMap(fn ($product): array => $product->tags ?? [])->countBy()->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count])->values(); - return response()->json(['data' => collect($results->items())->map(fn ($product): array => ['id' => $product->id, 'title' => $product->title, 'handle' => $product->handle, 'vendor' => $product->vendor, 'price_amount' => $product->defaultVariant()?->price_amount, 'image_url' => $product->media->first()?->url])->values()->all(), 'meta' => ['query' => $query, 'current_page' => $results->currentPage(), 'per_page' => $results->perPage(), 'total' => $results->total(), 'last_page' => $results->lastPage()]]); + return response()->json([ + 'query' => $data['q'], + 'results' => $items->map(fn ($product): array => [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => $product->defaultVariant()?->price_amount, + 'compare_at_amount' => $product->defaultVariant()?->compare_at_amount, + 'currency' => $product->defaultVariant()?->currency, + 'image_url' => $product->media->first()?->url, + 'in_stock' => $product->variants->contains(fn ($variant): bool => $variant->availableQuantity() > 0 || $variant->inventory?->policy?->value === 'continue'), + 'tags' => $product->tags ?? [], + ])->values()->all(), + 'facets' => ['vendors' => $vendors, 'tags' => $tags, 'price_range' => ['min' => $prices->min(), 'max' => $prices->max()]], + 'pagination' => ['current_page' => $results->currentPage(), 'per_page' => $results->perPage(), 'total' => $results->total(), 'last_page' => $results->lastPage()], + ]); } public function suggest(Request $request): JsonResponse { - $data = $request->validate(['q' => ['required', 'string', 'min:2', 'max:80'], 'limit' => ['nullable', 'integer', 'min:1', 'max:10']]); + $data = $request->validate(['q' => ['required', 'string', 'min:1', 'max:100'], 'limit' => ['nullable', 'integer', 'min:1', 'max:10']]); + $store = app('current_store'); + $limit = $data['limit'] ?? 5; + $products = $this->search->autocomplete($store, $data['q'], $limit); + $remaining = max(0, $limit - $products->count()); + $collections = $remaining === 0 ? collect() : Collection::query()->where('status', 'active')->where('title', 'like', trim($data['q']).'%')->with('products.media')->orderBy('title')->limit($remaining)->get(); - return response()->json(['data' => $this->search->autocomplete(app('current_store'), $data['q'], $data['limit'] ?? 8)->map(fn ($product): array => ['id' => $product->id, 'title' => $product->title, 'handle' => $product->handle])->values()->all()]); + return response()->json(['query' => $data['q'], 'suggestions' => [ + ...$products->map(fn ($product): array => ['type' => 'product', 'title' => $product->title, 'handle' => $product->handle, 'image_url' => $product->media->first()?->url, 'price_amount' => $product->defaultVariant()?->price_amount, 'currency' => $product->defaultVariant()?->currency])->all(), + ...$collections->map(fn (Collection $collection): array => ['type' => 'collection', 'title' => $collection->title, 'handle' => $collection->handle, 'image_url' => $collection->image_url])->all(), + ]]); } } diff --git a/app/Http/Middleware/EnsureApiAbility.php b/app/Http/Middleware/EnsureApiAbility.php index adc88196..ae10d8d9 100644 --- a/app/Http/Middleware/EnsureApiAbility.php +++ b/app/Http/Middleware/EnsureApiAbility.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\User; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -15,19 +16,21 @@ public function handle(Request $request, Closure $next): Response abort_unless($request->bearerToken() !== null && $user !== null, 401, 'A Sanctum bearer token is required.'); - $ability = $this->abilityFor($request); + $ability = $this->abilityFor($request, $user); abort_unless($ability !== null && $user->tokenCan($ability), 403, 'This token does not have the required ability.'); return $next($request); } - private function abilityFor(Request $request): ?string + private function abilityFor(Request $request, User $user): ?string { $path = $request->path(); $method = $request->method(); if (Str::contains($path, '/platform/')) { + abort_unless($user->isPlatformAdmin(), 403, 'Platform administration is restricted to platform administrators.'); + return 'manage-platform'; } @@ -35,6 +38,10 @@ private function abilityFor(Request $request): ?string return 'manage-platform'; } + if (Str::contains($path, '/exports/')) { + return 'read-orders'; + } + foreach (['products' => 'products', 'collections' => 'collections', 'orders' => 'orders', 'customers' => 'customers', 'discounts' => 'discounts'] as $segment => $resource) { if (Str::contains($path, '/'.$segment)) { return in_array($method, ['GET', 'HEAD'], true) ? 'read-'.$resource : 'write-'.$resource; diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php index 66db1074..8d421ce3 100644 --- a/app/Http/Middleware/ResolveStore.php +++ b/app/Http/Middleware/ResolveStore.php @@ -26,6 +26,10 @@ public function handle(Request $request, Closure $next, string $context = 'store return $next($request); } + if ($this->isAdminRequest($request) && $request->user('web') === null && $request->user('sanctum') === null) { + return $next($request); + } + if ($this->isAdminApiRequest($request) && $request->user('sanctum') === null) { return $next($request); } @@ -107,7 +111,7 @@ private function isAdminRequest(Request $request): bool return true; } - return $request->is('livewire/update') && str_contains((string) $request->headers->get('referer'), '/admin'); + return $request->is('livewire/update', 'livewire-*/update') && str_contains((string) $request->headers->get('referer'), '/admin'); } private function isAdminApiRequest(Request $request): bool @@ -124,6 +128,10 @@ private function isPublicCustomerAuthRequest(Request $request): bool private function isPublicAdminAuthRequest(Request $request): bool { - return $request->is('admin/login', 'admin/forgot-password', 'admin/reset-password/*'); + $path = $request->is('livewire/update', 'livewire-*/update') + ? trim((string) parse_url((string) $request->headers->get('referer'), PHP_URL_PATH), '/') + : trim($request->path(), '/'); + + return Str::is(['admin/login', 'admin/forgot-password', 'admin/reset-password/*'], $path); } } diff --git a/app/Http/Requests/CreateFulfillmentRequest.php b/app/Http/Requests/CreateFulfillmentRequest.php index d4ab90da..ab2f6ef5 100644 --- a/app/Http/Requests/CreateFulfillmentRequest.php +++ b/app/Http/Requests/CreateFulfillmentRequest.php @@ -13,7 +13,11 @@ class CreateFulfillmentRequest extends FormRequest */ public function authorize(): bool { - return Gate::allows('viewAny', Order::class); + $order = Order::withoutGlobalScopes() + ->where('store_id', app('current_store')->getKey()) + ->find($this->route('orderId')); + + return $order instanceof Order && Gate::allows('createFulfillment', $order); } /** diff --git a/app/Http/Requests/CreateOrderExportRequest.php b/app/Http/Requests/CreateOrderExportRequest.php new file mode 100644 index 00000000..523256ec --- /dev/null +++ b/app/Http/Requests/CreateOrderExportRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'format' => ['sometimes', 'in:csv'], + 'filters' => ['nullable', 'array'], + 'filters.status' => ['nullable', 'string'], + 'filters.financial_status' => ['nullable', 'string'], + 'filters.created_after' => ['nullable', 'date'], + 'filters.created_before' => ['nullable', 'date', 'after_or_equal:filters.created_after'], + ]; + } +} diff --git a/app/Http/Requests/CreateRefundRequest.php b/app/Http/Requests/CreateRefundRequest.php index b457b028..7f9037a1 100644 --- a/app/Http/Requests/CreateRefundRequest.php +++ b/app/Http/Requests/CreateRefundRequest.php @@ -13,7 +13,11 @@ class CreateRefundRequest extends FormRequest */ public function authorize(): bool { - return Gate::allows('viewAny', Order::class); + $order = Order::withoutGlobalScopes() + ->where('store_id', app('current_store')->getKey()) + ->find($this->route('orderId')); + + return $order instanceof Order && Gate::allows('createRefund', $order); } /** diff --git a/app/Http/Requests/StoreAnalyticsEventsRequest.php b/app/Http/Requests/StoreAnalyticsEventsRequest.php new file mode 100644 index 00000000..525585f5 --- /dev/null +++ b/app/Http/Requests/StoreAnalyticsEventsRequest.php @@ -0,0 +1,63 @@ +|string> + */ + public function rules(): array + { + return [ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', 'in:page_view,product_view,add_to_cart,remove_from_cart,checkout_started,checkout_completed,search'], + 'events.*.session_id' => ['required', 'string', 'max:100'], + 'events.*.client_event_id' => ['required', 'string', 'max:100'], + 'events.*.properties' => ['nullable', 'array'], + 'events.*.occurred_at' => ['required', 'date'], + ]; + } + + protected function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + foreach ($this->input('events', []) as $index => $event) { + try { + $occurredAt = new \DateTimeImmutable((string) ($event['occurred_at'] ?? '')); + if (abs(now()->getTimestamp() - $occurredAt->getTimestamp()) > 3600) { + $validator->errors()->add("events.$index.occurred_at", 'The event timestamp must be within one hour of the current time.'); + } + } catch (\Throwable) { + // The date rule reports malformed timestamps. + } + + if ($this->jsonDepth($event['properties'] ?? []) > 3) { + $validator->errors()->add("events.$index.properties", 'Event properties may not be deeper than three levels.'); + } + } + }); + } + + private function jsonDepth(mixed $value): int + { + if (! is_array($value) || $value === []) { + return 1; + } + + return 1 + max(array_map(fn (mixed $item): int => $this->jsonDepth($item), $value)); + } +} diff --git a/app/Http/Requests/StoreInvitationRequest.php b/app/Http/Requests/StoreInvitationRequest.php index 3ac4c043..1fb1d202 100644 --- a/app/Http/Requests/StoreInvitationRequest.php +++ b/app/Http/Requests/StoreInvitationRequest.php @@ -23,7 +23,7 @@ public function rules(): array { return [ 'email' => ['required', 'email', 'max:255'], - 'role' => ['required', 'in:owner,admin,staff,support'], + 'role' => ['required', 'in:admin,staff,support'], ]; } } diff --git a/app/Http/Requests/StoreShippingRateRequest.php b/app/Http/Requests/StoreShippingRateRequest.php index fa248d27..7fd12673 100644 --- a/app/Http/Requests/StoreShippingRateRequest.php +++ b/app/Http/Requests/StoreShippingRateRequest.php @@ -22,6 +22,6 @@ public function authorize(): bool */ public function rules(): array { - return ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['required', 'integer', 'min:0'], 'currency' => ['required', 'size:3'], 'config_json' => ['nullable', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']]; + return ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['nullable', 'integer', 'min:0'], 'currency' => ['nullable', 'size:3', 'uppercase'], 'config_json' => ['required', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']]; } } diff --git a/app/Http/Requests/StoreShippingZoneRequest.php b/app/Http/Requests/StoreShippingZoneRequest.php index b526fd3c..4f27f4eb 100644 --- a/app/Http/Requests/StoreShippingZoneRequest.php +++ b/app/Http/Requests/StoreShippingZoneRequest.php @@ -22,6 +22,6 @@ public function authorize(): bool */ public function rules(): array { - return ['name' => ['required', 'string', 'max:255'], 'countries_json' => ['nullable', 'array'], 'regions_json' => ['nullable', 'array']]; + return ['name' => ['required', 'string', 'max:255'], 'countries_json' => ['required', 'array', 'min:1'], 'countries_json.*' => ['required', 'string', 'size:2', 'uppercase'], 'regions_json' => ['nullable', 'array'], 'regions_json.*' => ['string', 'max:20']]; } } diff --git a/app/Http/Requests/StoreThemeRequest.php b/app/Http/Requests/StoreThemeRequest.php index 25fc3f57..df1cfa3c 100644 --- a/app/Http/Requests/StoreThemeRequest.php +++ b/app/Http/Requests/StoreThemeRequest.php @@ -22,6 +22,6 @@ public function authorize(): bool */ public function rules(): array { - return ['name' => ['required', 'string', 'max:255'], 'version' => ['nullable', 'string', 'max:30'], 'settings' => ['nullable', 'array']]; + return ['file' => ['required', 'file', 'mimes:zip', 'max:51200'], 'name' => ['nullable', 'string', 'max:255']]; } } diff --git a/app/Http/Requests/UpdateTaxSettingsRequest.php b/app/Http/Requests/UpdateTaxSettingsRequest.php index 53280e03..8ff77dbd 100644 --- a/app/Http/Requests/UpdateTaxSettingsRequest.php +++ b/app/Http/Requests/UpdateTaxSettingsRequest.php @@ -22,6 +22,6 @@ public function authorize(): bool */ public function rules(): array { - return ['mode' => ['sometimes', 'string'], 'provider' => ['sometimes', 'nullable', 'string'], 'prices_include_tax' => ['sometimes', 'boolean'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']]; + return ['mode' => ['required', 'in:manual,provider'], 'provider' => ['required_if:mode,provider', 'nullable', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']]; } } diff --git a/app/Http/Requests/UpdateThemeSettingsRequest.php b/app/Http/Requests/UpdateThemeSettingsRequest.php index 99d8162e..04f6f0e5 100644 --- a/app/Http/Requests/UpdateThemeSettingsRequest.php +++ b/app/Http/Requests/UpdateThemeSettingsRequest.php @@ -22,6 +22,6 @@ public function authorize(): bool */ public function rules(): array { - return ['settings' => ['required', 'array']]; + return ['settings_json' => ['required', 'array']]; } } diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php index abb095f8..c8e70363 100644 --- a/app/Jobs/AggregateAnalytics.php +++ b/app/Jobs/AggregateAnalytics.php @@ -4,7 +4,6 @@ use App\Models\AnalyticsDaily; use App\Models\AnalyticsEvent; -use App\Models\Order; use App\Models\Store; use Carbon\CarbonImmutable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -24,17 +23,18 @@ public function handle(): void $stores = $this->store === null ? Store::query()->get() : collect([$this->store]); foreach ($stores as $store) { - $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('created_at', $date)->get(); - $orders = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('placed_at', $date)->whereIn('financial_status', ['paid', 'partially_refunded'])->get(); - $revenue = (int) $orders->sum('total_amount'); + $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('occurred_at', $date)->get(); + $completed = $events->where('type', 'checkout_completed'); + $ordersCount = $completed->count(); + $revenue = (int) $completed->sum(fn (AnalyticsEvent $event): int => (int) ($event->properties_json['total_amount'] ?? $event->properties_json['order_total_amount'] ?? 0)); AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert( ['store_id' => $store->getKey(), 'date' => $date->toDateString()], [ - 'orders_count' => $orders->count(), + 'orders_count' => $ordersCount, 'revenue_amount' => $revenue, - 'aov_amount' => $orders->count() > 0 ? intdiv($revenue, $orders->count()) : 0, - 'visits_count' => $events->where('type', 'page_view')->count(), + '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' => $events->where('type', 'checkout_completed')->count(), diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php index 875f9e30..73fdf730 100644 --- a/app/Jobs/CancelUnpaidBankTransferOrders.php +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -4,6 +4,7 @@ use App\Enums\FinancialStatus; use App\Models\Order; +use App\Models\StoreSettings; use App\Services\OrderService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -16,7 +17,17 @@ class CancelUnpaidBankTransferOrders implements ShouldQueue public function handle(OrderService $orders): void { - $days = (int) config('shop.bank_transfer_expiry_days', 7); - Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.')); + Order::withoutGlobalScopes() + ->where('payment_method', 'bank_transfer') + ->where('financial_status', FinancialStatus::Pending) + ->with('lines.variant.inventory') + ->each(function (Order $order) use ($orders): void { + $settings = StoreSettings::withoutGlobalScopes()->find($order->store_id); + $days = (int) ($settings?->settings_json['bank_transfer_cancel_days'] ?? config('shop.bank_transfer_expiry_days', 7)); + + if ($order->placed_at?->isBefore(now()->subDays($days))) { + $orders->cancel($order, 'Bank transfer payment expired.'); + } + }); } } diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php index e18ec779..ab38d644 100644 --- a/app/Jobs/DeliverWebhook.php +++ b/app/Jobs/DeliverWebhook.php @@ -33,7 +33,7 @@ public function handle(WebhookService $webhooks): void $timestamp = (string) now()->timestamp; $response = Http::withHeaders([ 'Content-Type' => 'application/json', - 'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted), + 'X-Platform-Signature' => $webhooks->sign($payload, $subscription->signing_secret_encrypted), 'X-Platform-Event' => $this->delivery->event, 'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(), 'X-Platform-Timestamp' => $timestamp, diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php index 72c0d415..61d1b29e 100644 --- a/app/Jobs/ExpireAbandonedCheckouts.php +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Enums\CheckoutStatus; +use App\Events\CheckoutExpired; use App\Models\Checkout; use App\Services\InventoryService; use Illuminate\Contracts\Queue\ShouldQueue; @@ -24,6 +25,7 @@ public function handle(InventoryService $inventory): void } $checkout->update(['status' => CheckoutStatus::Expired]); + CheckoutExpired::dispatch($checkout->refresh()); }); } } diff --git a/app/Jobs/GenerateOrderExport.php b/app/Jobs/GenerateOrderExport.php new file mode 100644 index 00000000..5b41a04c --- /dev/null +++ b/app/Jobs/GenerateOrderExport.php @@ -0,0 +1,74 @@ +export->update(['status' => 'processing', 'error_message' => null]); + + try { + $filters = $this->export->filters_json ?? []; + $orders = Order::withoutGlobalScopes() + ->where('store_id', $this->export->store_id) + ->with(['customer', 'checkout.shippingRate']) + ->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status)) + ->when($filters['financial_status'] ?? null, fn ($query, string $status) => $query->where('financial_status', $status)) + ->when($filters['created_after'] ?? null, fn ($query, string $date) => $query->where('created_at', '>=', $date)) + ->when($filters['created_before'] ?? null, fn ($query, string $date) => $query->where('created_at', '<=', $date)) + ->orderBy('created_at') + ->get(); + + $handle = fopen('php://temp', 'w+'); + fputcsv($handle, ['order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', 'customer_email', 'customer_name', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency', 'shipping_method', 'tracking_number']); + + foreach ($orders as $order) { + fputcsv($handle, [ + $order->order_number, + $order->created_at?->toIso8601String(), + $order->status?->value ?? $order->status, + $order->financial_status?->value ?? $order->financial_status, + $order->fulfillment_status?->value ?? $order->fulfillment_status, + $order->customer?->email ?? $order->email, + $order->customer?->name, + $order->subtotal_amount, + $order->discount_amount, + $order->shipping_amount, + $order->tax_amount, + $order->total_amount, + $order->currency, + $order->checkout?->shippingRate?->name, + $order->fulfillments()->latest()->value('tracking_number'), + ]); + } + + rewind($handle); + $contents = stream_get_contents($handle); + fclose($handle); + $key = 'exports/orders-'.$this->export->getKey().'-'.now()->format('YmdHis').'.csv'; + Storage::disk('public')->put($key, $contents); + $expiresAt = now()->addHour(); + + $this->export->update(['status' => 'completed', 'row_count' => $orders->count(), 'storage_key' => $key, 'download_url' => Storage::disk('public')->url($key), 'download_expires_at' => $expiresAt, 'completed_at' => now()]); + } catch (Throwable $exception) { + $this->export->update(['status' => 'failed', 'error_message' => $exception->getMessage()]); + + throw $exception; + } + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php index 7e9e0f89..487b697d 100644 --- a/app/Jobs/ProcessMediaUpload.php +++ b/app/Jobs/ProcessMediaUpload.php @@ -80,23 +80,33 @@ private function writeImageVariants(object $disk, string $sourceKey, string $con $directory = trim(pathinfo($sourceKey, PATHINFO_DIRNAME), '.'); $extension = strtolower(pathinfo($sourceKey, PATHINFO_EXTENSION)); - foreach (['thumbnail' => 320, 'medium' => 800, 'large' => 1600] as $name => $maximum) { + $basename = pathinfo($sourceKey, PATHINFO_FILENAME); + + foreach (['thumbnail' => 150, 'small' => 300, 'medium' => 600, 'large' => 1200] as $name => $maximum) { + $variantContents = $contents; if ($width <= $maximum && $height <= $maximum) { $variants[$name] = $sourceKey; + } else { + $variantContents = $this->resize($contents, $mimeType, $width, $height, $maximum); + if ($variantContents === null) { + $variants[$name] = $sourceKey; - continue; + continue; + } } - $resized = $this->resize($contents, $mimeType, $width, $height, $maximum); - if ($resized === null) { - $variants[$name] = $sourceKey; + $key = $directory.'/'.$basename.'/'.$name.'.'.$extension; + $disk->put($key, $variantContents); + $variants[$name] = $key; - continue; + if (function_exists('imagewebp')) { + $webp = $this->resizeToWebp($contents, $width, $height, $maximum); + if ($webp !== null) { + $webpKey = $directory.'/'.$basename.'/'.$name.'.webp'; + $disk->put($webpKey, $webp); + $variants[$name.'_webp'] = $webpKey; + } } - - $key = $directory.'/'.$name.'.'.$extension; - $disk->put($key, $resized); - $variants[$name] = $key; } return $variants; @@ -130,4 +140,27 @@ private function resize(string $contents, string $mimeType, int $width, int $hei return is_string($result) ? $result : null; } + + private function resizeToWebp(string $contents, int $width, int $height, int $maximum): ?string + { + $source = @imagecreatefromstring($contents); + if ($source === false) { + return null; + } + + $scale = min(1, $maximum / $width, $maximum / $height); + $targetWidth = max(1, (int) round($width * $scale)); + $targetHeight = max(1, (int) round($height * $scale)); + $target = imagecreatetruecolor($targetWidth, $targetHeight); + imagealphablending($target, false); + imagesavealpha($target, true); + imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); + ob_start(); + $written = imagewebp($target, null, 85); + $result = $written ? ob_get_clean() : false; + imagedestroy($source); + imagedestroy($target); + + return is_string($result) ? $result : null; + } } diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..4e40f9c7 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,44 @@ + [$event->order->store, 'order.created', $event->order], + $event instanceof OrderPaid => [$event->order->store, 'order.paid', $event->order], + $event instanceof OrderFulfilled => [$event->order->store, 'order.fulfilled', $event->order], + $event instanceof OrderRefunded => [$event->order->store, 'order.refunded', $event->order], + $event instanceof CheckoutCompleted => [$event->checkout->store, 'checkout.completed', $event->checkout], + $event instanceof ProductCreated => [$event->product->store, 'product.created', $event->product], + $event instanceof ProductDeleted => [$event->product->store, 'product.deleted', $event->product], + $event instanceof ProductUpdated => [$event->product->store, $event->product->status->value === 'archived' ? 'product.deleted' : 'product.updated', $event->product], + default => [null, null, null], + }; + + if ($store === null || $eventType === null || $resource === null) { + return; + } + + $this->webhooks->dispatch($store, $eventType, [ + 'api_version' => '2026-01', + 'id' => (string) $resource->getKey(), + 'type' => $eventType, + 'data' => $resource->toArray(), + ]); + } +} diff --git a/app/Listeners/RecordAuthenticationEvent.php b/app/Listeners/RecordAuthenticationEvent.php new file mode 100644 index 00000000..4e3c746c --- /dev/null +++ b/app/Listeners/RecordAuthenticationEvent.php @@ -0,0 +1,31 @@ + 'auth.login', + $event instanceof Failed => 'auth.failed', + default => 'auth.logout', + }; + $subject = $event->user instanceof Model ? $event->user : null; + $context = ['guard' => $event->guard]; + + if ($event instanceof Failed) { + $context['identifier'] = $event->credentials['email'] ?? null; + } + + $this->audit->record($name, $subject, $context); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php index 6324c6b0..758ede46 100644 --- a/app/Livewire/Admin/Developers/Index.php +++ b/app/Livewire/Admin/Developers/Index.php @@ -45,7 +45,11 @@ public function createToken(): void 'tokenExpiresAt' => ['nullable', 'date', 'after:today'], 'tokenAbilities' => ['required', 'string', 'max:1000'], ]); - $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content']; + $allowed = ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content']; + + if (auth()->user()?->isPlatformAdmin()) { + $allowed[] = 'manage-platform'; + } $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities']))))); abort_if($abilities === [], 422, 'Select at least one token ability.'); $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']); diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php index 3563187d..895d8814 100644 --- a/app/Livewire/Storefront/CartDrawer.php +++ b/app/Livewire/Storefront/CartDrawer.php @@ -2,8 +2,12 @@ namespace App\Livewire\Storefront; +use App\Exceptions\InsufficientInventoryException; +use App\Exceptions\InvalidDiscountException; use App\Models\Cart; use App\Services\CartService; +use App\Services\CheckoutService; +use App\Services\DiscountService; use Livewire\Attributes\On; use Livewire\Component; @@ -13,9 +17,17 @@ class CartDrawer extends Component public Cart $cart; + public string $discountCode = ''; + + public int $discountAmount = 0; + + public string $message = ''; + public function mount(CartService $carts): void { $this->cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + $this->discountCode = (string) ($this->cart->discount_code ?? ''); + $this->dispatch('cart-count-updated', count: $this->cart->itemCount()); } #[On('open-cart-drawer')] @@ -29,7 +41,90 @@ public function open(): void public function refreshCart(): void { $this->cart = app(CartService::class)->getOrCreateForSession(app('current_store'), auth('customer')->user()); + $this->discountCode = (string) ($this->cart->discount_code ?? ''); + $this->discountAmount = $this->calculateDiscountAmount(); $this->open = true; + $this->dispatch('cart-count-updated', count: $this->cart->itemCount()); + } + + public function increase(int $lineId): void + { + $line = $this->cart->lines->firstWhere('id', $lineId); + + if ($line === null) { + return; + } + + try { + app(CartService::class)->updateLineQuantity($this->cart, $lineId, $line->quantity + 1); + $this->refreshCart(); + } catch (InsufficientInventoryException $exception) { + $this->addError('cart', $exception->getMessage()); + } + } + + public function decrease(int $lineId): void + { + $line = $this->cart->lines->firstWhere('id', $lineId); + + if ($line === null || $line->quantity < 2) { + return; + } + + app(CartService::class)->updateLineQuantity($this->cart, $lineId, $line->quantity - 1); + $this->refreshCart(); + } + + public function remove(int $lineId): void + { + if (! $this->cart->lines->contains('id', $lineId)) { + return; + } + + app(CartService::class)->removeLine($this->cart, $lineId); + $this->message = 'Item removed from your cart.'; + $this->refreshCart(); + } + + public function applyDiscount(DiscountService $discounts): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:64']]); + + try { + $discount = $discounts->validate(trim($this->discountCode), app('current_store'), $this->cart); + $this->cart->update(['discount_code' => $discount->code]); + $this->message = 'Discount applied.'; + $this->resetValidation('discountCode'); + $this->refreshCart(); + } catch (InvalidDiscountException $exception) { + $this->addError('discountCode', $exception->getMessage()); + } + } + + public function removeDiscount(): void + { + $this->cart->update(['discount_code' => null]); + $this->discountCode = ''; + $this->message = 'Discount removed.'; + $this->resetValidation('discountCode'); + $this->refreshCart(); + } + + public function checkout(CheckoutService $checkouts): void + { + if ($this->cart->lines->isEmpty()) { + $this->addError('cart', 'Your cart is empty.'); + + return; + } + + $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user()); + $this->redirect(route('checkout.show', $checkout), navigate: true); + } + + public function formatMoney(int|float $amount): string + { + return number_format((float) $amount / 100, 2, '.', ',').' '.($this->cart->currency ?: 'EUR'); } public function close(): void @@ -41,4 +136,24 @@ public function render(): mixed { return view('livewire.storefront.cart-drawer'); } + + private function calculateDiscountAmount(): int + { + if ($this->cart->discount_code === null || $this->cart->lines->isEmpty()) { + return 0; + } + + try { + $discount = app(DiscountService::class)->validate($this->cart->discount_code, app('current_store'), $this->cart); + } catch (InvalidDiscountException) { + return 0; + } + + return app(DiscountService::class)->calculate($discount, (int) $this->cart->lines->sum('line_subtotal_amount'), $this->cart->lines->map(fn ($line): array => [ + 'line_id' => $line->id, + 'amount' => $line->line_subtotal_amount, + 'product_id' => $line->variant->product_id, + 'collection_ids' => $line->variant->product->collections->modelKeys(), + ])->all())->amount; + } } diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php index 38d46a74..fd05990e 100644 --- a/app/Livewire/Storefront/Collections/Show.php +++ b/app/Livewire/Storefront/Collections/Show.php @@ -4,15 +4,28 @@ use App\Models\Collection as ProductCollection; use Livewire\Component; +use Livewire\WithPagination; class Show extends Component { + use WithPagination; + public ProductCollection $collection; public string $sort = 'featured'; public bool $inStock = false; + public function updatedSort(): void + { + $this->resetPage(); + } + + public function updatedInStock(): void + { + $this->resetPage(); + } + public function mount(string $handle): void { $this->collection = ProductCollection::query()->where('handle', $handle)->with(['products.variants.inventory', 'products.media'])->firstOrFail(); @@ -20,15 +33,14 @@ public function mount(string $handle): void public function render(): mixed { - $products = $this->collection->products->filter(fn ($product): bool => $product->status->value === 'active' && (! $this->inStock || $product->variants->contains(fn ($variant): bool => $variant->availableQuantity() > 0))); - - if ($this->sort === 'price_asc') { - $products = $products->sortBy(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0); - } elseif ($this->sort === 'price_desc') { - $products = $products->sortByDesc(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0); - } elseif ($this->sort === 'newest') { - $products = $products->sortByDesc('created_at'); - } + $products = $this->collection->products() + ->where('products.status', 'active') + ->with(['variants.inventory', 'media']) + ->when($this->inStock, fn ($query) => $query->whereHas('variants.inventory', fn ($inventory) => $inventory->whereColumn('quantity_on_hand', '>', 'quantity_reserved')->orWhere('policy', 'continue'))) + ->when($this->sort === 'price_asc', fn ($query) => $query->withMin('variants', 'price_amount')->orderBy('variants_min_price_amount')) + ->when($this->sort === 'price_desc', fn ($query) => $query->withMin('variants', 'price_amount')->orderByDesc('variants_min_price_amount')) + ->when($this->sort === 'newest', fn ($query) => $query->latest('products.created_at')) + ->paginate(12); return view('livewire.storefront.collections.show', ['products' => $products])->layout('layouts.storefront'); } diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php index bc8e84e7..c6e255e3 100644 --- a/app/Livewire/Storefront/Products/Show.php +++ b/app/Livewire/Storefront/Products/Show.php @@ -13,6 +13,8 @@ class Show extends Component public int $selectedVariantId; + public ?int $selectedMediaId = null; + /** @var array */ public array $selectedOptions = []; @@ -25,6 +27,7 @@ public function mount(string $handle): void $this->product = Product::query()->with(['variants.inventory', 'variants.optionValues', 'media', 'options.values'])->where('handle', $handle)->firstOrFail(); abort_unless($this->product->status->value === 'active', 404); $this->selectedVariantId = $this->product->defaultVariant()?->getKey() ?? 0; + $this->selectedMediaId = $this->product->media->first()?->getKey(); $default = $this->product->defaultVariant(); $this->selectedOptions = $default?->optionValues->mapWithKeys(fn ($value): array => [$value->product_option_id => $value->getKey()])->all() ?? []; } @@ -50,6 +53,13 @@ public function selectVariant(int $variantId): void $this->selectedVariantId = $variantId; } + public function selectMedia(int $mediaId): void + { + abort_unless($this->product->media->contains('id', $mediaId), 404); + + $this->selectedMediaId = $mediaId; + } + public function selectOption(int $optionId, int $valueId): void { abort_unless($this->product->options->firstWhere('id', $optionId)?->values->contains('id', $valueId), 404); diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php index 21b42974..044198a5 100644 --- a/app/Livewire/Storefront/Search/Modal.php +++ b/app/Livewire/Storefront/Search/Modal.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storefront\Search; use App\Services\SearchService; +use Livewire\Attributes\On; use Livewire\Component; class Modal extends Component @@ -23,6 +24,12 @@ public function updatedQuery(SearchService $search): void /** @var array */ public array $suggestions = []; + #[On('open-search-modal')] + public function open(): void + { + $this->open = true; + } + public function render(): mixed { return view('livewire.storefront.search.modal'); diff --git a/app/Models/OrderExport.php b/app/Models/OrderExport.php new file mode 100644 index 00000000..5c5800dc --- /dev/null +++ b/app/Models/OrderExport.php @@ -0,0 +1,26 @@ + */ + protected $fillable = ['store_id', 'format', 'filters_json', 'status', 'row_count', 'storage_key', 'download_url', 'download_expires_at', 'completed_at', 'error_message']; + + protected function casts(): array + { + return ['filters_json' => 'array', 'download_expires_at' => 'datetime', 'completed_at' => 'datetime']; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php index aa567ece..106f09ee 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -9,13 +9,13 @@ class Payment extends Model { - protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'currency', 'raw_json_encrypted']; + protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'currency', 'raw_json']; - protected $hidden = ['raw_json_encrypted']; + protected $hidden = ['raw_json']; protected function casts(): array { - return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted']; + return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json' => 'encrypted:array']; } public function order(): BelongsTo diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php index 710c3504..ab087020 100644 --- a/app/Models/ThemeFile.php +++ b/app/Models/ThemeFile.php @@ -7,7 +7,7 @@ class ThemeFile extends Model { - protected $fillable = ['theme_id', 'path', 'content']; + protected $fillable = ['theme_id', 'path', 'content', 'storage_key', 'sha256', 'byte_size']; public function theme(): BelongsTo { diff --git a/app/Models/User.php b/app/Models/User.php index 86e52422..3d768863 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,6 +29,7 @@ class User extends Authenticatable 'password_hash', 'status', 'last_login_at', + 'is_platform_admin', ]; /** @@ -55,6 +56,7 @@ protected function casts(): array 'email_verified_at' => 'datetime', 'password' => 'hashed', 'last_login_at' => 'datetime', + 'is_platform_admin' => 'boolean', ]; } @@ -74,6 +76,21 @@ public function stores(): BelongsToMany return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps(); } + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function getAuthPassword(): ?string + { + return $this->password_hash; + } + + public function isPlatformAdmin(): bool + { + return (bool) $this->is_platform_admin; + } + public function roleForStore(Store $store): ?StoreUserRole { $pivot = $this->stores()->where('stores.id', $store->getKey())->first()?->pivot; diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php index 725082f3..4f9c9dea 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,15 +13,18 @@ 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->getKey()); + ProductDeleted::dispatch($product); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 93b51f60..4064f084 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,20 @@ use App\Auth\StoreScopedPasswordBrokerManager; use App\Contracts\PaymentProvider as PaymentProviderContract; use App\Contracts\TaxProvider; +use App\Events\CheckoutAddressed; +use App\Events\CheckoutCompleted; +use App\Events\CheckoutExpired; +use App\Events\CheckoutShippingSelected; +use App\Events\FulfillmentCreated; +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\Listeners\DispatchWebhooks; +use App\Listeners\RecordAuthenticationEvent; use App\Models\Product; use App\Observers\ProductObserver; use App\Services\MockPaymentProvider; @@ -17,6 +31,8 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -45,6 +61,35 @@ public function boot(): void $this->configureDefaults(); Model::preventLazyLoading(! app()->isProduction()); Product::observe(ProductObserver::class); + Gate::policy(\App\Models\Collection::class, \App\Policies\CollectionPolicy::class); + Gate::policy(\App\Models\Customer::class, \App\Policies\CustomerPolicy::class); + Gate::policy(\App\Models\Discount::class, \App\Policies\DiscountPolicy::class); + Gate::policy(\App\Models\Fulfillment::class, \App\Policies\FulfillmentPolicy::class); + Gate::policy(\App\Models\Order::class, \App\Policies\OrderPolicy::class); + Gate::policy(\App\Models\Page::class, \App\Policies\PagePolicy::class); + Gate::policy(\App\Models\Product::class, \App\Policies\ProductPolicy::class); + Gate::policy(\App\Models\Refund::class, \App\Policies\RefundPolicy::class); + Gate::policy(\App\Models\Store::class, \App\Policies\StorePolicy::class); + Gate::policy(\App\Models\Theme::class, \App\Policies\ThemePolicy::class); + Event::listen([ + CheckoutAddressed::class, + CheckoutCompleted::class, + CheckoutExpired::class, + CheckoutShippingSelected::class, + FulfillmentCreated::class, + OrderCreated::class, + OrderFulfilled::class, + OrderPaid::class, + OrderRefunded::class, + ProductCreated::class, + ProductDeleted::class, + ProductUpdated::class, + ], DispatchWebhooks::class); + Event::listen([ + \Illuminate\Auth\Events\Failed::class, + \Illuminate\Auth\Events\Login::class, + \Illuminate\Auth\Events\Logout::class, + ], RecordAuthenticationEvent::class); Livewire::setUpdateRoute(function ($handle): mixed { return Route::post(EndpointResolver::updatePath(), $handle) ->middleware(['web', 'store.resolve']) diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php index 6630c6aa..e7b8e51b 100644 --- a/app/Services/CheckoutService.php +++ b/app/Services/CheckoutService.php @@ -3,6 +3,9 @@ namespace App\Services; use App\Enums\CheckoutStatus; +use App\Events\CheckoutAddressed; +use App\Events\CheckoutExpired; +use App\Events\CheckoutShippingSelected; use App\Models\Cart; use App\Models\Checkout; use App\Models\Customer; @@ -28,13 +31,14 @@ public function create(Cart $cart, string $email, ?Customer $customer = null): C public function setAddress(Checkout $checkout, array $address, ?array $billing = null, bool $useShippingAsBilling = true): Checkout { - if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + if (in_array($checkout->status, [CheckoutStatus::PaymentSelected, CheckoutStatus::PaymentPending, CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { throw new \LogicException('This checkout can no longer be changed.'); } Validator::make($address, ['first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'address1' => ['required', 'string', 'max:500'], 'city' => ['required', 'string', 'max:255'], 'country_code' => ['required', 'string', 'size:2'], 'postal_code' => ['required', 'string', 'max:20']])->validate(); $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]); $this->pricing->calculate($checkout->refresh()); + CheckoutAddressed::dispatch($checkout); return $checkout->refresh(); } @@ -43,9 +47,14 @@ public function setShippingMethod(Checkout $checkout, int $rateId): Checkout { $checkout->loadMissing('cart.lines.variant'); + if (! in_array($checkout->status, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected], true)) { + throw new \LogicException('Checkout must have an address before selecting shipping.'); + } + if (! $this->requiresShipping($checkout)) { $checkout->update(['shipping_rate_id' => null, 'shipping_method_id' => null, 'status' => CheckoutStatus::ShippingSelected]); $this->pricing->calculate($checkout->refresh()); + CheckoutShippingSelected::dispatch($checkout); return $checkout->refresh(); } @@ -62,6 +71,7 @@ public function setShippingMethod(Checkout $checkout, int $rateId): Checkout $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]); $this->pricing->calculate($checkout->refresh()); + CheckoutShippingSelected::dispatch($checkout); return $checkout->refresh(); } @@ -80,8 +90,8 @@ public function selectPaymentMethod(Checkout $checkout, string $method): Checkou throw new \LogicException('A shipping method is required before selecting payment.'); } - if ($checkout->status === CheckoutStatus::PaymentSelected) { - return $checkout->refresh(); + if ($checkout->status !== CheckoutStatus::ShippingSelected) { + throw new \LogicException('Checkout must have a selected shipping method before selecting payment.'); } $checkout->load('cart.lines.variant.inventory'); @@ -117,6 +127,7 @@ public function expireCheckout(Checkout $checkout): void } $checkout->update(['status' => CheckoutStatus::Expired]); + CheckoutExpired::dispatch($checkout->refresh()); }); } diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php index fcb921ef..fe1081f6 100644 --- a/app/Services/FulfillmentService.php +++ b/app/Services/FulfillmentService.php @@ -5,6 +5,7 @@ use App\Enums\FinancialStatus; use App\Enums\FulfillmentStatus; use App\Enums\OrderStatus; +use App\Events\FulfillmentCreated; use App\Events\FulfillmentDelivered; use App\Events\FulfillmentShipped; use App\Events\OrderFulfilled; @@ -43,6 +44,7 @@ public function create(Order $order, array $lines, ?array $tracking = null): Ful } $this->refreshOrderStatus($order->refresh()); + FulfillmentCreated::dispatch($fulfillment->refresh()); return $fulfillment->load('lines.orderLine'); }); diff --git a/app/Services/MockPaymentProvider.php b/app/Services/MockPaymentProvider.php index d578dcb2..3d863a55 100644 --- a/app/Services/MockPaymentProvider.php +++ b/app/Services/MockPaymentProvider.php @@ -23,8 +23,8 @@ public function charge(Checkout $checkout, PaymentMethod $method, array $details $number = preg_replace('/\D+/', '', (string) ($details['card_number'] ?? '')); return match ($number) { - '4000000000000002' => new PaymentResult(PaymentStatus::Failed, 'mock_'.Str::lower(Str::random(16)), 'Your card was declined.'), - '4000000000009995' => new PaymentResult(PaymentStatus::Failed, 'mock_'.Str::lower(Str::random(16)), 'Your card has insufficient funds.'), + '4000000000000002' => new PaymentResult(PaymentStatus::Failed, 'mock_'.Str::lower(Str::random(16)), 'Your card was declined.', 'card_declined'), + '4000000000009995' => new PaymentResult(PaymentStatus::Failed, 'mock_'.Str::lower(Str::random(16)), 'Your card has insufficient funds.', 'insufficient_funds'), default => new PaymentResult(PaymentStatus::Captured, 'mock_'.Str::lower(Str::random(16)), 'Payment captured.'), }; } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index f65da467..5eec46ef 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -6,11 +6,12 @@ use App\Enums\FulfillmentStatus; use App\Enums\OrderStatus; use App\Enums\PaymentStatus; +use App\Events\CheckoutCompleted; use App\Events\OrderCancelled; use App\Events\OrderCreated; use App\Events\OrderPaid; use App\Models\Checkout; -use App\Models\Discount; +use App\Models\Customer; use App\Models\Order; use App\Models\Store; use App\ValueObjects\PaymentResult; @@ -32,7 +33,14 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe } $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency]; - $discount = $checkout->discount_code === null ? null : Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first(); + if ($checkout->customer_id === null) { + $customer = Customer::withoutGlobalScopes()->firstOrCreate( + ['store_id' => $checkout->store_id, 'email' => strtolower($checkout->email)], + ['first_name' => 'Guest', 'last_name' => 'Customer', 'status' => 'active', 'metadata' => ['guest_checkout' => true]], + ); + $checkout->update(['customer_id' => $customer->getKey()]); + } + $status = $paymentResult?->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending; $taxByLine = $this->allocateTaxLines($checkout->cart->lines, $totals['tax_lines'] ?? []); $order = Order::withoutGlobalScopes()->create([ @@ -71,7 +79,7 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe 'line_discount_amount' => $line->line_discount_amount, 'line_total_amount' => $line->line_total_amount, 'tax_lines_json' => $taxByLine[$line->getKey()] ?? [], - 'discount_allocations_json' => $discount === null || $line->line_discount_amount < 1 ? [] : [['discount_id' => $discount->getKey(), 'amount' => $line->line_discount_amount]], + 'discount_allocations_json' => $totals['discount_allocations'][$line->getKey()] ?? [], ]); if ($paymentResult?->status === PaymentStatus::Captured && $line->variant->inventory !== null) { @@ -81,6 +89,7 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe $checkout->update(['status' => 'completed']); $checkout->cart->update(['status' => 'converted']); + CheckoutCompleted::dispatch($checkout->refresh(), $order); OrderCreated::dispatch($order); $this->audit->record('order.created', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]); $order->load(['lines', 'payments']); diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 3230ad35..51180159 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -6,6 +6,7 @@ use App\Enums\CheckoutStatus; use App\Enums\PaymentMethod; use App\Enums\PaymentStatus; +use App\Exceptions\PaymentDeclinedException; use App\Models\Checkout; use App\Models\Discount; use App\Models\Order; @@ -18,7 +19,8 @@ public function __construct(private readonly PaymentProvider $provider, private public function pay(Checkout $checkout, PaymentMethod $method, array $details = []): ?Order { - return DB::transaction(function () use ($checkout, $method, $details): ?Order { + $declined = null; + $order = DB::transaction(function () use ($checkout, $method, $details, &$declined): ?Order { $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first(); if ($existing !== null) { @@ -43,18 +45,31 @@ public function pay(Checkout $checkout, PaymentMethod $method, array $details = } $checkout->update(['status' => CheckoutStatus::ShippingSelected]); + $declined = $result; return null; } $order = $this->orders->createFromCheckout($checkout, $result); - Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]); + Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json' => ['reference' => $result->reference, 'message' => $result->message]]); - if ($checkout->discount_code !== null) { - Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->increment('usage_count'); + $discountIds = collect($checkout->totals_json['discount_allocations'] ?? []) + ->flatMap(fn (array $allocations): array => $allocations) + ->pluck('discount_id') + ->filter() + ->unique(); + + if ($discountIds->isNotEmpty()) { + Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereIn('id', $discountIds)->increment('usage_count'); } return $order->refresh()->load(['lines', 'payments']); }); + + if ($declined !== null) { + throw new PaymentDeclinedException($declined->errorCode ?? 'payment_failed', $declined->message ?: 'Payment failed.'); + } + + return $order; } } diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php index 446e151b..ced94738 100644 --- a/app/Services/PricingEngine.php +++ b/app/Services/PricingEngine.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Enums\DiscountType; use App\Models\Checkout; use App\Models\Discount; use App\Models\TaxSettings; @@ -19,32 +20,59 @@ public function calculate(Checkout $checkout): PricingResult $subtotal = (int) $lines->sum(fn ($line): int => $line->unit_price_amount * $line->quantity); $discountAmount = 0; $freeShipping = false; + $discountAllocations = []; foreach ($lines as $line) { $line->updateQuietly(['line_discount_amount' => 0, 'line_total_amount' => $line->line_subtotal_amount]); } + $discounts = collect(); + if ($checkout->discount_code !== null) { $discount = Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first(); if ($discount?->isAvailable()) { - $result = $this->discounts->calculate($discount, $subtotal, $lines->map(fn ($line): array => [ - 'line_id' => $line->id, - 'amount' => $line->unit_price_amount * $line->quantity, - 'product_id' => $line->variant->product_id, - 'collection_ids' => $line->variant->product->collections->modelKeys(), - ])->all()); - $discountAmount = $result->amount; - $freeShipping = $result->freeShipping; - - foreach ($lines as $line) { - $lineDiscount = $result->allocations[$line->id] ?? 0; - $line->updateQuietly(['line_discount_amount' => $lineDiscount, 'line_total_amount' => max(0, $line->line_subtotal_amount - $lineDiscount)]); + $discounts->push($discount); + } + } + + Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->where('type', DiscountType::Automatic) + ->get() + ->filter(fn (Discount $discount): bool => $discount->isAvailable()) + ->each(fn (Discount $discount): mixed => $discounts->push($discount)); + + foreach ($discounts as $discount) { + $currentSubtotal = (int) $lines->sum('line_total_amount'); + $minimum = (int) ($discount->rules_json['min_purchase_amount'] ?? $discount->rules_json['minimum_purchase_amount'] ?? 0); + + if ($currentSubtotal < $minimum) { + continue; + } + + $result = $this->discounts->calculate($discount, $currentSubtotal, $lines->map(fn ($line): array => [ + 'line_id' => $line->id, + 'amount' => $line->line_total_amount, + 'product_id' => $line->variant->product_id, + 'collection_ids' => $line->variant->product->collections->modelKeys(), + ])->all()); + $discountAmount += $result->amount; + $freeShipping = $freeShipping || $result->freeShipping; + + foreach ($lines as $line) { + $lineDiscount = $result->allocations[$line->id] ?? 0; + $line->updateQuietly([ + 'line_discount_amount' => $line->line_discount_amount + $lineDiscount, + 'line_total_amount' => max(0, $line->line_total_amount - $lineDiscount), + ]); + if ($lineDiscount > 0) { + $discountAllocations[$line->id][] = ['discount_id' => $discount->getKey(), 'amount' => $lineDiscount]; } } } - $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0; + $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? ($this->shipping->calculate($checkout->shippingRate, $cart) ?? 0) : 0; $taxSettings = TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $checkout->store_id], ['default_rate_basis_points' => 0]); $taxLines = []; @@ -62,7 +90,17 @@ public function calculate(Checkout $checkout): PricingResult $pricesIncludeTax = (bool) $taxSettings->prices_include_tax || $taxSettings->mode === 'inclusive'; $total = max(0, $discountedSubtotal + $shippingAmount + ($pricesIncludeTax ? 0 : $taxTotal)); $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency); - $checkout->update(['totals_json' => $result->toArray()]); + $totals = [...$result->toArray(), 'discount_allocations' => $discountAllocations]; + $checkout->update([ + 'totals_json' => $totals, + 'tax_provider_snapshot_json' => [ + 'provider' => $taxSettings->provider, + 'mode' => $taxSettings->mode, + 'rates' => $taxSettings->rates_json ?? [], + 'captured_at' => now()->toIso8601String(), + 'tax_total' => $taxTotal, + ], + ]); return $result; } diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php index ada22c1a..a7e8a42f 100644 --- a/app/Services/SearchService.php +++ b/app/Services/SearchService.php @@ -13,8 +13,10 @@ class SearchService { - public function search(Store $store, string $query, array $filters = [], int $perPage = 12): LengthAwarePaginator + public function search(Store $store, string $query, array $filters = [], int $perPage = 24, int $page = 1, string $sort = 'relevance'): LengthAwarePaginator { + $minimumPrice = $filters['price_min'] ?? $filters['min_price'] ?? null; + $maximumPrice = $filters['price_max'] ?? $filters['max_price'] ?? null; $products = Product::withoutGlobalScopes() ->published() ->where('store_id', $store->getKey()) @@ -27,11 +29,21 @@ public function search(Store $store, string $query, array $filters = [], int $pe } }) ->when($filters['vendor'] ?? null, fn (Builder $builder, string $vendor): Builder => $builder->where('vendor', $vendor)) - ->when(isset($filters['min_price']), fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->where('price_amount', '>=', (int) $filters['min_price']))) - ->when(isset($filters['max_price']), fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->where('price_amount', '<=', (int) $filters['max_price']))) + ->when(isset($filters['collection_id']), fn (Builder $builder): Builder => $builder->whereHas('collections', fn (Builder $collections): Builder => $collections->whereKey((int) $filters['collection_id']))) + ->when($minimumPrice !== null, fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->where('price_amount', '>=', (int) $minimumPrice))) + ->when($maximumPrice !== null, fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->where('price_amount', '<=', (int) $maximumPrice))) + ->when($filters['in_stock'] ?? false, fn (Builder $builder): Builder => $builder->whereHas('variants', fn (Builder $variants): Builder => $variants->whereHas('inventory', fn (Builder $inventory): Builder => $inventory->whereColumn('quantity_on_hand', '>', 'quantity_reserved')->orWhere('policy', 'continue')))) + ->when($filters['tags'] ?? [], fn (Builder $builder, array $tags): Builder => $builder->where(function (Builder $products) use ($tags): void { + foreach ($tags as $tag) { + $products->whereJsonContains('tags', $tag); + } + })) ->with(['variants.inventory', 'media']) - ->latest('published_at') - ->paginate($perPage); + ->when($sort === 'price_asc', fn (Builder $builder): Builder => $builder->withMin('variants', 'price_amount')->orderBy('variants_min_price_amount')) + ->when($sort === 'price_desc', fn (Builder $builder): Builder => $builder->withMin('variants', 'price_amount')->orderByDesc('variants_min_price_amount')) + ->when($sort === 'best_selling', fn (Builder $builder): Builder => $builder->orderByDesc('sales_count')) + ->when(! in_array($sort, ['price_asc', 'price_desc', 'best_selling'], true), fn (Builder $builder): Builder => $builder->latest('published_at')) + ->paginate($perPage, ['*'], 'page', $page); if (Schema::hasTable('search_queries')) { SearchQuery::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'query' => $query, 'results_count' => $products->total(), 'customer_id' => auth('customer')->id()]); @@ -49,10 +61,11 @@ public function autocomplete(Store $store, string $prefix, int $limit = 8): Coll return Product::withoutGlobalScopes() ->published() ->where('store_id', $store->getKey()) - ->where('title', 'like', trim($prefix).'%') + ->where('title', 'like', '%'.trim($prefix).'%') ->orderBy('title') ->limit($limit) - ->get(['id', 'title', 'handle']); + ->with(['media', 'variants']) + ->get(); } public function syncProduct(Product $product): void diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php index 2b956241..33255bc0 100644 --- a/app/Services/ShippingCalculator.php +++ b/app/Services/ShippingCalculator.php @@ -20,22 +20,21 @@ public function getAvailableRates(Store $store, array $address): Collection $countries = array_map('strtoupper', $rate->zone->countries_json ?? []); $regions = array_map('strtoupper', $rate->zone->regions_json ?? []); - return ($region !== '' && in_array($region, $regions, true)) - || ($country !== '' && in_array($country, $countries, true)) - || ($countries === [] && $regions === []); + return $country !== '' && in_array($country, $countries, true) + && ($region === '' || $regions === [] || in_array($region, $regions, true)); }); - $specificity = $matching->groupBy(function (ShippingRate $rate) use ($country, $region): int { + $specificity = $matching->groupBy(function (ShippingRate $rate) use ($region): int { $countries = array_map('strtoupper', $rate->zone->countries_json ?? []); $regions = array_map('strtoupper', $rate->zone->regions_json ?? []); - return ($region !== '' && in_array($region, $regions, true)) ? 2 : (($country !== '' && in_array($country, $countries, true)) ? 1 : 0); + return $region !== '' && in_array($region, $regions, true) ? 2 : 1; }); - return $specificity->sortKeysDesc()->first() ?? collect(); + return $specificity->sortKeysDesc()->first()?->sortBy('id')->values() ?? collect(); } - public function calculate(ShippingRate $rate, Cart $cart): int + public function calculate(ShippingRate $rate, Cart $cart): ?int { $lines = $cart->load('lines.variant')->lines; $lines = $lines->filter(fn ($line): bool => (bool) $line->variant->requires_shipping); @@ -46,18 +45,19 @@ public function calculate(ShippingRate $rate, Cart $cart): int return match ($rate->type) { 'weight' => $this->rangeAmount($config['ranges'] ?? [], $weight, $rate->price_amount), 'price' => $this->rangeAmount($config['ranges'] ?? [], $subtotal, $rate->price_amount), - default => $rate->price_amount, + 'carrier' => (int) ($config['amount'] ?? $rate->price_amount ?? 0), + default => (int) ($config['amount'] ?? $rate->price_amount ?? 0), }; } - private function rangeAmount(array $ranges, int $value, int $fallback): int + private function rangeAmount(array $ranges, int $value, ?int $fallback): ?int { foreach ($ranges as $range) { if ($value >= (int) ($range['min_g'] ?? $range['min_amount'] ?? 0) && $value <= (int) ($range['max_g'] ?? $range['max_amount'] ?? PHP_INT_MAX)) { - return (int) ($range['amount'] ?? $fallback); + return (int) ($range['amount'] ?? $fallback ?? 0); } } - return 0; + return null; } } diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php index 23fdba70..bfdfe3db 100644 --- a/app/Services/TaxCalculator.php +++ b/app/Services/TaxCalculator.php @@ -13,7 +13,15 @@ public function __construct(private readonly ?TaxProvider $provider = null) {} public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult { - return ($this->provider ?? new \App\Services\Tax\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings)); + $provider = $this->provider; + + if ($provider === null) { + $provider = $settings->mode === 'provider' && $settings->provider === 'stripe_tax' + ? new \App\Services\Tax\StripeTaxProvider(new \App\Services\Tax\ManualTaxProvider) + : new \App\Services\Tax\ManualTaxProvider; + } + + return $provider->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings)); } public function extractInclusive(int $grossAmount, int $rateBasisPoints): int diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php index 179e3a10..f402ad1d 100644 --- a/app/ValueObjects/PaymentResult.php +++ b/app/ValueObjects/PaymentResult.php @@ -6,7 +6,7 @@ readonly class PaymentResult { - public function __construct(public PaymentStatus $status, public string $reference, public string $message = '') {} + public function __construct(public PaymentStatus $status, public string $reference, public string $message = '', public ?string $errorCode = null) {} public function isSuccessful(): bool { diff --git a/bootstrap/app.php b/bootstrap/app.php index 1f7252a9..dd699df1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,18 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->redirectGuestsTo(function (\Illuminate\Http\Request $request): string { + if ($request->is('admin/*') || $request->is('admin')) { + return route('admin.login'); + } + + if ($request->is('account/*') || $request->is('account')) { + return route('account.login'); + } + + return url('/login'); + }); + $middleware->prependToPriorityList( before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, prepend: App\Http\Middleware\ResolveStore::class, diff --git a/config/cors.php b/config/cors.php index ea47980a..500bd83d 100644 --- a/config/cors.php +++ b/config/cors.php @@ -3,7 +3,7 @@ return [ 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], - 'allowed_origins' => array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', '*'))), + 'allowed_origins' => array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', env('APP_URL', 'http://localhost')))), 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'], diff --git a/config/session.php b/config/session.php index 914da71b..9dcac90b 100644 --- a/config/session.php +++ b/config/session.php @@ -167,7 +167,7 @@ | */ - 'secure' => env('SESSION_SECURE_COOKIE'), + 'secure' => (bool) env('SESSION_SECURE_COOKIE', env('APP_ENV') === 'production'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/OrderExportFactory.php b/database/factories/OrderExportFactory.php new file mode 100644 index 00000000..cbe52871 --- /dev/null +++ b/database/factories/OrderExportFactory.php @@ -0,0 +1,26 @@ + + */ +class OrderExportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'format' => 'csv', + 'filters_json' => [], + 'status' => 'queued', + ]; + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php index b34bac89..2e10aac6 100644 --- a/database/factories/PaymentFactory.php +++ b/database/factories/PaymentFactory.php @@ -29,7 +29,7 @@ public function definition(): array 'status' => PaymentStatus::Captured, 'amount' => fake()->numberBetween(999, 99999), 'currency' => 'EUR', - 'raw_json_encrypted' => null, + 'raw_json' => [], ]; } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index be7fd955..3f7a633a 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -32,6 +32,7 @@ public function definition(): array 'password' => $password, 'password_hash' => $password, 'status' => 'active', + 'is_platform_admin' => false, 'last_login_at' => now()->subDays(fake()->numberBetween(0, 30)), 'remember_token' => Str::random(10), 'two_factor_secret' => null, diff --git a/database/migrations/2026_08_21_002127_create_order_exports_table.php b/database/migrations/2026_08_21_002127_create_order_exports_table.php new file mode 100644 index 00000000..98879fc1 --- /dev/null +++ b/database/migrations/2026_08_21_002127_create_order_exports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('format')->default('csv'); + $table->json('filters_json')->nullable(); + $table->string('status')->default('queued'); + $table->unsignedInteger('row_count')->nullable(); + $table->string('storage_key')->nullable(); + $table->text('download_url')->nullable(); + $table->dateTime('download_expires_at')->nullable(); + $table->dateTime('completed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_exports'); + } +}; diff --git a/database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php b/database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php new file mode 100644 index 00000000..6fa88208 --- /dev/null +++ b/database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php @@ -0,0 +1,44 @@ +boolean('is_platform_admin')->default(false)->index(); + } + }); + + Schema::table('payments', function (Blueprint $table): void { + if (! Schema::hasColumn('payments', 'raw_json')) { + $table->text('raw_json')->nullable(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + if (Schema::hasColumn('users', 'is_platform_admin')) { + $table->dropColumn('is_platform_admin'); + } + }); + + Schema::table('payments', function (Blueprint $table): void { + if (Schema::hasColumn('payments', 'raw_json')) { + $table->dropColumn('raw_json'); + } + }); + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php index af8001d5..f1adf1a3 100644 --- a/database/seeders/AnalyticsSeeder.php +++ b/database/seeders/AnalyticsSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Models\AnalyticsEvent; +use App\Models\Store; use Illuminate\Database\Seeder; class AnalyticsSeeder extends Seeder @@ -11,6 +13,11 @@ class AnalyticsSeeder extends Seeder */ public function run(): void { - // + foreach (Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) { + AnalyticsEvent::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'client_event_id' => 'seed-'.$store->getKey().'-home'], + ['type' => 'page_view', 'session_id' => 'seed-session-'.$store->getKey(), 'payload' => ['path' => '/'], 'properties_json' => ['path' => '/'], 'occurred_at' => now()->subDay()], + ); + } } } diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php index e9be7a0f..a942f7df 100644 --- a/database/seeders/NavigationSeeder.php +++ b/database/seeders/NavigationSeeder.php @@ -2,6 +2,10 @@ namespace Database\Seeders; +use App\Models\Collection; +use App\Models\NavigationMenu; +use App\Models\Page; +use App\Models\Store; use Illuminate\Database\Seeder; class NavigationSeeder extends Seeder @@ -11,6 +15,19 @@ class NavigationSeeder extends Seeder */ public function run(): void { - // + foreach (Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) { + $menu = NavigationMenu::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => 'main'], + ['name' => 'Main navigation', 'title' => 'Main navigation'], + ); + $about = Page::withoutGlobalScopes()->where('store_id', $store->getKey())->where('handle', 'about')->first(); + $newArrivals = Collection::withoutGlobalScopes()->where('store_id', $store->getKey())->where('handle', 'new-arrivals')->first(); + $menu->items()->delete(); + $menu->items()->createMany(array_values(array_filter([ + ['label' => 'Collections', 'type' => 'url', 'url' => '/collections', 'position' => 1], + $newArrivals === null ? null : ['label' => 'New arrivals', 'type' => 'collection', 'resource_id' => $newArrivals->getKey(), 'position' => 2], + $about === null ? null : ['label' => 'About', 'type' => 'page', 'resource_id' => $about->getKey(), 'position' => 3], + ]))); + } } } diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php index 28a2418d..f55a65fb 100644 --- a/database/seeders/PageSeeder.php +++ b/database/seeders/PageSeeder.php @@ -2,6 +2,9 @@ namespace Database\Seeders; +use App\Enums\PageStatus; +use App\Models\Page; +use App\Models\Store; use Illuminate\Database\Seeder; class PageSeeder extends Seeder @@ -11,6 +14,17 @@ class PageSeeder extends Seeder */ public function run(): void { - // + foreach (Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) { + foreach ([ + ['title' => 'About us', 'handle' => 'about', 'content' => '

      Made for everyday life

      We choose useful, durable products and make shopping simple.

      '], + ['title' => 'Shipping & returns', 'handle' => 'shipping-returns', 'content' => '

      Shipping & returns

      Orders ship promptly from our warehouse. Contact support if you need help with a return.

      '], + ['title' => 'Contact', 'handle' => 'contact', 'content' => '

      Contact us

      Our support team is happy to help with your order or product questions.

      '], + ] as $page) { + Page::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => $page['handle']], + [...$page, 'store_id' => $store->getKey(), 'body_html' => $page['content'], 'status' => PageStatus::Published, 'published_at' => now()], + ); + } + } } } diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php index 18d2a0d9..ef33cf72 100644 --- a/database/seeders/SearchSettingsSeeder.php +++ b/database/seeders/SearchSettingsSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Models\SearchSetting; +use App\Models\Store; use Illuminate\Database\Seeder; class SearchSettingsSeeder extends Seeder @@ -11,6 +13,11 @@ class SearchSettingsSeeder extends Seeder */ public function run(): void { - // + foreach (Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get() as $store) { + SearchSetting::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->getKey()], + ['enabled' => true, 'synonyms' => [], 'stopwords' => []], + ); + } } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 04f6643b..1dcc83a9 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -24,7 +24,7 @@ public function run(): void User::query()->updateOrCreate( ['email' => $user['email']], - ['name' => $user['name'], 'password' => $password, 'password_hash' => $password, 'status' => 'active', 'email_verified_at' => now(), 'last_login_at' => $user['last_login_at']], + ['name' => $user['name'], 'password' => $password, 'password_hash' => $password, 'status' => 'active', 'is_platform_admin' => $user['email'] === 'admin@acme.test', 'email_verified_at' => now(), 'last_login_at' => $user['last_login_at']], ); } } diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 1991032d..515e227f 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -1,16 +1,52 @@ - {{ $title ?? 'Admin · '.($currentStore?->name ?? 'Shop') }}@vite(['resources/css/app.css', 'resources/js/app.js'])@livewireStyles - + + + + + {{ $title ?? 'Admin · '.($currentStore?->name ?? 'Shop') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + +
      -
      Store Admin
      {{ $slot }}
      +
      +
      +
      + +
      Store Admin
      +
      + + +
      +
      +
      +
      +
      {{ $slot }}
      +
      @fluxScripts @livewireScripts diff --git a/resources/views/layouts/storefront.blade.php b/resources/views/layouts/storefront.blade.php index ae89fad4..83071edd 100644 --- a/resources/views/layouts/storefront.blade.php +++ b/resources/views/layouts/storefront.blade.php @@ -1,6 +1,7 @@ @php($currentStore = $currentStore ?? null) +@php($headerCart = $currentStore ? \App\Models\Cart::withoutGlobalScopes()->where('store_id', $currentStore->getKey())->where('status', 'active')->withSum('lines', 'quantity')->find(session('cart_id_'.$currentStore->getKey(), session('cart_id'))) : null) - + @@ -8,33 +9,37 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles - + Skip to main content -
      +
      {{ data_get($currentStore?->settings?->settings_json, 'announcement', 'Free shipping on orders over €50') }} +
      +
      {{ $slot }}
      -

      {{ $currentStore?->name ?? 'Shop' }}

      Thoughtful everyday pieces, made to last.

      +

      {{ data_get($currentStore?->settings?->general_json, 'store_name', $currentStore?->name ?? 'Shop') }}

      Thoughtful everyday pieces, made to last.

      -

      Stay in the loop

      Subscribe for exclusive offers and updates.

      +

      Stay in the loop

      Subscribe for exclusive offers and updates.

      Thanks — you’re on the list.

      © {{ now()->year }} {{ $currentStore?->name ?? 'Shop' }}. All rights reserved.
      diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php index e8f33d8a..ffe47301 100644 --- a/resources/views/livewire/storefront/cart-drawer.blade.php +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -1,10 +1,13 @@ -
      +
      diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php index be088a2e..9bb6983b 100644 --- a/resources/views/livewire/storefront/collections/show.blade.php +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -1 +1 @@ -

      {{ $collection->title }}

      {{ $collection->description }}

      {{ $products->count() }} products
      @forelse ($products as $product)@empty

      No products found

      Try adjusting your filters or browse our full collection.

      @endforelse
      +

      {{ $collection->title }}

      {{ $collection->description }}

      {{ $products->total() }} products
      @forelse ($products as $product)@empty

      No products found

      Try adjusting your filters or browse our full collection.

      @endforelse
      {{ $products->links() }}
      diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php index c99f1837..37facfef 100644 --- a/resources/views/livewire/storefront/home.blade.php +++ b/resources/views/livewire/storefront/home.blade.php @@ -2,9 +2,9 @@
      -

      Acme Fashion

      -

      Everyday pieces, thoughtfully made.

      -

      Timeless wardrobe essentials with an easy, modern fit.

      +

      {{ data_get($store->settings?->general_json, 'store_name', $store->name) }}

      +

      {{ data_get($store->settings?->settings_json, 'hero_title', 'Everyday pieces, thoughtfully made.') }}

      +

      {{ data_get($store->settings?->settings_json, 'hero_subtitle', 'Timeless wardrobe essentials with an easy, modern fit.') }}

      Shop new arrivals
      diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php index 1ad50e9b..ecb56f1c 100644 --- a/resources/views/livewire/storefront/products/show.blade.php +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -1,3 +1,62 @@ @php($selectedVariant = $product->variants->firstWhere('id', $selectedVariantId)) +@php($selectedMedia = $product->media->firstWhere('id', $selectedMediaId)) @php($soldOut = $selectedVariant?->inventory?->availableQuantity() <= 0 && $selectedVariant?->inventory?->policy?->value !== 'continue') -
      @if ($product->media->first()?->url){{ $product->title }}@else
      @endif
      @foreach ($product->media as $media)@endforeach

      {{ $product->vendor }}

      {{ $product->title }}

      €{{ number_format(($selectedVariant?->price_amount ?? 0) / 100, 2) }}

      @if(($selectedVariant?->compare_at_amount ?? 0) > ($selectedVariant?->price_amount ?? 0))

      €{{ number_format($selectedVariant->compare_at_amount / 100, 2) }}

      Sale@endif
      {!! app(\App\Support\HtmlSanitizer::class)->sanitize($product->description) !!}
      @foreach ($product->options as $option)
      {{ $option->name }}: {{ $option->values->firstWhere('id', $selectedOptions[$option->id] ?? null)?->value }}
      @foreach ($option->values as $value)@php($available = $product->variants->contains(fn ($variant): bool => $variant->optionValues->contains('id', $value->id)))@endforeach
      @endforeach@if ($soldOut)

      Sold out

      @elseif ($selectedVariant?->inventory?->policy?->value === 'continue' && $selectedVariant->availableQuantity() <= 0)

      Available on backorder

      @endif
      @error('quantity')

      {{ $message }}

      @enderror@if ($message)

      {{ $message }}

      @endif
      + +
      +
      +
      + @if ($selectedMedia?->url) + {{ $product->title }} + @else +
      + @endif +
      +
      + @foreach ($product->media as $media) + + @endforeach +
      +
      + +
      + +

      {{ $product->vendor }}

      +

      {{ $product->title }}

      +
      +

      €{{ number_format(($selectedVariant?->price_amount ?? 0) / 100, 2) }}

      + @if (($selectedVariant?->compare_at_amount ?? 0) > ($selectedVariant?->price_amount ?? 0)) +

      €{{ number_format($selectedVariant->compare_at_amount / 100, 2) }}

      + Sale + @endif +
      +
      {!! app(\App\Support\HtmlSanitizer::class)->sanitize($product->description) !!}
      + + @foreach ($product->options as $option) +
      + {{ $option->name }}: {{ $option->values->firstWhere('id', $selectedOptions[$option->id] ?? null)?->value }} +
      + @foreach ($option->values as $value) + @php($available = $product->variants->contains(fn ($variant): bool => $variant->optionValues->contains('id', $value->id))) + + @endforeach +
      +
      + @endforeach + + @if ($soldOut) +

      Sold out

      + @elseif ($selectedVariant?->inventory?->policy?->value === 'continue' && $selectedVariant->availableQuantity() <= 0) +

      Available on backorder

      + @endif + +
      + + + +
      + @error('quantity')

      {{ $message }}

      @enderror + @if ($message)

      {{ $message }}

      @endif +
      +
      diff --git a/routes/api.php b/routes/api.php index ffe38387..cccd3a4e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -45,36 +45,38 @@ Route::post('products', [AdminController::class, 'storeProduct'])->middleware('role.check:owner,admin,staff'); Route::get('products/{productId}', [AdminController::class, 'showProduct']); Route::put('products/{productId}', [AdminController::class, 'updateProduct'])->middleware('role.check:owner,admin,staff'); - Route::delete('products/{productId}', [AdminController::class, 'deleteProduct'])->middleware('role.check:owner,admin,staff'); + Route::delete('products/{productId}', [AdminController::class, 'deleteProduct'])->middleware('role.check:owner,admin'); Route::post('products/{productId}/media/presign-upload', [PlatformController::class, 'presignMediaUpload'])->middleware('role.check:owner,admin,staff'); Route::post('products/{productId}/media/{mediaId}/complete', [PlatformController::class, 'completeMediaUpload'])->middleware('role.check:owner,admin,staff'); - Route::get('collections', [AdminController::class, 'collections']); + Route::get('collections', [AdminController::class, 'collections'])->middleware('role.check:owner,admin,staff'); Route::post('collections', [AdminController::class, 'storeCollection'])->middleware('role.check:owner,admin,staff'); Route::put('collections/{collectionId}', [AdminController::class, 'updateCollection'])->middleware('role.check:owner,admin,staff'); - Route::delete('collections/{collectionId}', [AdminController::class, 'deleteCollection'])->middleware('role.check:owner,admin,staff'); + Route::delete('collections/{collectionId}', [AdminController::class, 'deleteCollection'])->middleware('role.check:owner,admin'); Route::get('orders', [AdminController::class, 'orders']); Route::get('orders/{orderId}', [AdminController::class, 'showOrder']); Route::get('customers', [AdminController::class, 'customers']); - Route::get('discounts', [AdminController::class, 'discounts']); + Route::get('discounts', [AdminController::class, 'discounts'])->middleware('role.check:owner,admin,staff'); Route::post('discounts', [AdminController::class, 'storeDiscount'])->middleware('role.check:owner,admin,staff'); Route::put('discounts/{discountId}', [AdminController::class, 'updateDiscount'])->middleware('role.check:owner,admin,staff'); Route::delete('discounts/{discountId}', [AdminController::class, 'deleteDiscount'])->middleware('role.check:owner,admin'); - Route::get('shipping/zones', [AdminController::class, 'shippingZones']); + Route::get('shipping/zones', [AdminController::class, 'shippingZones'])->middleware('role.check:owner,admin'); Route::post('shipping/zones', [AdminController::class, 'storeShippingZone'])->middleware('role.check:owner,admin'); Route::put('shipping/zones/{zoneId}', [AdminController::class, 'updateShippingZone'])->middleware('role.check:owner,admin'); Route::post('shipping/zones/{zoneId}/rates', [AdminController::class, 'storeShippingRate'])->middleware('role.check:owner,admin'); - Route::get('tax/settings', [AdminController::class, 'taxSettings']); + Route::get('tax/settings', [AdminController::class, 'taxSettings'])->middleware('role.check:owner,admin'); Route::put('tax/settings', [AdminController::class, 'updateTaxSettings'])->middleware('role.check:owner,admin'); - Route::get('pages', [AdminController::class, 'pages']); + Route::get('pages', [AdminController::class, 'pages'])->middleware('role.check:owner,admin,staff'); Route::post('pages', [AdminController::class, 'storePage'])->middleware('role.check:owner,admin,staff'); Route::put('pages/{pageId}', [AdminController::class, 'updatePage'])->middleware('role.check:owner,admin,staff'); Route::delete('pages/{pageId}', [AdminController::class, 'deletePage'])->middleware('role.check:owner,admin'); Route::post('themes', [AdminController::class, 'storeTheme'])->middleware('role.check:owner,admin'); Route::post('themes/{themeId}/publish', [AdminController::class, 'publishTheme'])->middleware('role.check:owner,admin'); Route::put('themes/{themeId}/settings', [AdminController::class, 'updateThemeSettings'])->middleware('role.check:owner,admin'); - Route::post('search/reindex', [AdminController::class, 'reindex'])->middleware('role.check:owner,admin,staff'); - Route::get('search/status', [AdminController::class, 'searchStatus']); - Route::get('analytics/summary', [AdminController::class, 'analyticsSummary']); + Route::post('search/reindex', [AdminController::class, 'reindex'])->middleware('role.check:owner,admin'); + Route::get('search/status', [AdminController::class, 'searchStatus'])->middleware('role.check:owner,admin'); + Route::get('analytics/summary', [AdminController::class, 'analyticsSummary'])->middleware('role.check:owner,admin,staff'); + Route::post('exports/orders', [AdminController::class, 'createOrderExport']); + Route::get('exports/{exportId}', [AdminController::class, 'showOrderExport']); Route::post('orders/{orderId}/fulfillments', [AdminController::class, 'fulfillOrder'])->middleware('role.check:owner,admin,staff'); Route::post('orders/{orderId}/refunds', [AdminController::class, 'refundOrder'])->middleware('role.check:owner,admin'); }); diff --git a/specs/progress.md b/specs/progress.md index 45424975..4e6337ca 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -15,16 +15,16 @@ The self-contained multi-tenant shop is implemented across the database, commerc ## Verification -- `php artisan test --compact`: 87 passing tests, 296 assertions. +- `php artisan test --compact`: 95 passing tests, 326 assertions. - `vendor/bin/pint --dirty --format agent`: passing. - PHP lint across application, database, routes, configuration, bootstrap, and tests: passing. -- `php artisan migrate:status --no-interaction`: all migrations applied, including tenant-scoped password reset tokens, webhook contract alignment, normalized theme settings, and store invitations. +- `php artisan migrate:status --no-interaction`: all migrations applied, including tenant-scoped password reset tokens, webhook contract alignment, normalized theme settings, store invitations, order exports, platform-admin flags, and encrypted payment payload storage. - `php artisan view:cache --no-interaction`: passing. - `npm run build`: passing. -- Playwright MCP browser smoke checks: storefront home, product detail, cart drawer, collections, search, cart, responsive mobile layout, admin login/dashboard/products/orders/developers/settings, Flux domain modal, and mobile admin navigation; no application console errors observed. +- Playwright MCP browser smoke checks: storefront home, product detail, cart drawer quantity updates and checkout navigation, collections, search suggestions, responsive mobile navigation, dark mode, admin login/dashboard/products/orders/settings, and tenant-correct storefront/admin hosts; no application console errors observed. - The Pest browser plugin is not installed and dependencies were intentionally left unchanged; the browser coverage above was executed manually through Playwright MCP. ## Final audit -- Independent read-only audit found and closed platform API, nested product persistence, media processing, webhook contract, and domain settings gaps. -- Follow-up read-only verification confirmed the residual media lifecycle and nested product response findings are resolved. +- Independent read-only audits found and closed platform API, nested product persistence, media processing, webhook contract, domain settings, authentication, checkout, analytics, and storefront interaction gaps. +- Final browser verification also corrected Livewire admin-auth tenant resolution and confirmed the corrected flow end to end. diff --git a/tests/Feature/ApiTokenTest.php b/tests/Feature/ApiTokenTest.php index 88986236..049f0f36 100644 --- a/tests/Feature/ApiTokenTest.php +++ b/tests/Feature/ApiTokenTest.php @@ -49,3 +49,23 @@ ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products") ->assertForbidden(); }); + +test('platform management is restricted to platform administrators', function (): void { + $member = User::factory()->create(); + $member->stores()->attach($this->store, ['role' => 'admin']); + $platformToken = $this->admin->createToken('platform-manager', ['manage-platform'])->plainTextToken; + + $this->withToken($platformToken) + ->postJson('http://shop.test/api/admin/v1/platform/organizations', ['name' => 'Allowed Platform Org', 'billing_email' => 'allowed@example.test']) + ->assertCreated(); +}); + +test('non-platform admins cannot use platform management even with the ability', function (): void { + $member = User::factory()->create(); + $member->stores()->attach($this->store, ['role' => 'admin']); + $token = $member->createToken('platform-attempt', ['manage-platform'])->plainTextToken; + + $this->withToken($token) + ->postJson('http://shop.test/api/admin/v1/platform/organizations', ['name' => 'Blocked Platform Org', 'billing_email' => 'blocked@example.test']) + ->assertForbidden(); +}); diff --git a/tests/Feature/CommerceFlowTest.php b/tests/Feature/CommerceFlowTest.php index 8d7ad7d3..fdb9ddd6 100644 --- a/tests/Feature/CommerceFlowTest.php +++ b/tests/Feature/CommerceFlowTest.php @@ -1,6 +1,8 @@ firstOrFail(); $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]); - $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'credit_card', 'card_number' => '4000000000000002', 'card_expiry' => '12/28', 'card_cvc' => '123', 'card_holder' => 'Declined Tester'])->assertUnprocessable(); + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'credit_card', 'card_number' => '4000000000000002', 'card_expiry' => '12/28', 'card_cvc' => '123', 'card_holder' => 'Declined Tester'])->assertUnprocessable()->assertJsonPath('error_code', 'card_declined'); expect(InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe($inventoryBefore) ->and(Order::query()->where('email', 'declined@example.test')->exists())->toBeFalse(); }); +test('automatic discounts stack sequentially during checkout pricing', function (): void { + $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); + Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => null, 'type' => 'automatic', 'value_type' => 'percent', 'value_amount' => 10, 'status' => 'active', 'starts_at' => now()->subMinute(), 'ends_at' => now()->addDay(), 'rules_json' => []]); + Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => null, 'type' => 'automatic', 'value_type' => 'fixed', 'value_amount' => 100, 'status' => 'active', 'starts_at' => now()->subMinute(), 'ends_at' => now()->addDay(), 'rules_json' => []]); + + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]); + $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'automatic@example.test']) + ->assertCreated() + ->json(); + + expect($checkout['totals']['discount'])->toBeGreaterThan(100) + ->and($checkout['totals']['discount_allocations'])->not->toBeEmpty(); +}); + test('stale cart versions return a conflict response', function (): void { $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail(); $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); @@ -119,6 +136,14 @@ ->assertNotFound(); }); +test('guest cart endpoints only expose active carts', function (): void { + $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json(); + Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']); + + $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}") + ->assertNotFound(); +}); + test('cart and checkout APIs return domain errors as unprocessable responses', function (): void { $soldOutVariant = Product::query()->where('handle', 'sold-out-limited-tee')->firstOrFail()->variants()->firstOrFail(); $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json(); diff --git a/tests/Feature/ContractBehaviorTest.php b/tests/Feature/ContractBehaviorTest.php index 91ce8e12..c4dbd01b 100644 --- a/tests/Feature/ContractBehaviorTest.php +++ b/tests/Feature/ContractBehaviorTest.php @@ -105,12 +105,11 @@ (new WebhookService)->dispatch($store, 'order.created', ['order_id' => 1001]); Http::assertSent(function ($request): bool { - $timestamp = $request->header('X-Platform-Timestamp')[0]; $body = $request->body(); return $request->header('Content-Type')[0] === 'application/json' && $request->header('X-Platform-Event')[0] === 'order.created' - && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', $timestamp.'.'.$body, 'test-secret'); + && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', $body, 'test-secret'); }); expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered'); diff --git a/tests/Feature/OrderExportsAndDomainEventsTest.php b/tests/Feature/OrderExportsAndDomainEventsTest.php new file mode 100644 index 00000000..e9ef9cea --- /dev/null +++ b/tests/Feature/OrderExportsAndDomainEventsTest.php @@ -0,0 +1,50 @@ + 'array']); + $this->seed(ShopSeeder::class); + $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + app()->instance('current_store', $this->store); +}); + +test('domain order events dispatch signed webhook deliveries', function (): void { + Http::fake(['https://hooks.test/*' => Http::response([], 200)]); + $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'secret', 'status' => 'active']); + $order = Order::query()->firstOrFail()->load('store'); + + OrderCreated::dispatch($order); + + Http::assertSent(fn ($request): bool => $request->header('X-Platform-Event')[0] === 'order.created'); + expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->where('event', 'order.created')->exists())->toBeTrue(); +}); + +test('admin order exports queue and expose generated csv status', function (): void { + Storage::fake('public'); + $token = $this->admin->createToken('order-exporter', ['read-orders'])->plainTextToken; + + $response = $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/exports/orders", ['format' => 'csv'])->assertStatus(202); + $exportId = $response->json('export_id'); + $export = OrderExport::withoutGlobalScopes()->findOrFail($exportId); + + expect($export->status)->toBe('completed')->and($export->row_count)->toBeGreaterThan(0); + Storage::disk('public')->assertExists($export->storage_key); + $this->withToken($token)->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/exports/{$exportId}") + ->assertOk() + ->assertJsonPath('data.status', 'completed') + ->assertJsonPath('data.row_count', $export->row_count); +}); diff --git a/tests/Feature/PlatformApiAndMediaTest.php b/tests/Feature/PlatformApiAndMediaTest.php index 243a5c5c..66e8c53f 100644 --- a/tests/Feature/PlatformApiAndMediaTest.php +++ b/tests/Feature/PlatformApiAndMediaTest.php @@ -110,7 +110,7 @@ expect($media->refresh()->status)->toBe('ready') ->and($media->width)->toBe(640) ->and($media->height)->toBe(400) - ->and($media->metadata['variants'])->toHaveKeys(['original', 'thumbnail', 'medium', 'large']) + ->and($media->metadata['variants'])->toHaveKeys(['original', 'thumbnail', 'small', 'medium', 'large']) ->and($media->checksum)->toBe(hash('sha256', $contents)); $failed = ProductMedia::create(['product_id' => $product->getKey(), 'type' => 'image', 'path' => 'missing.jpg', 'status' => 'processing']); diff --git a/tests/Feature/SearchAnalyticsWebhookTest.php b/tests/Feature/SearchAnalyticsWebhookTest.php index 18a0a467..80f863c8 100644 --- a/tests/Feature/SearchAnalyticsWebhookTest.php +++ b/tests/Feature/SearchAnalyticsWebhookTest.php @@ -34,6 +34,19 @@ ->and(SearchQuery::query()->where('query', 'classic')->count())->toBe(1); }); +test('storefront search exposes the documented result and pagination contract', function (): void { + $this->getJson('http://shop.test/api/storefront/v1/search?q=classic') + ->assertOk() + ->assertJsonPath('query', 'classic') + ->assertJsonPath('results.0.handle', 'classic-cotton-t-shirt') + ->assertJsonStructure(['results', 'facets', 'pagination' => ['current_page', 'total']]); + + $this->getJson('http://shop.test/api/storefront/v1/search/suggest?q=c') + ->assertOk() + ->assertJsonPath('query', 'c') + ->assertJsonStructure(['suggestions']); +}); + test('product changes are synchronized to the FTS index and autocomplete', function (): void { $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); $product->update(['title' => 'Classic Cotton Tee']); @@ -47,7 +60,7 @@ $analytics = new AnalyticsService; foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) { - $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1'); + $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1', null, null, $date); $event->forceFill(['created_at' => $date])->save(); } @@ -59,7 +72,7 @@ expect($daily->visits_count)->toBe(2) ->and($daily->add_to_cart_count)->toBe(1) ->and($daily->checkout_started_count)->toBe(1) - ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(4); + ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(5); }); test('webhooks are signed and delivered with platform headers', function (): void { diff --git a/tests/Feature/Tenancy/ResolveStoreTest.php b/tests/Feature/Tenancy/ResolveStoreTest.php index 77668445..58f299df 100644 --- a/tests/Feature/Tenancy/ResolveStoreTest.php +++ b/tests/Feature/Tenancy/ResolveStoreTest.php @@ -101,3 +101,13 @@ ->get('/admin/tenant-resolution-test') ->assertForbidden(); }); + +test('admin auth livewire updates do not require a resolved store', function () { + $request = \Illuminate\Http\Request::create('/livewire-test123/update', 'POST', [], [], [], [ + 'HTTP_REFERER' => 'http://admin.example.test/admin/login', + ]); + + expect(app(\App\Http\Middleware\ResolveStore::class)->handle($request, fn (): \Symfony\Component\HttpFoundation\Response => response('ok'))) + ->getContent() + ->toBe('ok'); +}); From 3c88a008d55c6b373ddbcc740139a77515061c35 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 21 Aug 2026 12:54:13 +0200 Subject: [PATCH 8/9] Enable stateful Sanctum API requests --- bootstrap/app.php | 2 ++ tests/Feature/SanctumConfigurationTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+) create mode 100644 tests/Feature/SanctumConfigurationTest.php diff --git a/bootstrap/app.php b/bootstrap/app.php index dd699df1..a1660b6b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,8 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->statefulApi(); + $middleware->redirectGuestsTo(function (\Illuminate\Http\Request $request): string { if ($request->is('admin/*') || $request->is('admin')) { return route('admin.login'); diff --git a/tests/Feature/SanctumConfigurationTest.php b/tests/Feature/SanctumConfigurationTest.php new file mode 100644 index 00000000..1c5c78a6 --- /dev/null +++ b/tests/Feature/SanctumConfigurationTest.php @@ -0,0 +1,11 @@ +getRoutes()->getRoutes()) + ->first(fn ($route): bool => $route->uri() === 'api/admin/v1/stores/{storeId}/me'); + + expect(app('router')->gatherRouteMiddleware($route)) + ->toContain(EnsureFrontendRequestsAreStateful::class); +}); From 0f53b070a70fdd1bedf6e628436c7d5748d9d91a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 21 Aug 2026 13:06:21 +0200 Subject: [PATCH 9/9] Add Laravel Sanctum dependency --- composer.json | 1 + composer.lock | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 547a4793..556db2c7 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "livewire/livewire": "^4.0" diff --git a/composer.lock b/composer.lock index 5b977876..02ebe528 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": "4038df3fd598c391599a1e9a16d424b1", + "content-hash": "5af3a037f5c8b5b7a227323a0ec6c8ee", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9",