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..c9b549fe --- /dev/null +++ b/.agents/skills/infer-conventions/SKILL.md @@ -0,0 +1,105 @@ +--- +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. Only run this skill when the user explicitly asks for it; never start a sweep as part of another task. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand." +disable-model-invocation: true +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..311ab844 --- /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) | +| Tests: coverage, factories, fakes, and assertions | the `testing-best-practices` skill | +| 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..54dd783d --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Best Practices + +## Select Single Relationship Values with Subqueries + +When only one value from a has-many relationship is needed, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. This selects the value as part of the main query without an additional relationship query. + +```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 with a Subquery Foreign Key + +The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still executes a separate query, but it avoids loading the full has-many collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class, 'last_login_id'); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Combine Related Counts with Conditional Aggregates + +Combine several counts over the same filtered data set into one query by using conditional aggregates. Use `toBase()` when only scalar values are needed and model hydration provides no benefit. Confirm the expression syntax against the application's database engine. + +```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(); +``` + +## Reuse Loaded Parent Models with `setRelation()` + +When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. This avoids an additional lazy-loading query for each child. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Compare `whereHas()` with an `IN` Subquery + +`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. Measure both forms with representative data; neither subquery loads its result set into PHP memory. + +Option using `EXISTS`: + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Option using `IN`: + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Measure Two Simple Queries Against One Complex Query + +Two targeted queries can outperform one complex correlated subquery or join when the first query is highly selective. They also add a database round trip, can transfer a large identifier list, and do not provide a single-query consistency snapshot. Decide from query plans and production-like measurements. + +## Design Composite Indexes for the Query + +For common multi-column sorts, consider a composite index whose column order supports the query's filters and ordering. Database engines may combine indexes or choose an explicit sort, so matching the `ORDER BY` list alone does not guarantee that an index will be used. Verify the query plan. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query that this index may support +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Consider a Correlated Subquery for Has-Many Ordering + +When sorting by one value from a has-many relationship, a direct join can duplicate parent rows unless it first reduces the related table to one row per parent. A correlated subquery in `orderBy()` is often simpler, but its performance depends on the query plan and supporting indexes. + +```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..5e7af23b --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,219 @@ +# Architecture Best Practices + +## Extract Focused Business Operations + +Extract a discrete business operation into an action class when doing so makes the operation easier to reuse or test. An action class has no special meaning to Laravel; follow the project's naming and invocation conventions. + +```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; + } +} +``` + +## Inject Required Dependencies + +Prefer constructor injection for dependencies required throughout an object's lifetime. Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit. + +Hidden dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Injected dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request, OrderService $service) + { + return $service->create($request->validated()); + } +} +``` + +## Depend on Contracts at Boundaries + +Depend on contracts at system boundaries, such as payment gateways, notification channels, and external services, when testability or interchangeable implementations justify the abstraction. + +Concrete boundary dependency: + +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Contract boundary 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); +``` + +## Specify a Deterministic Sort Order + +Without an explicit `ORDER BY`, row order is undefined. Choose an order that matches the feature, and add a unique tie-breaker when stable pagination matters. + +Unspecified order: + +```php +$posts = Post::paginate(); +``` + +Newest first with a stable tie-breaker: + +```php +$posts = Post::query() + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems. + +```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 database lock is held. +}); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer multibyte-aware functions such as `mb_strlen()` and `mb_strtolower()` for UTF-8 text. For example, `strlen()` counts bytes, while `strtolower()` is not multibyte-aware. + +Incorrect: + +```php +strlen('José'); // 5 bytes, not 4 characters +strtolower('MÜNCHEN'); // Does not lowercase Ü +``` + +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 work that does not need retries or crash durability, consider `defer()` instead of dispatching a job. During an HTTP request, the callback normally runs after the response has been sent but remains in the same PHP process. + +Queued and durable: + +```php +dispatch(new LogPageView($page)); +``` + +Deferred in the current process: + +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use a queued job when the work needs retries, queue controls, or durability across process failures. + +## Use `Context` for Request-Scoped Data + +The `Context` facade makes contextual data available across the current execution lifecycle without manually passing arguments through every layer. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Later in the same execution lifecycle +$tenantId = Context::get('tenant_id'); +``` + +Visible context is added to log context, and both visible and hidden context are captured and restored for queued jobs. Use `Context::addHidden()` for data that should propagate to queued jobs without appearing in logs. Do not place secrets in context unless that propagation is intended. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations concurrently through Laravel's configured concurrency driver. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +With a process-based driver, each closure runs in a separate PHP process that boots the application. Use concurrency when independent database queries, HTTP client calls, or computations benefit enough to offset process and serialization overhead. The `sync` driver executes closures sequentially and is useful primarily during testing. + +## Follow Framework Conventions + +Follow Laravel conventions unless the domain or an existing schema requires an override. + +Customized schema: + +```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'); + } +} +``` + +Conventional schema: + +```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..4ea2c6c3 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade and View Best Practices + +## Use `$attributes->merge()` in Component Templates + +Use the component attribute bag so callers can add attributes. `merge()` combines default attributes with caller-provided values; class values receive special merging behavior. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders repeatedly, `@push` adds its script on every render. Use a consistently named `@pushOnce` block to add that content once per rendered response. + +## Prefer Components for Explicit Interfaces + +Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include remains suitable for a small partial that intentionally uses the current view data; pass an explicit data array when implicit variable sharing would obscure its dependencies. + +## Share Compatible View Data with a View Composer + +Use a view composer to centralize data needed whenever one or more named Blade views are rendered. Keep the composer compatible with every view it targets, and avoid broad wildcards when views require different data shapes. A view composer runs when Laravel renders the matching view; it does not supply data to JSON, streamed, or other non-view responses. + +## Return Blade Fragments for Partial Rendering + +A route can return either a full view or a named fragment for clients such as htmx or Turbo. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Share Parent Component Props with `@aware` + +Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. It does not expose an ancestor's default prop value unless that value was passed through the attribute bag. 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..cd2ffd60 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,100 @@ +# Caching Best Practices + +## Use `Cache::remember()` for Cache-Aside Reads + +`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value; use an atomic lock when duplicate computation must be prevented. + +The manual version below incorrectly treats valid falsy values, such as `false` or `0`, as cache misses. + +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()); +``` + +## Consider `Cache::flexible()` for Stale-While-Revalidate + +For frequently read keys, `Cache::flexible()` can serve stale data during a defined stale period and register a deferred refresh. During an HTTP request, that refresh normally runs after the response; it is not a durable background job. Once the stale period has elapsed, the request recomputes the value synchronously. + +Synchronous expiration: + +```php +Cache::remember('users', 300, fn () => User::all()); +``` + +Stale-while-revalidate tradeoff: + +```php +Cache::flexible('users', [300, 600], fn () => User::all()); +``` + +This value is fresh for five minutes and may be served stale until ten minutes after it was cached. + +## Use `Cache::memo()` to Avoid Redundant Hits Within an Execution + +If the same cache key is read repeatedly during one request or job, `memo()` decorates a cache store and retains resolved values in memory for that execution. + +```php +$settings = Cache::memo()->get('settings'); +``` + +Repeated reads through the same memoized store avoid additional store lookups. Writes through the memoized store update or invalidate its in-memory values as appropriate. + +## Use Cache Tags to Invalidate Related Groups + +Tags group related entries for invalidation without tracking each key. Cache tags are not supported by the `file`, `dynamodb`, or `database` drivers; confirm support before choosing a store. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` atomically writes a value only when the key does not already exist. + +Incorrect: + +```php +if (! Cache::has('lock')) { + Cache::put('lock', true, 10); +} +``` + +Correct: + +```php +Cache::add('lock', true, 10); +``` + +Use `Cache::lock()` rather than an ordinary cache key when lock ownership and safe release are required. + +## Use `once()` for In-Process Memoization + +`once()` memoizes a callback's return value for the current request or job. Calls made from an object instance are scoped to that instance. Unlike `Cache::memo()`, `once()` does not read from an external cache store. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Repeated calls return the memoized result without rerunning the callback. Use `once()` for repeated computation within one execution. Use `Cache::memo()` to memoize access to an underlying store that can also persist values across executions. + +## Configure Failover Cache Stores in Production + +The failover driver tries each configured store in order when a store operation throws an exception. It does not consult later stores for an ordinary cache miss, and data is not replicated between stores. + +```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..211fec11 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,72 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Explicit closure: + +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Concise equivalent: + +```php +$users->each->markAsVip(); +``` + +Higher-order messages are available for supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer. + +## Choose Between `cursor()` and `lazy()` + +`cursor()` executes one query and hydrates models individually, but it cannot eager load relationships. The database driver's result buffering can still consume substantial memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable. + +`lazy()` executes multiple chunked queries and returns a flat `LazyCollection`. It supports eager loading relationships for each chunk and avoids holding one database cursor open for the entire iteration. + +With relationships: + +```php +User::with('roles')->lazy()->each(function (User $user) { + // The roles for this chunk have been eager loaded. +}); +``` + +Without relationships: + +```php +User::cursor()->each(function (User $user) { + // Process model attributes. +}); +``` + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination, so updates to columns that affect the query can shift rows and cause records to be skipped or processed twice. `lazyById()` paginates by a monotonic key and is safer when updating other columns during iteration. Do not change the pagination key itself while iterating. + +## Use `toQuery()` for Bulk Operations on Collections + +Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause. + +Manual query: + +```php +User::whereIn('id', $users->modelKeys())->update(['active' => false]); +``` + +Collection query: + +```php +$users->toQuery()->update(['active' => false]); +``` + +`toQuery()` requires a non-empty Eloquent collection whose models are of the same type. Like other bulk Eloquent updates, it does not dispatch per-model update events, so use it only when those events are not required. + +## Use `#[CollectedBy]` for Custom Collection Classes + +The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override. + +```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..82691c42 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,85 @@ +# Configuration Best Practices + +## Read Environment Variables in Configuration Files + +Call `env()` only from configuration files. After configuration is cached, Laravel does not load the application's `.env` file, so application code should read configuration values through `config()`. + +Incorrect: + +```php +$key = env('API_KEY'); +``` + +Correct: + +```php +// config/services.php +return [ + 'key' => env('API_KEY'), +]; + +// Application code +$key = config('services.key'); +``` + +## Protect Production Secrets + +Do not commit plaintext production secrets. Laravel can encrypt an environment file so its encrypted form can be stored safely, while deployment platforms can supply secrets through their native secret stores. + +Incorrect: + +```bash +# A plaintext .env file committed to the repository +STRIPE_SECRET= +AWS_SECRET_ACCESS_KEY= +``` + +Encrypted environment file: + +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For hosted deployments, consider the platform's native secret store, such as AWS Secrets Manager or Vault, and inject secrets at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: + +```php +if (env('APP_ENV') === 'production') { + // ... +} +``` + +Correct: + +```php +if (app()->isProduction()) { + // ... +} + +if (App::environment('production')) { + // ... +} +``` + +## Name Repeated Domain Values + +Use an enum or class constant when a domain value is repeated or represents a constrained set. A one-off string literal does not always need a named constant. + +```php +// Repeated literal +return $this->type === 'normal'; + +// Named domain value +return $this->type === self::TYPE_NORMAL; +``` + +If the application supports localization, put user-facing strings in language files and retrieve them with `__()`. Simple literals are reasonable for applications that intentionally do not support multiple languages. + +```php +// In a localized application +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..3f339770 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,189 @@ +# Database Performance Best Practices + +## Eager Load Relationships Before Iterating + +When a relationship will be accessed for many models, eager load it with `with()` to avoid running one initial query plus one relationship query per model, commonly called an N+1 query pattern. Lazy loading is reasonable when the relationship may not be needed or only one model is involved. + +Lazy-loaded version: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Eager-loaded version: + +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads when large columns are unnecessary. Include the related model's primary key and every column Eloquent needs to match the relationship. In this example, `users.id` and `posts.user_id` match posts to users, while selecting `posts.id` preserves each related model's primary 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()); +} +``` + +By default, accessing an unloaded relationship then throws a `LazyLoadingViolationException`. Applications can customize violation handling with `handleLazyLoadingViolationUsing()`. + +## Select Only Needed Columns + +Select only the columns the operation needs when omitting large text, binary, or JSON columns provides a meaningful benefit. + +All columns: + +```php +$posts = Post::with('author')->get(); +``` + +Selected columns: + +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When limiting selected columns, retain every key Eloquent needs for matching. A `belongsTo` relationship needs its foreign key on the parent query and the owner's key on the related query. A `hasMany` relationship needs the parent's local key and the related model's foreign key. + +## Process Large Data Sets Incrementally + +Use chunking or lazy iteration when loading an entire result set would exceed the application's practical memory budget. + +Loads the complete result set: + +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Processes bounded chunks: + +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, whose result positions can shift as rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +For read-only, attribute-only iteration, `cursor()` hydrates models individually from one query, although some database drivers still buffer raw results. Use `lazy()` when relationships must be eager loaded in chunks, and use `lazyById()` or `chunkById()` when updates can affect query membership. See the collection rules for detailed tradeoffs. + +## Add Indexes for Measured Query Patterns + +Design indexes around frequent, performance-sensitive query patterns. A column's presence in `WHERE`, `ORDER BY`, `JOIN`, or `GROUP BY` does not by itself justify an index; selectivity, write cost, existing indexes, and the database query plan all matter. + +Schema without an application-specific query index: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Schema optimized for `WHERE status = ? ORDER BY created_at`: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Confirm composite index column order and effectiveness with production-like data and the database's query-plan tools. Also check whether the database already created an index to support a foreign key before adding another one. + +## Count Relationships Without Loading Them + +Use `withCount()` when only relationship counts are needed; loading and hydrating every related model wastes memory. + +Loads related models: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Selects relationship counts: + +```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(); +``` + +## Keep Queries Out of Blade Templates + +Prepare data before rendering a Blade template, such as in a controller, query service, or view composer. This keeps query behavior visible and testable. + +Query in the template: + +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Data prepared before rendering: + +```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..5a89510d --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,158 @@ +# Eloquent Best Practices + +## Define Precise Relationship Types + +Define the relationship that matches the database association, and declare its concrete return type. + +```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. + +Duplicated constraints: + +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Reusable local scope: + +```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. + +Global scope tradeoff: + +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} + +// Admin panels, reports, and jobs now omit drafts unless the scope is removed. +``` + +Explicit local scope: + +```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 and Time Attributes + +Cast a date or timestamp attribute when application code should treat it as a Carbon instance. Eloquent already casts the conventional `created_at` and `updated_at` timestamps. + +Manual parsing in the template: + +```blade +{{ Carbon::parse($order->ordered_at)->toDateString() }} +``` + +Model cast: + +```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 + +`whereBelongsTo()` expresses the relationship constraint without manually specifying its foreign key. + +Foreign key constraint: + +```php +Post::where('user_id', $user->id)->get(); +``` + +Relationship-aware constraint: + +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Keep Application Queries Model-Aware + +Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL legitimately require table names, so use them when their lower-level behavior is intentional. + +Lower-level alternatives: + +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Model-aware queries: + +```php +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +When a query builder operation should follow a model's configured table name, use `(new User)->getTable()`. For complex joins or raw SQL, explicit table names may be clearer; keep those references covered by tests when schema changes are possible. + +In migrations, use explicit table names rather than application models. Migrations are historical snapshots, while models and their scopes can change after a migration is deployed. 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..afcdf4ac --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,77 @@ +# Error Handling Best Practices + +## Choose Where to Report and Render Exceptions + +Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern already established by the project. + +Exception methods keep behavior beside the exception definition: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void + { + // Send the exception to a custom reporter. + } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { + // Send the exception to a custom reporter. + }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +An exception's `report()` method suppresses Laravel's default reporting unless it returns `false`. A report callback allows default reporting unless it returns `false` or is chained with `stop()`. Use `ShouldntReport` or `dontReport()` when the handler should not report an exception at all. By contrast, returning `false` from a `render()` method or render callback defers to Laravel's default rendering. + +## Mark Exceptions the Handler Should Not Report + +Implementing `ShouldntReport` prevents Laravel's exception handler from reporting that exception type and keeps the policy visible on the class. It does not prevent application code from logging the exception explicitly. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exception Reports + +A failing integration can flood logs or error tracking. Configure `throttle()` with a `Lottery` or `Limit` result to sample or rate-limit matching exception reports. Choose keys deliberately when separate exception classes, tenants, or integrations need independent limits. + +## Prevent Duplicate Reports of One Exception Instance + +Enable `dontReportDuplicates()` when the same exception object may pass through multiple `report($exception)` calls. It deduplicates by object identity, not by exception class or message. + +## Define JSON Rendering for API Routes + +Laravel normally uses request content negotiation to decide whether to render an exception as JSON. If the application's API contract requires JSON regardless of the `Accept` header, define that policy explicitly for the relevant routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to an exception through `context()`. Laravel merges that data into the exception's log context when the handler reports it. + +```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..a8cce31a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events and Notifications Best Practices + +## Rely on Event Discovery + +Laravel discovers listeners in the configured listener directories by inspecting type-hinted event arguments on `handle()` or `__invoke()` methods. Register listeners manually only when discovery is disabled, the listener is outside those directories, or explicit registration is clearer. + +## Cache Event Discovery During Production Deployment + +Cache discovered listeners during production deployment with `php artisan optimize` or `php artisan event:cache`. Rebuild the cache whenever listener definitions change. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +When an event is dispatched inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. This affects synchronous and queued listeners; it is not limited to queue timing. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Queue Slow Notifications + +Queue notifications that call external services, such as email, text messaging, or Slack, when they do not need to complete before the response. Keep a notification synchronous when immediate completion or failure feedback is part of the operation. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Dispatch Queued Notifications After Commit + +A queued notification sent inside a database transaction can run before the transaction commits. Call `afterCommit()` on the queued notification, or enable the queue connection's `after_commit` option, when its delivery depends on committed data. This setting has no scheduling effect on a synchronous notification. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Different notification channels can have different latency and priority requirements. Implement `viaQueues()` when channels should use 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 + +Implement `HasLocalePreference::preferredLocale()` on a notifiable model when notifications and mailables should use the recipient's locale. Laravel also preserves that locale for queued delivery. An explicit `locale()` call can still override the preference for an individual notification. 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..cc8fdd6a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,157 @@ +# HTTP Client Best Practices + +## Set Explicit Timeouts + +Laravel's HTTP client has a 30-second response timeout by default. Choose response and connection timeouts that fit the service and the calling request or job. Remember that retries can multiply the total elapsed time. + +Less resilient: + +```php +$response = Http::get('https://api.example.com/users'); +``` + +Preferred: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users'); +``` + +Define shared settings in a macro or a dedicated client: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->connectTimeout(3) + ->timeout(10) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Retry Only Safe Operations + +Retry transient connection failures, rate-limit responses, and server errors with an appropriate delay. Retry idempotent requests such as `GET` when the operation can safely run more than once. Retry a state-changing request only when the remote API supports an idempotency key or provides equivalent duplicate protection. + +Unsafe without an idempotency guarantee: + +```php +$response = Http::retry([100, 500, 1000]) + ->post('https://api.example.com/v1/charges', $data); +``` + +Safe for an idempotent request: + +```php +$response = Http::connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->get('https://api.example.com/data'); +``` + +For a supported state-changing API, send a stable idempotency key for every attempt: + +```php +$response = Http::withHeaders(['Idempotency-Key' => $paymentAttempt->uuid]) + ->connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->post('https://api.example.com/v1/charges', $data); +``` + +## Handle Errors Explicitly + +The HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing by default. Inspect the expected statuses or call `throw()` before consuming a success payload. + +Unsafe when a success payload is expected: + +```php +$user = Http::get('https://api.example.com/users/1')->json(); +``` + +Preferred: + +```php +$user = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1') + ->throw() + ->json(); +``` + +Handle expected alternatives explicitly when graceful degradation is required: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Pool Independent Requests + +Use `Http::pool()` when several independent requests can run concurrently. Pooling changes execution time, not error handling; inspect or throw for each response as needed. + +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/users'), + $pool->as('posts')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/posts'), +]); + +$users = $responses['users']->throw()->json(); +$posts = $responses['posts']->throw()->json(); +``` + +## Fake HTTP Requests in Tests + +Use `Http::fake()` for external integrations, and use `Http::preventStrayRequests()` when an unexpected real request should fail the test. Also test timeouts, connection failures, and error responses that the application handles. + +```php +it('syncs a user from the API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + (new UserSyncService)->sync(1); + + Http::assertSent(fn (Request $request) => + $request->url() === 'https://api.example.com/users/1' + ); +}); +``` + +For example, fake a connection failure when testing the integration's failure path: + +```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..f6310437 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,54 @@ +# Mail Best Practices + +## Queue Slow Mail Delivery + +Implement `ShouldQueue` on a mailable when delivery should normally happen in the background. Laravel queues that mailable even when the call site uses `Mail::send()`. + +```php +class OrderShipped extends Mailable implements ShouldQueue +{ + use Queueable, SerializesModels; +} +``` + +Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available. + +## Dispatch Queued Mail After Commit + +A queued mailable dispatched during a database transaction can be processed before the transaction commits. Call `afterCommit()` on the mailable, or enable the queue connection's `after_commit` option, when the mail depends on committed records. + +```php +Mail::to($user)->send( + (new OrderShipped($order))->afterCommit() +); +``` + +If the transaction rolls back, an after-commit mailable is not dispatched. This setting affects queued mail only; it does not defer synchronous delivery. + +## Assert the Delivery Mode + +Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables. + +Incorrect for a mailable that implements `ShouldQueue`: + +```php +Mail::assertSent(OrderShipped::class); +``` + +Correct: + +```php +Mail::assertQueued(OrderShipped::class); +``` + +## Use Markdown Mailables When They Fit + +Markdown mailables render HTML and plain-text versions from Laravel's mail components and support publishable themes. They are useful for conventional transactional messages, but a custom HTML and text pair may be more appropriate for a specialized design. + +```bash +php artisan make:mail OrderShipped --markdown=mail.orders.shipped +``` + +## Separate Content and Delivery Tests + +Test rendered content by instantiating the mailable and using assertions such as `assertSeeInHtml()` and `assertSeeInText()`. Test delivery separately with `Mail::fake()` and `assertSent()` or `assertQueued()` so failures identify the affected behavior. 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..3345402e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,67 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Use `php artisan make:migration` to generate the timestamped filename and migration structure. + +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Define Foreign-Key Constraints Deliberately + +Use `constrained()` when its naming conventions and default actions match the relationship. Specify the table or delete behavior when they do not. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); +$table->foreignId('author_id')->constrained('users'); +``` + +Do not add a duplicate single-column index without checking the database driver's treatment of foreign-key indexes and the indexes already created by the migration. + +## Treat Deployed Migrations as Immutable + +After a migration has run in a shared or production environment, create a new migration for subsequent changes. Editing the old file makes fresh installations differ from upgraded installations. + +For a local migration that has not been shared or deployed, editing and rerunning it may be simpler. + +## Design Indexes for Real Queries + +Add indexes based on query patterns, selectivity, write cost, and the database's ability to use composite indexes. A column appearing in `WHERE`, `ORDER BY`, or `JOIN` does not automatically need its own index. + +Declare each selected index in the schema migration that creates or changes the relevant table. Confirm important indexes with representative data and the database's query plan, and avoid redundant indexes whose leading columns duplicate an existing index without serving a distinct query. See the database performance and advanced query rules for index selection and column-order guidance. + +## Stage Changes That Affect Existing Rows + +Adding a required or unique column to a populated table often needs multiple deployment-safe steps. Add a nullable column, deploy code that can handle both states, backfill existing rows in bounded chunks, then add the required constraint or index after the data is valid. + +Do not assume this migration is safe on a populated table: + +```php +$table->string('slug')->unique(); +``` + +Large backfills are usually better implemented as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when their locking, transaction, and deployment behavior is understood. + +## Mirror Defaults Only When Unsaved Models Need Them + +A database default is applied when a row is inserted, not when a model is instantiated. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions synchronized. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Make Rollbacks Honest + +Implement `down()` when the change can be safely reversed. A rollback that drops populated columns or cannot restore transformed data is destructive even if it is syntactically reversible; document that limitation and prefer a forward-fix migration in production. + +## Keep Migrations Focused + +Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when doing so reduces locks and supports phased deployment, but do not split related operations merely to enforce a blanket separation between data definition and data manipulation. 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..011828a6 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,117 @@ +# Queue and Job Best Practices + +## Keep Reservation Time Longer Than Execution Time + +For queue drivers that use Laravel's `retry_after` setting, configure it to exceed the longest worker or job timeout by a safety margin. When a reservation expires, another worker can reserve the same job while the first process is still running. Keep the worker's `--timeout` several seconds shorter than `retry_after`. + +```php +// Job +public $timeout = 120; + +// config/queue.php for the connection +'retry_after' => 150, +``` + +Amazon Simple Queue Service uses its visibility timeout instead of Laravel's `retry_after`; configure that timeout at the queue level. Because workers can also stop after side effects but before acknowledging a job, make important jobs idempotent even with correct timeout settings. + +## Back Off Transient Failures + +Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures. + +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 4; + + public $backoff = [1, 5, 10]; +} +``` + +Rate-limiting and exception-throttling middleware can release jobs back to the queue. Released attempts may still count toward the maximum attempt limit, so configure `$tries` or `retryUntil()` to allow the intended retry window. + +## Use Unique Jobs for Dispatch Deduplication + +Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock and is not a substitute for idempotent processing or a database constraint. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public $uniqueFor = 3600; + + public function uniqueId(): string + { + return (string) $this->order->id; + } +} +``` + +All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches. + +Use `ShouldBeUniqueUntilProcessing` only when the lock should be released immediately before processing begins, allowing another instance to be dispatched while the first is running: + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // ... +} +``` + +## Handle Terminal Failure When Needed + +Implement `failed()` when the application must update state, alert an operator, or record domain-specific context after all attempts are exhausted. Logging every failure in each job may duplicate the queue system's failure reporting. + +Laravel invokes `failed()` on a new job instance, so mutations made to the job during `handle()` are not available there. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + + Log::error('Podcast processing failed', [ + 'podcast_id' => $this->podcast->id, + 'exception' => $exception, + ]); +} +``` + +## Rate Limit External Calls + +Use queue middleware such as `RateLimited` when jobs share a third-party API quota. Define the named limiter and choose release delays and attempt limits together. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Jobs for Group Coordination + +Use `Bus::batch()` to monitor a group of jobs and run callbacks when the batch completes or encounters failures. A batch is not a database transaction: completed jobs are not rolled back when another job fails. By default, one failed job cancels the batch; call `allowFailures()` only when partial failure is acceptable. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) + ->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) + ->catch(fn (Batch $batch, Throwable $exception) => Log::error('Import batch failed', [ + 'exception' => $exception, + ])) + ->dispatch(); +``` + +## Configure Time-Based Retry Limits Deliberately + +Use `retryUntil()` as the time-based alternative to a maximum attempt count. Laravel may attempt the job any number of times until this deadline, subject to other failure conditions such as maximum exceptions. The method takes precedence over attempt-based limits, so setting `$tries = 0` is not required. + +```php +public function retryUntil(): DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use Horizon for Redis Queue Operations + +Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers. 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..604a8d96 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,106 @@ +# Routing and Controller Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint. + +Instead of manual lookup: + +```php +public function show(int $id): View +{ + $post = Post::findOrFail($id); + + return view('posts.show', ['post' => $post]); +} +``` + +Use route model binding: + +```php +public function show(Post $post): View +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Scope Nested Bindings + +Use scoped bindings when a nested resource must belong to its parent. This constrains model resolution; it does not replace authorization. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // The resolved post belongs to the resolved user. +})->scopeBindings(); +``` + +## Use Resource Routes for Resourceful Actions + +Use `Route::resource()` or `Route::apiResource()` when the endpoint follows Laravel's resource-controller actions. Define explicit routes when the behavior does not fit that vocabulary. + +```php +Route::resource('posts', PostController::class); + +// Alternatively, for an API-only resource: +Route::apiResource('posts', ApiPostController::class); +``` + +`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not itself add an `/api` prefix; that prefix comes from the application's API route configuration. + +## Organize Controllers Around Resources + +As a general default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This keeps routes predictable and prevents controllers from accumulating unrelated behavior. + +When a controller needs a custom action such as `publish`, `approve`, or `archive`, first consider whether that behavior represents a separate resource. A focused resource controller gives the behavior its own authorization, validation, and middleware boundary. + +Custom action on the primary controller: + +```php +Route::post('/podcasts/{podcast}/publish', [PodcastController::class, 'publish']); +``` + +The published podcast modeled as a resource: + +```php +Route::post('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'store']) + ->name('published-podcasts.store'); + +Route::delete('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'destroy']) + ->name('published-podcasts.destroy'); +``` + +```php +class PublishedPodcastController extends Controller +{ + public function store(Podcast $podcast): RedirectResponse + { + $podcast->publish(); + + return back(); + } + + public function destroy(Podcast $podcast): RedirectResponse + { + $podcast->unpublish(); + + return back(); + } +} +``` + +Treat a custom verb as a design signal, not proof that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions. + +## Keep Controllers Focused on HTTP Concerns + +Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit. + +```php +public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse +{ + $post = $create->handle($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request can perform validation and authorization before the controller runs. Do not repeat its rules in the controller. Keep simple, endpoint-specific validation inline when extraction would not improve reuse or clarity; see the validation rules for detailed guidance. 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..4c25134a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,61 @@ +# Task Scheduling Best Practices + +## Prevent Unwanted Overlap + +Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. This is appropriate for variable-duration tasks that are not safe to run concurrently. + +```php +Schedule::command('reports:generate') + ->everyFifteenMinutes() + ->withoutOverlapping(30); +``` + +The optional value is the lock expiration time in minutes, not the task timeout. Choose it carefully: the default is 24 hours, stale locks can be cleared with `php artisan schedule:clear-cache`, and an expiration that is too short can permit overlap while the first task still runs. The task itself should still tolerate retries and partial execution where practical. + +## Run a Task on One Server + +Use `onOneServer()` when only one scheduler node should run an eligible task. Scheduler nodes must use the same default cache store, and that store must support atomic locks. Supported stores include `database`, `memcached`, `dynamodb`, and `redis`. + +```php +Schedule::command('billing:charge')->daily()->onOneServer(); +``` + +Name scheduled closures before applying `onOneServer()`, especially when scheduling the same closure with different parameters, so each task has a distinct lock identity. + +## Run Eligible Commands in the Background + +Tasks due at the same time run sequentially by default. Use `runInBackground()` when an independent, long-running scheduled command should not delay later tasks. + +```php +Schedule::command('analytics:process')->hourly()->runInBackground(); +``` + +Laravel restricts `runInBackground()` to tasks scheduled with `command()` and `exec()`; it is not available for scheduled closures. Ensure background processes have appropriate logging and failure monitoring. + +## Restrict Tasks by Environment + +Use `environments()` when a task should run only in named application environments. Treat this as an operational safeguard, not an authorization control. + +```php +Schedule::command('billing:charge') + ->monthly() + ->environments(['production']); +``` + +## Group Shared Configuration + +Use schedule groups when several tasks genuinely share frequency or constraints. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` + +## Bound Work Inside the Task + +The scheduler does not provide a `takeUntilTimeout()` event method or terminate arbitrary tasks at a deadline. Bound work in the command or job itself by processing finite chunks, checking a deadline, or dispatching queue jobs with suitable timeouts. Use operating-system or process controls when hard termination is required. 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..57b57f0f --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,156 @@ +# Security Best Practices + +## Control Mass Assignment + +Define `$fillable` when a model is populated from request-derived arrays, or deliberately guard attributes by another consistent model convention. Laravel models guard all attributes by default; `$guarded = []` opts out of that protection. + +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Do not pass untrusted request data to a model with `$guarded = []`. Mass-assignment protection controls which attributes `create()`, `fill()`, and `update()` may set; it does not validate values or authorize the operation. + +## Authorize Protected Actions + +Use policies, gates, or form request authorization for actions that depend on the current user's permissions. Authentication alone does not establish permission, and validation is not authorization. + +```php +public function update(UpdatePostRequest $request, Post $post): RedirectResponse +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +Authorization may instead live in the form request: + +```php +public function authorize(): bool +{ + return $this->user()?->can('update', $this->route('post')) ?? false; +} +``` + +Public actions intentionally available to everyone do not need a redundant authorization check. + +## Bind Query Parameters + +Use Eloquent, the query builder, or explicit bindings instead of interpolating untrusted values into Structured Query Language (SQL). Bindings protect values, not identifiers such as column names or sort directions; map user-selected identifiers to an allow-list. + +Incorrect: + +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: + +```php +User::where('name', $request->name)->get(); +User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get(); +``` + +## Escape Output in Its Context + +Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content that has been sanitized for the exact HTML context in which it is rendered. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets. + +Incorrect for untrusted content: + +```blade +{!! $user->bio !!} +``` + +Correct: + +```blade +{{ $user->bio }} +``` + +## Apply Cross-Site Request Forgery Protection + +Include `@csrf` in state-changing Blade forms handled by Laravel's `web` middleware. Routes intentionally excluded from cross-site request forgery (CSRF) verification, such as validated third-party webhooks, need their own authenticity check. + +```blade +
+ @csrf + +
+``` + +Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. Confirm equivalent configuration when using another HTTP client. Do not disable CSRF protection merely to fix a token mismatch. + +## Rate Limit Sensitive Endpoints + +Apply suitable rate limits to login attempts, password recovery, verification messages, and expensive or abuse-prone application programming interface (API) routes. Choose the limiter key deliberately; an Internet Protocol (IP) address alone can unfairly group users behind a shared network, while an account identifier alone can enable targeted denial of service. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by(Str::transliterate( + Str::lower($request->string('email')).'|'.$request->ip() + )); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +Rate limiting reduces abuse; it does not replace authentication, authorization, or upstream denial-of-service protection. + +## Validate and Store Uploads Safely + +Validate expected content type, dimensions where relevant, and size. Laravel's `mimes` rule reads the file contents and guesses a Multipurpose Internet Mail Extensions (MIME) type corresponding to the listed extensions; it does not validate the user-assigned filename extension. The `extensions` rule checks that extension and should not be used by itself. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Use Laravel's storage methods to generate a filename, and store untrusted files outside a publicly executable location. Public files can require additional controls, such as image re-encoding, content-disposition headers, and explicit blocking of active formats. + +```php +$path = $request->file('avatar')->store('avatars'); +``` + +## Keep Secrets Out of Application Code + +Do not commit populated environment files or hard-code credentials. Read environment variables in configuration files, then use `config()` in application code so configuration caching works correctly. See the configuration rules for encrypted environment files and external secret stores. + +## Audit Dependencies + +Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly. + +```bash +composer audit +``` + +## Encrypt Sensitive Attributes When Appropriate + +Use an `encrypted` cast for sensitive values that must be recoverable, and use `$hidden` to omit them from array and JavaScript Object Notation (JSON) serialization. Hidden attributes remain accessible in PHP, and encryption does not replace access control. Encrypted values cannot be meaningfully queried and should use a `TEXT` or larger column because ciphertext length is variable. + +```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..3f3f14fb --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,110 @@ +# Convention and Style Best Practices + +## Follow Project Naming Conventions + +Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile. + +| Element | Convention | Example | +| --- | --- | --- | +| Controller | Singular resource name | `ArticleController` | +| Model | Singular StudlyCase | `User` | +| Table | Plural snake_case | `article_comments` | +| Pivot table | Singular model names in alphabetical order, in snake_case | `article_user` | +| Column | snake_case | `meta_title` | +| Conventional foreign key | Singular model name plus `_id`, in snake_case | `article_id` | +| Resource URI | Plural resource | `articles/1` | +| Route name | Dotted segments; snake_case within a segment when needed | `users.show_active` | +| Method | camelCase | `getAll` | +| Variable | camelCase | `$articlesWithAuthor` | +| Collection | Descriptive and plural | `$activeUsers` | +| Object | Descriptive and singular | `$activeUser` | +| View | kebab-case | `show-filtered.blade.php` | +| Configuration file | snake_case | `google_calendar.php` | +| Enumeration | Singular StudlyCase | `UserType` | + +## Prefer Clear, Idiomatic Syntax + +Use Laravel helpers and query methods when they communicate intent more directly. Do not shorten code when the result is ambiguous or loses useful type information. + +| More verbose | Idiomatic alternative | +| --- | --- | +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()?->name` | `->value('name')` when only that value is needed | + +Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation. + +## Use Utilities When They Clarify Intent + +Laravel's `Str`, `Arr`, `Number`, and `Uri` utilities provide expressive operations and framework-consistent behavior. Prefer them when they are clearer or safer than an equivalent PHP operation, not as an unconditional replacement for every built-in function. + +```php +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename(User::class); +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Use `Arr` for dot notation and common transformations: + +```php +$name = Arr::get($array, 'user.name', 'default'); +$public = Arr::only($attributes, ['name', 'email']); +``` + +Use `Number` for localized display formatting rather than values that will be stored or calculated: + +```php +Number::format(1000000); +Number::currency(1500, 'USD'); +Number::fileSize(1024 * 1024); +``` + +Use `Uri` when constructing or transforming a uniform resource identifier (URI) benefits from a structured API: + +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Check the documentation for the Laravel version supported by the project before using newer utility classes or methods. + +## Keep Presentation Code Maintainable + +Prefer the project's asset pipeline, components, and existing conventions for substantial JavaScript and Cascading Style Sheets (CSS). Small page-specific scripts or styles can be reasonable in Blade layouts or stacks; avoid mixing large behavior and style blocks into templates. + +Pass server data with an encoding mechanism appropriate to its context. For example, Blade's `Js::from()` safely formats data for JavaScript: + +```blade + +``` + +Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping. + +## Write Comments That Explain Why + +Prefer clear names and small units of code over comments that merely restate an operation. Add concise comments for non-obvious constraints, tradeoffs, workarounds, regular expressions, or external behavior that the code cannot express by itself. Keep comments accurate when behavior changes. + +Unhelpful: + +```php +// Check whether the query has joins. +if (count((array) $builder->getQuery()->joins) > 0) { + // ... +} +``` + +Clearer: + +```php +if ($this->hasJoins()) { + // ... +} +``` 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..37ce6c5e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,89 @@ +# Validation and Forms Best Practices + +## Extract Validation When It Improves the Boundary + +Use a form request when validation or authorization is substantial, reused, or clearer outside the controller. Inline `$request->validate()` remains appropriate for a small, endpoint-specific rule set. + +```php +public function store(StorePostRequest $request): RedirectResponse +{ + $post = Post::create($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request's `authorize()` method can enforce access to the operation. Validation establishes the shape and values of input; it does not itself authorize the user. + +## Prefer Readable Rule Syntax + +Array syntax composes cleanly with rule objects and avoids delimiter issues. Prefer it in new code when it improves readability, while following a consistent local style. + +```php +'email' => ['required', 'email', Rule::unique('users')], +``` + +String syntax remains valid for simple rules: + +```php +'email' => 'required|email|unique:users', +``` + +## Use Only Intended Validated Data + +Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. Then select the fields intended for the operation when the validation rules also cover control fields or nested data. + +Unsafe: + +```php +Post::create($request->all()); +``` + +Preferred: + +```php +$post = Post::create($request->safe()->only(['title', 'body'])); +``` + +Validated data is not automatically safe for mass assignment. Keep model `$fillable` or `$guarded` rules aligned with the operation, and never add a sensitive attribute to validation merely to make mass assignment convenient. + +## Express Conditional Rules Clearly + +Use conditional rules such as `Rule::when()`, `required_if`, or `exclude_unless` when they make the condition explicit. Choose the simplest form that remains easy to test. + +```php +'company_name' => [ + 'string', + 'max:255', + Rule::when( + $this->input('account_type') === 'business', + ['required'], + ['nullable'], + ), +], +``` + +## Add Cross-Field Validation After Base Rules + +Use a form request's `after()` method for validation that depends on multiple fields or application state. Avoid expensive queries when prerequisite fields have already failed validation. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($validator->errors()->hasAny(['product_id', 'quantity'])) { + return; + } + + $stock = Product::find($this->integer('product_id'))?->stock; + + if ($stock !== null && $this->integer('quantity') > $stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` + +Validation against mutable state does not prevent a race between validation and persistence. Enforce inventory, uniqueness, and similar invariants with database constraints, atomic updates, or a database transaction as appropriate. diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..f62fb274 --- /dev/null +++ b/.agents/skills/livewire-development/SKILL.md @@ -0,0 +1,164 @@ +--- +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/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7e3cd2ac --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,96 @@ +--- +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 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/skills/testing-best-practices/SKILL.md b/.agents/skills/testing-best-practices/SKILL.md new file mode 100644 index 00000000..35d02a1d --- /dev/null +++ b/.agents/skills/testing-best-practices/SKILL.md @@ -0,0 +1,58 @@ +--- +name: testing-best-practices +description: "Laravel test design and review. Use when selecting coverage, naming or structuring tests, choosing assertions or test data, isolating dependencies, testing HTTP or security boundaries, improving suite performance, or reviewing test value. Use framework guidance or search-docs for Pest and PHPUnit syntax." +license: MIT +metadata: + author: laravel +--- + +# Testing Best Practices + +This skill provides rules for designing Laravel tests. Each rule file explains what to do and why. Use `search-docs` for Laravel and Pest API syntax. +This project uses Pest. Follow the corresponding guidance in each rule. + +## Consistency First + +Read nearby tests before you choose syntax and organization. + +A pattern repeated throughout the project is a convention, and project conventions take precedence over this skill. Follow them and give new tests the same structure. + +These rules govern the tests you write now. An existing test that follows a project convention is not defective merely because it conflicts with this skill. Do not delete or rewrite it. If the convention has drawbacks, explain them and let the user decide. + +## What to Test + +Read this section before you write a test. + +- Test observable behavior and application contracts. A test must pass after an implementation change if the behavior stays the same. +- Cover every changed decision and each applicable high-value failure mode. A decision is a branch, a validation, a calculation, or an authorization. +- Exercise declarations through behavior instead of repeating their text. +- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project. +- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test. +- Write a feature test first. Write a unit test only for logic that does not use the framework. +- Write a feature test for every behavior reachable through a request. Real-browser tests require `pestphp/pest-plugin-browser` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test. +- Judge an architecture test by the convention it protects, not by the rules above. An `arch()` test declares a rule for an entire directory, such as the parent class of every model, the classes that may use an enum, or the methods every factory declares. It intentionally checks declarations and fails when a new file breaks the convention. +- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it. + +## How to Apply + +1. Read the code under test. Read the tests in the same directory. Identify every decision in the code. +2. Select every applicable branch in the rule index. Read every selected rule file. +3. Report each defect in the code before you write a test. Examples are a method with no body, a policy that no action calls, and a write action with no validation. Test the actual behavior. Report the defect to the user. +4. Write the tests. Run the smallest set of tests that covers the change. The tests must pass. +5. Check every applicable item in `rules/review.md` and every selected rule file. Resolve every mismatch before completion. + +## Rule Index + +Most changes need more than one rule file. + +| Subject | Rule File | +| --- | --- | +| Test framework features that may already do the work | [`rules/finding-features.md`](rules/finding-features.md) | +| File layout, test names, and groups | [`rules/naming.md`](rules/naming.md) | +| Arrange-act-assert and choosing the correct assertion | [`rules/assertions.md`](rules/assertions.md) | +| Endpoint coverage, authentication, authorization, tenant isolation, validation, and browser tests | [`rules/endpoint-tests.md`](rules/endpoint-tests.md) | +| Factories, test data ownership, and repeated input values | [`rules/test-data.md`](rules/test-data.md) | +| Fakes, mocks, outbound HTTP, time, randomness, and databases | [`rules/isolation.md`](rules/isolation.md) | +| Escaping, injection, cross-tenant access, and privilege checks | [`rules/security.md`](rules/security.md) | +| Environment and CI settings for a slow suite | [`rules/performance.md`](rules/performance.md) | +| Reviewing a test or suite | [`rules/review.md`](rules/review.md) | diff --git a/.agents/skills/testing-best-practices/rules/assertions.md b/.agents/skills/testing-best-practices/rules/assertions.md new file mode 100644 index 00000000..ba55fb25 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/assertions.md @@ -0,0 +1,60 @@ +# Assertions + +## Arrange, Act, Assert + +Write each test in three parts: setup, one action, and assertions. Put one blank line between them so readers can identify each part without comments. + +Keep each test self-contained. Do not use values created by another test. + +## How to Find the Correct Assertion + +First identify the subject of the check, then find an assertion designed for it. A subject-specific assertion identifies the incorrect value when the test fails. + +1. Search Laravel's assertions for framework subjects such as responses, the database, sessions, models, queues, events, mail, and notifications. +2. Fetch `https://pestphp.com/docs/expectations.md` for the expectations of Pest for a plain value, a type, a format, or a shape. +3. Build the check by hand only if no assertion exists for the subject. +4. Confirm the name in the documentation before you use it. Do not write an assertion that you did not confirm. + +Use the assertion in this table for each subject. + +| Subject | Assertion to use | +| --- | --- | +| A return value, the state of an object, or a transformation of a value | an `expect()` chain | +| An HTTP status, JSON, a session, or Inertia | a Laravel response assertion | +| The state in the database | a Laravel database assertion | +| The existence of a model | `assertModelExists($model)` rather than `assertDatabaseHas('users', ['id' => $user->id])` | + +Use a PHPUnit assertion only if no Pest expectation and no Laravel assertion exists for the subject. + +Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered. + +## Named Response Assertions + +Use a named response assertion, such as `assertNotFound()`, rather than `assertStatus(404)`. A failure then identifies the broken contract. Laravel provides named assertions for commonly tested status codes. + +Keep one `expect()` chain on one subject. Start a new chain when the subject changes, or when the chain is difficult to read. + +## Assert a Known Value + +Write the expected value in the test, or calculate the expected value by a different method. Do not calculate the expected value with the logic of the implementation, because the test then passes when that logic is wrong. + +```php +// The test calculates the value with the logic of the implementation... +$expected = now()->subHours(24)->floorSeconds(30)->toJson(); +expect($from)->toBe($expected); + +// The test sets a fixed input and asserts a known value... +travelTo('2025-01-01 00:00:00'); +expect($from)->toBe('2024-12-31T00:00:00.000000Z'); +``` + +## Assert the Complete Result + +A status code is not the complete result of a write operation. Assert each of the following if the operation changes it: + +- The response or the return value. +- The state in the database. +- The jobs and the events that the operation dispatches. +- The notifications and the mail that the operation sends. + +On the failure path, assert that the operation makes none of these changes. A test that asserts only `assertOk()` passes even when the application saves no record. diff --git a/.agents/skills/testing-best-practices/rules/endpoint-tests.md b/.agents/skills/testing-best-practices/rules/endpoint-tests.md new file mode 100644 index 00000000..32e32e64 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/endpoint-tests.md @@ -0,0 +1,47 @@ +# Endpoint Tests + +## How to Write the Test + +Fetch `https://laravel.com/framework/docs/http-tests` for the request helpers, the authentication helpers, and the response assertions. Confirm the name before you use it, and do not guess an assertion. + +Choose an assertion based on the subject of the check: the status, a header, a redirect, the JSON body, the session, a validation error, or the view. Laravel provides a named assertion for each subject that identifies the incorrect value. + +## Endpoint Coverage + +Write a test for each applicable case: + +- The request has missing or invalid authentication. +- The request comes from a different tenant, team, or organization. +- The user has an insufficient role or permission. +- The request does not satisfy a route or scope constraint. +- The request fails the validation. +- The request is valid. Assert both the response and the persisted state. + +Assert the application's actual behavior rather than a generic status code. An API returns `401` for a missing or invalid token, while a browser endpoint redirects to the sign-in route. + +## Tenant Isolation + +Assert the status code returned for a cross-tenant request. Use `404` rather than `403` when one tenant must not learn that another tenant's record exists, because `403` confirms its existence. + +## Test Authorization at the Policy Level + +An HTTP test shows that the endpoint performs authorization. It cannot identify which mechanism refused the request because middleware, a policy, and a call to `abort()` can all return `403`. + +- Assert the complete matrix of the permissions against the policy or the gate. A failure then names the rule that is not correct. +- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization. +- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists. + +## Testing Validation + +- Write one test for each validation rule when each failure represents a separate contract. +- Write one test with an empty payload to assert several required fields together. +- Assert the text of the message that the user gets. A message that is present but wrong is a defect. +- Use a dataset for input values that need the same setup and the same assertions. + +Send an input value that is not valid through the application, and assert the error. Do not assert that an array of rules contains a string, because that assertion tests the declaration and not the behavior. Use such an assertion only for a rule that no request can reach, and write the reason in the test. + +### Which Layer Owns Which Case + +The rule-class test owns the matrix of values that pass and fail. The endpoint test proves that the endpoint applies the rule and that the user receives the message. + +When both tests contain the matrix, move it to the rule-class test and retain one case in the endpoint test. Never remove the last case, because the rule-class test still passes if the request omits the rule. The same division applies to policies, scopes, and other classes called by a request. diff --git a/.agents/skills/testing-best-practices/rules/finding-features.md b/.agents/skills/testing-best-practices/rules/finding-features.md new file mode 100644 index 00000000..83ce650b --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/finding-features.md @@ -0,0 +1,34 @@ +# How to Find Test Framework Features + +Pest adds features faster than this skill can list them. Find an existing feature before implementing the behavior by hand. + +- Give `search-docs` the capability you need rather than the name of a function you remember. It returns features available in the installed version. +- Fetch `https://pestphp.com/llms.txt` for the complete feature list and additions in each release. +- If a search returns no results, tell the user that the installed version does not provide the feature. Do not write an API that you have not confirmed. + +Search for a feature in this table before you write the code by hand. + +| Work that you need | Term to search for | +| --- | --- | +| Run one test with many input values | datasets, bound datasets | +| Assert over many values or over a collection | higher-order expectations | +| Remove the same setup from each test in a file | hooks, higher-order tests | +| Apply a convention to the complete codebase | architecture testing | +| Measure if the suite finds a defect | mutation testing | +| Find code with no types | type coverage | +| Reduce the time of a slow suite | parallel, profiling | +| Run one test while you debug | filtering, `--bail`, `--dirty` | + +## Built-in Laravel Assertion Methods + +Laravel provides assertions for each part of the framework. Fetch `https://laravel.com/framework/docs/testing` for the complete list, and search for an assertion before building a check by hand. Examples include `assertDatabaseHas()`, `assertModelExists()`, `assertSoftDeleted()`, response assertions such as `assertRedirectToRoute()` and `assertJsonPath()`, and fake assertions such as `Queue::assertPushed()` and `Notification::assertSentTo()`. + +A hand-built check fails with `false is not true`, which identifies nothing. A framework assertion names the incorrect table, value, or response, so the failure indicates what to fix. + +```php +// The failure says that false is not true. Instead of this... +expect(User::where('email', 'taylor@laravel.com')->exists())->toBeTrue(); + +// Use this... the failure names the table and the attributes that it did not find... +$this->assertDatabaseHas('users', ['email' => 'taylor@laravel.com']); +``` diff --git a/.agents/skills/testing-best-practices/rules/isolation.md b/.agents/skills/testing-best-practices/rules/isolation.md new file mode 100644 index 00000000..e1a21fdb --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/isolation.md @@ -0,0 +1,52 @@ +# Fakes, Mocks, and Determinism + +Tests that depend on actual time, randomness, sleeping, or network calls can fail for reasons unrelated to the code under test. Control all four. + +## How to Isolate a Dependency + +Fetch `https://laravel.com/framework/docs/mocking` for Laravel's fakes, facade doubles, and fake assertions. Confirm each name before using it. + +Identify the dependency, then choose the first applicable option. A framework fake preserves the real code path, while a mock replaces the dependency. + +1. Always use framework fakes for facades such as events, queues, mail, notifications, storage, the HTTP client, time, and sleep. +2. Use a developer-defined fake implementation of a service if the application provides one. +3. Use a mock for a container-resolved contract only when the real implementation leaves the process or is nondeterministic. +4. Use the real implementation for everything else, including the database. + +## Framework Fakes + +- Create each fake inside the test that needs it. Do not create fakes in a file-level `beforeEach()`. +- Pass class names to `Event::fake()` and `Queue::fake()` when you know which classes the code dispatches. A fake without class names can hide an unexpected dispatch. +- Use a fake without class names only when the test asserts the complete result, including a call to `assertNothingPushed()`. +- Write one assertion for each fake. The assertion states that the code dispatches the item, or that the code does not dispatch the item. +- Assert the data of a job or of an event if that data is part of the behavior. +- Use `Exceptions::fake()` to assert that the application reports the correct exception. Do not use `withoutExceptionHandling()`, because it changes the response under test. + +Create prerequisite factory records before calling `Event::fake()`. Factories use model events, such as a `creating` hook that generates a UUID, and a fake without class names suppresses those events and can produce an invalid model. Call the fake first only when a factory event is under test, and pass that event's class name. + +## Mocking + +Use `shouldReceive()` before the action to declare an expectation. Use `shouldHaveReceived()` after the action for a spy. Use `Mockery::on()` or `withArgs()` if an equality check cannot state the expected argument, such as a check of one field of a value object. + +Import the mock function before you use it: `use function Pest\Laravel\mock;`. + +## Outbound HTTP Testing + +Call `Http::preventStrayRequests()`. Any request without a matching fake then fails without reaching the network. + +Fake the exact endpoint used by each test. Do not call `Http::fake()` without an endpoint because it accepts unexpected requests and can hide defects. + +## Time and Randomness + +- Freeze the time or move the time in each test that depends on a date, a period, or a timestamp. +- Use the framework helpers `freezeTime()`, `travelTo()`, `travel()`, and `travelBack()`. Do not call `Carbon::setTestNow()`. +- Use `Str::createRandomStringsUsing()` to fix a generated string, if the test asserts an identifier or a slug. +- Use `Sleep::fake()` instead of a real sleep, and assert the sleeps that the code requests. +- Restore the time and the randomness after each test, if the suite does not restore them for every test. + +## Database + +- Run real queries against the real records in the test database. Do not mock the query builder, because the test then asserts the mock. +- Assert the exact keys of `toArray()` if the shape of the serialized model is a contract. The test then fails when the model exposes a new attribute. +- Test application behavior caused by the schema, such as deleting dependent records through a cascade. Do not test the database engine's cascade implementation. +- Use `LazilyRefreshDatabase` instead of `RefreshDatabase`. A test that does not use the database then does not run the migrations. diff --git a/.agents/skills/testing-best-practices/rules/naming.md b/.agents/skills/testing-best-practices/rules/naming.md new file mode 100644 index 00000000..4f3f5b1d --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/naming.md @@ -0,0 +1,45 @@ +# Naming and Structure + +## File Layout + +- Name each test file `{ClassName}Test.php`. +- Place each test file at the same relative path as the class under test. The class `app/Actions/DeleteTeam.php` gets the test `tests/Unit/Actions/DeleteTeamTest.php`. +- Follow the project's convention for fixture files. If none exists, put fixtures in `tests/Fixtures/` and load them by path. +- Move large literal values out of the test body and into fixture files. + +## Test Function + +Use the test function used by other files in the same directory. If no neighboring test files exist: + +- Use `it()` for the behavior of the code, and write the name as a verb phrase. +- Use `test()` for a declarative fact, such as a grant in a policy, the labels of an enum, or the shape of a serialized model. + +Use one Pest declaration style in each file. Use either `it()` or `test()` consistently. + +## Naming Tests + +The name of a test is a specification. State the user-visible result and the condition that causes it. + +- Name the behavior, and not the method under test. The file name already gives the class. +- Give the exact status code in the name of a test for an API error. +- Do not write `Given`, `When`, or `Then` in the name. + +```php +it('returns 401 when no token is provided', function () { ... }); +it('does not include deployments from deleted environments', function () { ... }); +it('falls back to the default region when none is configured', function () { ... }); +``` + +Use a verb that describes a result, such as `returns`, `renders`, `creates`, `dispatches`, `rejects`, `forbids`, `falls back`, or `does not`. + +Do not write `it('works correctly')` or `it('returns data')`, because neither specifies a meaningful result. Do not write `it('handleMethod creates record')`, because it names a method rather than behavior. + +## Grouping + +Use `describe()` if one file covers separate actions in a lifecycle. An example is a controller with the actions `index`, `show`, `store`, `update`, and `destroy`. + +Do not use `describe()` in these cases: + +- The file covers one action or one flow. +- The tests are different only in the input value. Use a dataset instead. +- The group adds a level but does not make the file easier to read. diff --git a/.agents/skills/testing-best-practices/rules/performance.md b/.agents/skills/testing-best-practices/rules/performance.md new file mode 100644 index 00000000..ee993fbb --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/performance.md @@ -0,0 +1,46 @@ +# Test Suite Performance + +These settings apply to the project and CI, not to individual tests. Read `rules/isolation.md` for choices within a test. + +Fetch `https://pestphp.com/docs/optimizing-tests` for Pest options that make test runs faster. +Verify each flag in the documentation before adding it to CI. + +Measure before changing a setting. Find the slow test first, and apply a project-wide setting only after identifying the costly work. + +## Test Environment + +- Set `BCRYPT_ROUNDS=4` in `.env.testing` or in `phpunit.xml`. The default value is 12, and the hash then takes most of the time of each test that signs a user in. +- Disable XDebug. Disable pcov also, unless the run needs the coverage. +- Disable packages that perform work on every request in the test environment. Examples are Pulse, Telescope, and Nightwatch. +- Use the `WithCachedConfig` and `WithCachedRoutes` traits, so the run does not parse the configuration and the routes for every test. +- Call `withoutVite()`, or `withoutMix()`, so the framework does not resolve a built asset. + +## Global Fakes + +Put these three calls in the base `Pest.php` of the project: + +- `Http::preventStrayRequests()`, because one request that reaches the network can slow the suite. This catches requests made through Laravel's HTTP client. Check direct Guzzle and cURL usage separately. +- `Sleep::fake(syncWithCarbon: true)`, so a retry and a backoff do not sleep. +- `Exceptions::fake()`, so the suite does not report an exception to an external service. + +## How to Run the Suite in Parallel + +Run `vendor/bin/pest --parallel` to spread tests across the machine's CPU cores. Add `--processes=N` if the default count is unsuitable for the machine or CI. + +A parallel run gives each process a separate database. Tests must meet these conditions; a test that fails only in parallel breaks one of them: + +- The test creates each record that it reads. It does not read a record that another test creates. +- The test does not depend on the order of the run. +- The test does not share a file, a cache key, or a queue with another test. Give each process a separate name for such a resource. + +## How to Find a Slow Test + +Run `vendor/bin/pest --profile` to list the slowest tests. Start with the ten slowest tests, because the same cause often applies to the complete suite. + +If the cause of a slow test is unclear, add an event listener or temporary log entry to identify its work. + +## Common Errors + +- The run loads XDebug for a test that does not need it. +- `BCRYPT_ROUNDS` keeps the default value, because the project has no `.env.testing`. +- The code under test calls the real `sleep()`, and `Sleep::fake()` then does not help. diff --git a/.agents/skills/testing-best-practices/rules/review.md b/.agents/skills/testing-best-practices/rules/review.md new file mode 100644 index 00000000..e0cfca14 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/review.md @@ -0,0 +1,44 @@ +# Reviewing Tests + +Check every item in this file. A passing test may still provide no value. For each test, identify the defect it would catch. + +Report each finding. Do not delete or rewrite a test without the user's approval. When an issue appears throughout the suite as a convention, report the pattern once rather than every affected file. + +## Test Value + +Apply this section to behavioral tests. An architecture test states a convention for a directory, so these items do not apply to it. + +- [ ] Each test covers observable behavior or an application contract, and passes after a change to the implementation that keeps the behavior. +- [ ] Each tested declaration is exercised through behavior, and no test asserts the behavior of the framework. A test of what this project configures, such as a relation with a constraint, a cast, or a scope, belongs to this project. +- [ ] Each test detects a distinct defect that no other test covers. A duplicate shrinks at the higher layer to the one case that proves the wiring. +- [ ] Every changed decision and each applicable high-value failure mode has coverage. + +## Names and Structure + +- [ ] Each file has the name `{ClassName}Test.php` and the relative path of the class under test. +- [ ] Each name states a result, the condition that causes it, and the status code for an API error. +- [ ] Each file uses one declaration style consistently, and each `describe()` group holds separate behavior. + +## Coverage + +- [ ] HTTP tests cover authentication, authorization, role, scope, and validation when applicable. +- [ ] A request for a record of a different tenant gets a status code that does not confirm that the record exists. +- [ ] The complete permission matrix belongs in policy tests, not controller tests. +- [ ] Each validation rule has one test that asserts the user-visible message. When a unit test owns a matrix, reduce duplicate higher-level coverage to one case rather than deleting it. +- [ ] Rendered user input and each dynamic part of a query have a security test. + +## Data and Determinism + +- [ ] Each test creates its mutable records directly or through a helper that it calls, and every created record arranges the behavior or supports an assertion. +- [ ] Each `beforeEach()` holds configuration only. +- [ ] Each factory state and each relationship gives the meaning of the data. +- [ ] Each call to `make()` is in a test that does not need the database. +- [ ] Time, randomness, sleep, and outbound HTTP are controlled. +- [ ] Each test passes alone, and passes in the complete suite in any order. + +## Assertions + +- [ ] Each expected value is a known value, and the test does not calculate the value with the logic of the implementation. +- [ ] Each test of a write operation asserts the response, the state in the database, and the side effects. +- [ ] Each fake has one assertion, and gives the class names unless the test asserts the complete result. +- [ ] Each `expect()` chain stays on one subject. diff --git a/.agents/skills/testing-best-practices/rules/security.md b/.agents/skills/testing-best-practices/rules/security.md new file mode 100644 index 00000000..234f5b00 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/security.md @@ -0,0 +1,27 @@ +# Security Tests + +Test each security boundary where user input affects authorization, rendered output, or query construction. A defect at such a boundary can be difficult to detect because the feature may continue to work. + +Write a test for each of these cases: + +- **Cross-tenant access.** Request a record of a different tenant, team, or organization. Read `rules/endpoint-tests.md` for why the response should possibly be `404` rather than `403`. +- **Each unprivileged role.** Use a dataset over the roles that the endpoint must refuse. +- **Escaping user-provided content.** Test escaping in HTML and mail. Include names and every free-text field a template renders. Assert that dangerous characters are escaped and the raw value is absent. Do not assert an exact entity for a quote, because Markdown and mail CSS inliners may decode it. +- **Injection into dynamic query components.** Examples include sort columns, filter fields, and sort directions. +- **An unexpected key** in a payload or configuration array. A merge that accepts every key can set an attribute the user must not control. + +```php +it('escapes dangerous content in the notification', function () { + $organization = Organization::factory()->make([ + 'name' => "O'Reilly ", + ]); + + $content = (new QuotaApproaching($organization, 80))->toMail()->render(); + + expect($content) + ->toContain('<script>') + ->not->toContain(""); +}); +``` + +Laravel provides defenses against mass assignment, unauthorized access, and unescaped output. Test that the application applies the appropriate defense to each attribute, route, and template. diff --git a/.agents/skills/testing-best-practices/rules/test-data.md b/.agents/skills/testing-best-practices/rules/test-data.md new file mode 100644 index 00000000..65124683 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/test-data.md @@ -0,0 +1,56 @@ +# Factories and Test Data + +## Each Test Makes Its Own Data + +Create mutable records inside the test that uses them. This keeps setup visible and lets each test select its factory state. + +Use `beforeEach()` only for configuration that applies to every test in the file. Do not create records in it. + +## Record Construction + +- Use `create()` if the test needs the record in the database. +- Use `make()` only if the test does not need the database. Examples include rendering a notification and testing a value object's behavior. +- Use a named factory state instead of a raw attribute. `User::factory()->unverified()->create()` gives the state meaning; `create(['email_verified_at' => null])` gives only its value. +- Use `for()` or the relationship helper of the project to declare the owner of a record. +- Use `recycle()` if several records must share one parent record. +- Use `sequence()` if several records need different attributes. + +```php +$organization = Organization::factory()->onPlan(BillingPlan::PRO)->create(); + +$environment = Environment::factory()->recycle($organization)->create(); + +$organizations = Organization::factory() + ->count(3) + ->sequence( + ['created_at' => now()->setSeconds(30)], + ['created_at' => now()->setSeconds(1)], + ) + ->create(); +``` + +Create only the records required to arrange the behavior or support an assertion. + +## Datasets + +Use a dataset when the setup, test body, and assertions remain the same across input values. + +```php +it('forbids roles other than admin', function (Role $role) { + actingAs(User::factory()->hasOrganization($role)->create()) + ->post('/settings') + ->assertForbidden(); +})->with(collect(Role::cases())->reject(fn (Role $role) => $role === Role::ADMIN)); +``` + +Use parameterized tests for: + +- enum cases +- roles and plans +- boundary values +- input values that are invalid in the same way +- input and output value pairs + +Write separate tests if the cases need a different setup, a different behavior, or different assertions. One test function with a branch in the body is two tests in one function. + +Give each dataset case a name that states the difference. A failure then identifies the case without requiring you to count positions. diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..864e1fbd --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] diff --git a/.env.example b/.env.example index c0660ea1..427d14c9 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -APP_NAME=Laravel +APP_NAME=Shop APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=http://shop.test APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -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/AGENTS.md b/AGENTS.md index 296f2af0..e05592b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,226 @@ 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 a rule with `record-rule` only when the user explicitly asks for one. Instructions for the work at hand are not rules, no matter how emphatic: "remove this typo", "use X here" are work to do, not rules to record. Never record a rule on your own initiative, as a byproduct of a change, or to summarize what you just did. When the user does ask, pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Use `record-rule` rather than your native memory or notes tool, because native memory is personal and session-scoped, while 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. +- Activate the `deploying-to-cloud` skill whenever deploying to Laravel Cloud, configuring Cloud environments or resources, using the Cloud CLI, or troubleshooting Cloud deployments. + +=== herd rules === + +# Laravel Herd + +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. + +=== tests rules === + +# Test Enforcement + +- Add or update tests for behavior and logic changes when a test provides meaningful regression coverage. +- Pure copy, styling, and layout-only changes do not require new or updated tests. +- When test coverage applies, run the affected tests and ensure they pass. +- Test the changed behavior and its important failure modes, but do not add tests beyond them. +- Read the `testing-best-practices` skill before writing tests. + +=== 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 allows you 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. Create tests with `php artisan make:test --pest {name}`. +- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`. +- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review. +- Do not delete tests or test files without approval. They are part of the application. + +## Running Tests + +- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`. +- Rerun a test after each change to it. +- Run `vendor/bin/pest` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments. +- After the feature tests pass, ask the user to run the complete suite with `php artisan test --compact`. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..245fb805 --- /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 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. + +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/app/Auth/StoreScopedCustomerUserProvider.php b/app/Auth/StoreScopedCustomerUserProvider.php new file mode 100644 index 00000000..82844bfb --- /dev/null +++ b/app/Auth/StoreScopedCustomerUserProvider.php @@ -0,0 +1,35 @@ +bound('current_store') ? app('current_store') : $this->storeForCurrentHost(); + + if (! $store instanceof Store) { + return $query->whereRaw('1 = 0'); + } + + return $query->where($query->getModel()->qualifyColumn('store_id'), $store->id); + } + + private function storeForCurrentHost(): ?Store + { + $hostname = strtolower(request()->getHost()); + $storeId = StoreDomain::query() + ->where('hostname', $hostname) + ->where('type', 'storefront') + ->value('store_id'); + + return $storeId ? Store::query()->find($storeId) : null; + } +} diff --git a/app/Auth/StoreScopedDatabaseTokenRepository.php b/app/Auth/StoreScopedDatabaseTokenRepository.php new file mode 100644 index 00000000..e142ee99 --- /dev/null +++ b/app/Auth/StoreScopedDatabaseTokenRepository.php @@ -0,0 +1,72 @@ +deleteExisting($user); + $token = $this->createNewToken(); + + $this->getTable()->insert([ + 'store_id' => $this->currentStoreId(), + 'email' => $user->getEmailForPasswordReset(), + 'token' => $this->hasher->make($token), + 'created_at' => new Carbon, + ]); + + return $token; + } + + public function exists(CanResetPassword $user, $token): bool + { + $record = (array) $this->getTable() + ->where('store_id', $this->currentStoreId()) + ->where('email', $user->getEmailForPasswordReset()) + ->first(); + + return $record !== [] + && ! $this->tokenExpired($record['created_at']) + && $this->hasher->check($token, $record['token']); + } + + public function recentlyCreatedToken(CanResetPassword $user): bool + { + $record = (array) $this->getTable() + ->where('store_id', $this->currentStoreId()) + ->where('email', $user->getEmailForPasswordReset()) + ->first(); + + return $record !== [] && $this->tokenRecentlyCreated($record['created_at']); + } + + public function delete(CanResetPassword $user): void + { + $this->deleteExisting($user); + } + + protected function deleteExisting(CanResetPassword $user): int + { + return $this->getTable() + ->where('store_id', $this->currentStoreId()) + ->where('email', $user->getEmailForPasswordReset()) + ->delete(); + } + + private function currentStoreId(): int + { + $store = app()->bound('current_store') ? app('current_store') : null; + + if (! $store instanceof Store) { + throw new \LogicException('A store context is required for customer password resets.'); + } + + return (int) $store->id; + } +} diff --git a/app/Auth/StoreScopedPasswordBrokerManager.php b/app/Auth/StoreScopedPasswordBrokerManager.php new file mode 100644 index 00000000..0225c892 --- /dev/null +++ b/app/Auth/StoreScopedPasswordBrokerManager.php @@ -0,0 +1,31 @@ +app['config']['app.key']; + + if (str_starts_with($key, 'base64:')) { + $key = base64_decode(substr($key, 7)); + } + + return new StoreScopedDatabaseTokenRepository( + $this->app['db']->connection($config['connection'] ?? null), + $this->app['hash'], + $config['table'], + $key, + ($config['expire'] ?? 60) * 60, + $config['throttle'] ?? 0, + ); + } +} diff --git a/app/Contracts/TaxProvider.php b/app/Contracts/TaxProvider.php new file mode 100644 index 00000000..0ea95c5c --- /dev/null +++ b/app/Contracts/TaxProvider.php @@ -0,0 +1,11 @@ + $product */ + public function __construct(public readonly int $storeId, public readonly array $product) {} +} diff --git a/app/Events/ProductStatusChanged.php b/app/Events/ProductStatusChanged.php new file mode 100644 index 00000000..453560e8 --- /dev/null +++ b/app/Events/ProductStatusChanged.php @@ -0,0 +1,21 @@ +getKey(); + $export = AnalyticsExport::query() + ->where('store_id', $storeId) + ->whereKey($analyticsExport) + ->where('status', 'completed') + ->whereNotNull('storage_key') + ->firstOrFail(); + + abort_unless(Storage::disk('local')->exists($export->storage_key), 404); + + return Storage::disk('local')->download( + $export->storage_key, + 'analytics-'.$export->from_date->format('Y-m-d').'-'.$export->to_date->format('Y-m-d').'.csv', + ['Content-Type' => 'text/csv'], + ); + } +} diff --git a/app/Http/Controllers/Api/AdminController.php b/app/Http/Controllers/Api/AdminController.php new file mode 100644 index 00000000..d540b353 --- /dev/null +++ b/app/Http/Controllers/Api/AdminController.php @@ -0,0 +1,1065 @@ +store($storeId); + $user = request()->user(); + $role = $user->roleForStore($store); + + return response()->json([ + 'data' => [ + 'user_id' => $user->id, + 'store_id' => $store->id, + 'role' => $role, + 'email' => $user->email, + 'name' => $user->name, + 'permissions' => $this->permissionsForRole((string) $role), + ], + ]); + } + + public function products(Request $request, int $storeId, AdminProductApiService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('viewAny', Product::class); + $filters = $request->validate([ + 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['sometimes', 'string', 'max:255'], + 'collection_id' => [ + 'sometimes', 'integer', 'min:1', + Rule::exists('collections', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $store->getKey())), + ], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort' => ['sometimes', 'string', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])], + ]); + + return response()->json($resources->list($store, $filters)); + } + + public function createProduct(Request $request, int $storeId, AdminProductApiService $products): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('create', Product::class); + $validated = $this->validateProductPayload($request, $storeId, true); + $requestedStatus = ProductStatus::from($validated['status'] ?? 'draft'); + + if ($requestedStatus === ProductStatus::Archived) { + Gate::authorize('archive-products'); + } elseif ($requestedStatus === ProductStatus::Active) { + Gate::authorize('manage-products'); + } + + return response()->json(['data' => $products->resource($products->create($store, $validated))], 201); + } + + public function product(int $storeId, int $productId, AdminProductApiService $resources): JsonResponse + { + $this->store($storeId); + $product = Product::query()->where('store_id', $storeId)->findOrFail($productId); + Gate::authorize('view', $product); + + return response()->json(['data' => $resources->resource($product)]); + } + + public function updateProduct(Request $request, int $storeId, int $productId, AdminProductApiService $products): JsonResponse + { + $store = $this->store($storeId); + $product = Product::query()->where('store_id', $storeId)->findOrFail($productId); + Gate::authorize('update', $product); + $validated = $this->validateProductPayload($request, $storeId, false, $product); + + if (($validated['status'] ?? null) === ProductStatus::Archived->value) { + Gate::authorize('archive-products'); + } elseif (isset($validated['status'])) { + Gate::authorize('manage-products'); + } + + return response()->json(['data' => $products->resource($products->update($product, $store, $validated))]); + } + + public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse + { + $this->store($storeId); + $product = Product::query()->where('store_id', $storeId)->findOrFail($productId); + Gate::authorize('archive-products'); + $products->transitionStatus($product, ProductStatus::Archived); + + return response()->json(['data' => [ + 'id' => $product->getKey(), + 'status' => $product->refresh()->status, + 'updated_at' => $product->updated_at?->toISOString(), + ]]); + } + + public function collections(Request $request, int $storeId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('view-collections'); + $validated = $request->validate([ + 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['sometimes', 'string', 'max:255'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]); + + return response()->json($resources->listCollections($store, $validated, $validated['per_page'] ?? 25)); + } + + public function createCollection(Request $request, int $storeId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-products'); + $validated = $request->validate($this->collectionRules($storeId, false)); + $collection = $resources->createCollection($store, $validated); + + return response()->json($resources->collectionResource($collection), 201); + } + + public function updateCollection(Request $request, int $storeId, int $collectionId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-products'); + $collection = Collection::query()->where('store_id', $store->getKey())->findOrFail($collectionId); + $validated = $request->validate($this->collectionRules($storeId, true, $collectionId)); + + if (array_key_exists('product_ids', $validated) + && (array_key_exists('add_product_ids', $validated) || array_key_exists('remove_product_ids', $validated))) { + throw ValidationException::withMessages([ + 'product_ids' => 'Use product_ids or the incremental product fields, not both.', + ]); + } + + return response()->json($resources->collectionResource($resources->updateCollection($collection, $validated))); + } + + public function deleteCollection(int $storeId, int $collectionId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-products'); + $collection = Collection::query()->where('store_id', $store->getKey())->findOrFail($collectionId); + $resources->deleteCollection($collection); + + return response()->json(['message' => 'Collection deleted']); + } + + public function orders(Request $request, int $storeId): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('viewAny', Order::class); + $validated = $request->validate([ + 'status' => ['sometimes', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])], + 'financial_status' => ['sometimes', Rule::in(['pending', 'paid', 'partially_refunded', 'refunded'])], + 'fulfillment_status' => ['sometimes', Rule::in(['unfulfilled', 'partial', 'fulfilled'])], + 'customer_id' => [ + 'sometimes', 'integer', 'min:1', + Rule::exists('customers', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $store->getKey())), + ], + 'created_after' => ['sometimes', 'date'], + 'created_before' => ['sometimes', 'date', 'after_or_equal:created_after'], + 'query' => ['sometimes', 'string', 'max:255'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort' => ['sometimes', Rule::in(['placed_at_desc', 'placed_at_asc', 'total_desc', 'total_asc'])], + ]); + $query = Order::query()->where('store_id', $store->getKey())->with(['customer'])->withCount('lines'); + + foreach (['status', 'financial_status', 'fulfillment_status', 'customer_id'] as $filter) { + if (isset($validated[$filter])) { + $query->where($filter, $validated[$filter]); + } + } + + if (isset($validated['created_after'])) { + $query->where('placed_at', '>=', CarbonImmutable::parse($validated['created_after'])->toDateTimeString()); + } + + if (isset($validated['created_before'])) { + $query->where('placed_at', '<=', CarbonImmutable::parse($validated['created_before'])->toDateTimeString()); + } + + if (isset($validated['query'])) { + $text = addcslashes($validated['query'], '%_\\'); + $query->where(function (\Illuminate\Database\Eloquent\Builder $builder) use ($text): void { + $builder->where('order_number', 'like', "%{$text}%") + ->orWhere('email', 'like', "%{$text}%") + ->orWhereHas('customer', fn (\Illuminate\Database\Eloquent\Builder $customers) => $customers->where('email', 'like', "%{$text}%")); + }); + } + + [$sortColumn, $sortDirection] = match ($validated['sort'] ?? 'placed_at_desc') { + 'placed_at_asc' => ['placed_at', 'asc'], + 'total_desc' => ['total_amount', 'desc'], + 'total_asc' => ['total_amount', 'asc'], + default => ['placed_at', 'desc'], + }; + $page = $validated['page'] ?? 1; + $perPage = $validated['per_page'] ?? 25; + $orders = $query->orderBy($sortColumn, $sortDirection)->paginate($perPage, ['*'], 'page', $page); + + return response()->json([ + 'data' => $orders->getCollection()->map(fn (Order $order): array => $this->orderListResource($order))->all(), + 'meta' => [ + 'current_page' => $orders->currentPage(), + 'per_page' => $orders->perPage(), + 'total' => $orders->total(), + 'last_page' => $orders->lastPage(), + ], + ]); + } + + public function order(int $storeId, int $orderId): JsonResponse + { + $this->store($storeId); + $order = Order::query()->where('store_id', $storeId)->with([ + 'customer', 'lines', 'payments', 'refunds.lines', 'fulfillments.lines', + ])->findOrFail($orderId); + Gate::authorize('view', $order); + + return response()->json(['data' => $this->orderResource($order)]); + } + + public function fulfill(Request $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse + { + $this->store($storeId); + $order = Order::query()->where('store_id', $storeId)->with(['lines.fulfillmentLines'])->findOrFail($orderId); + Gate::authorize('createFulfillment', $order); + $validated = $request->validate([ + 'line_items' => ['required', 'array', 'min:1'], + 'line_items.*' => ['required', 'array:order_line_id,quantity'], + 'line_items.*.order_line_id' => [ + 'required', 'integer', 'distinct', + Rule::exists('order_lines', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('order_id', $order->getKey())), + ], + 'line_items.*.quantity' => ['required', 'integer', 'min:1'], + 'tracking_company' => ['nullable', 'string', 'max:255'], + 'tracking_number' => ['nullable', 'string', 'max:255'], + 'tracking_url' => ['nullable', 'url', 'max:2048'], + 'notify_customer' => ['sometimes', 'boolean'], + ]); + if (! in_array($order->financial_status, ['paid', 'partially_refunded'], true) || $order->fulfillment_status === 'fulfilled' || $order->status === 'cancelled') { + return response()->json(['message' => 'The order is not in a fulfillable state.'], 409); + } + + $lineQuantities = collect($validated['line_items'])->mapWithKeys(static fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']])->all(); + $fulfillment = $fulfillments->create($order, $lineQuantities, $validated); + $fulfillment = $fulfillments->markShipped($fulfillment, (bool) ($validated['notify_customer'] ?? true)); + + return response()->json(['data' => [ + 'id' => $fulfillment->getKey(), + 'order_id' => $fulfillment->order_id, + 'status' => $fulfillment->status, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toISOString(), + 'line_items' => $fulfillment->load('lines')->lines->map(static fn ($line): array => [ + 'order_line_id' => $line->order_line_id, + 'quantity' => (int) $line->quantity, + ])->all(), + ]], 201); + } + + public function refund(Request $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse + { + $this->store($storeId); + $order = Order::query()->where('store_id', $storeId)->findOrFail($orderId); + Gate::authorize('processRefund', $order); + $validated = $request->validate([ + 'amount' => ['required', 'integer', 'min:1'], + 'reason' => ['sometimes', 'nullable', 'string', 'max:1000'], + 'line_items' => ['sometimes', 'array'], + 'line_items.*' => ['required', 'array:order_line_id,quantity'], + 'line_items.*.order_line_id' => [ + 'required', 'integer', 'distinct', + Rule::exists('order_lines', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('order_id', $order->getKey())), + ], + 'line_items.*.quantity' => ['required', 'integer', 'min:1'], + 'notify_customer' => ['sometimes', 'boolean'], + ]); + if (! in_array($order->financial_status, ['paid', 'partially_refunded'], true)) { + return response()->json(['message' => 'The order cannot be refunded.'], 409); + } + + $lineQuantities = collect($validated['line_items'] ?? [])->mapWithKeys(static fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']])->all(); + $refund = $refunds->refund( + $order, + (int) $validated['amount'], + $validated['reason'] ?? null, + false, + $lineQuantities, + (bool) ($validated['notify_customer'] ?? true), + ); + + return response()->json(['data' => [ + 'id' => $refund->getKey(), + 'order_id' => $refund->order_id, + 'payment_id' => $refund->payment_id, + 'provider_refund_id' => $refund->provider_refund_id, + 'amount' => (int) $refund->amount, + 'reason' => $refund->reason, + 'status' => $refund->status === 'processed' ? 'completed' : $refund->status, + 'created_at' => $refund->created_at?->toISOString(), + ]], 201); + } + + public function discounts(Request $request, int $storeId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('view-discounts'); + $validated = $request->validate([ + 'type' => ['sometimes', 'string', Rule::in(['code', 'automatic'])], + 'status' => ['sometimes', 'string', Rule::in(['active', 'expired', 'scheduled'])], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]); + + return response()->json($resources->listDiscounts($store, $validated, $validated['per_page'] ?? 25)); + } + + public function createDiscount(Request $request, int $storeId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-discounts'); + $this->normalizeDiscountCode($request); + $validated = $request->validate($this->discountRules($request, $storeId, true)); + if (($validated['type'] ?? null) === 'automatic' && filled($validated['code'] ?? null)) { + throw ValidationException::withMessages(['code' => 'Automatic discounts must not have a code.']); + } + + $this->validateDiscountDates($validated); + $discount = $resources->createDiscount($store, $validated); + + return response()->json($resources->discountResource($discount), 201); + } + + public function updateDiscount(Request $request, int $storeId, int $discountId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-discounts'); + $discount = Discount::query()->where('store_id', $store->getKey())->findOrFail($discountId); + $this->normalizeDiscountCode($request); + $validated = $request->validate($this->discountRules($request, $storeId, false, $discount)); + $requestedType = $validated['type'] ?? ($discount->code === null ? 'automatic' : 'code'); + $currentType = $discount->code === null ? 'automatic' : 'code'; + + if ($requestedType !== $currentType) { + throw ValidationException::withMessages(['type' => 'The discount type cannot be changed after creation.']); + } + + if (array_key_exists('code', $validated) && $validated['code'] !== $discount->code) { + throw ValidationException::withMessages(['code' => 'The discount code cannot be changed after creation.']); + } + + $this->validateEffectiveDiscountValue($validated, $discount); + $this->validateDiscountDates($validated, $discount); + + return response()->json($resources->discountResource($resources->updateDiscount($discount, $validated))); + } + + public function deleteDiscount(int $storeId, int $discountId, AdminCollectionDiscountService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-discounts'); + $discount = Discount::query()->where('store_id', $store->getKey())->findOrFail($discountId); + $resources->deleteDiscount($discount); + + return response()->json(['message' => 'Discount deleted']); + } + + public function shippingZones(int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('manage-shipping'); + + return response()->json(ShippingZone::query()->with('rates')->orderBy('name')->get()); + } + + public function taxSettings(int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('manage-store-settings'); + $settings = TaxSetting::query()->firstOrCreate(['store_id' => $storeId]); + + return response()->json(['data' => [ + 'store_id' => $storeId, + 'mode' => $settings->mode ?? 'manual', + 'provider' => $settings->provider ?? 'none', + 'prices_include_tax' => (bool) $settings->prices_include_tax, + 'config_json' => $settings->taxConfiguration(), + 'updated_at' => $settings->updated_at, + ]]); + } + + public function updateTaxSettings(Request $request, int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('manage-store-settings'); + $this->requireJsonObjectField($request, 'config_json'); + $validated = $request->validate([ + 'mode' => ['required', Rule::in(['manual', 'provider'])], + 'provider' => ['required_if:mode,provider', 'nullable', Rule::in(['stripe_tax', 'none'])], + 'prices_include_tax' => ['required', 'boolean'], + 'config_json' => ['required', 'array:default_tax_rate,tax_rates,fallback,stripe_tax_settings_id'], + 'config_json.default_tax_rate' => ['sometimes', 'integer', 'min:0', 'max:10000'], + 'config_json.tax_rates' => ['sometimes', 'array', 'max:250'], + 'config_json.tax_rates.*' => ['required', 'array:country_code,province_code,rate,name,shipping_taxed'], + 'config_json.tax_rates.*.country_code' => ['required', 'string', 'size:2', 'alpha', 'uppercase'], + 'config_json.tax_rates.*.province_code' => ['sometimes', 'nullable', 'string', 'max:10'], + 'config_json.tax_rates.*.rate' => ['required', 'integer', 'min:0', 'max:10000'], + 'config_json.tax_rates.*.name' => ['sometimes', 'string', 'max:100'], + 'config_json.tax_rates.*.shipping_taxed' => ['sometimes', 'boolean'], + 'config_json.fallback' => ['sometimes', Rule::in(['block', 'allow'])], + 'config_json.stripe_tax_settings_id' => ['sometimes', 'nullable', 'string', 'max:255'], + ]); + if (($validated['mode'] ?? null) === 'provider' && empty($validated['provider'])) { + throw ValidationException::withMessages(['provider' => 'The provider field is required when tax mode is provider.']); + } + + $config = $validated['config_json']; + $legacyRates = []; + + foreach ($config['tax_rates'] ?? [] as $taxRate) { + $countryCode = strtoupper($taxRate['country_code']); + $provinceCode = strtoupper((string) ($taxRate['province_code'] ?? '')); + + if ($provinceCode === '') { + $legacyRates[$countryCode] = (int) $taxRate['rate']; + } else { + $legacyRates[$countryCode][$provinceCode] = (int) $taxRate['rate']; + } + } + + $settings = TaxSetting::query()->updateOrCreate(['store_id' => $storeId], [ + 'mode' => $validated['mode'], + 'provider' => $validated['provider'] ?? 'none', + 'prices_include_tax' => $validated['prices_include_tax'], + 'config_json' => $config, + 'default_rate' => $config['default_tax_rate'] ?? 0, + 'rates_json' => $legacyRates, + 'updated_at' => now(), + ]); + + return response()->json(['data' => $settings->only([ + 'store_id', 'mode', 'provider', 'prices_include_tax', 'config_json', 'updated_at', + ])]); + } + + public function pages(int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('manage-store-settings'); + + return response()->json(Page::query()->orderBy('title')->paginate(50)); + } + + public function reindexSearch(int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('manage-store-settings'); + + $wasQueued = DB::transaction(function () use ($storeId): bool { + DB::table('search_settings')->insertOrIgnore(['store_id' => $storeId]); + $settings = DB::table('search_settings')->where('store_id', $storeId)->lockForUpdate()->first(); + + if (in_array($settings->index_status, ['queued', 'processing'], true)) { + return false; + } + + DB::table('search_settings')->where('store_id', $storeId)->update([ + 'index_status' => 'queued', + 'pending_updates' => Product::query()->count(), + ]); + + return true; + }); + + if (! $wasQueued) { + return response()->json(['message' => 'A search reindex is already in progress.'], 409); + } + + $jobId = 'job_reindex_'.Str::lower(Str::random(16)); + ReindexStoreProducts::dispatch($storeId); + + return response()->json([ + 'message' => 'Reindex job queued.', + 'job_id' => $jobId, + 'status' => 'queued', + ], 202); + } + + public function searchStatus(int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('manage-store-settings'); + DB::table('search_settings')->insertOrIgnore(['store_id' => $storeId]); + $settings = DB::table('search_settings')->where('store_id', $storeId)->first(); + + return response()->json([ + 'data' => [ + 'store_id' => $storeId, + 'index_status' => $settings->index_status, + 'last_reindex_at' => $settings->last_reindex_at, + 'last_reindex_duration_seconds' => $settings->last_reindex_duration_seconds, + 'documents_count' => (int) $settings->documents_count, + 'pending_updates' => (int) $settings->pending_updates, + ], + ]); + } + + public function analyticsSummary(Request $request, int $storeId): JsonResponse + { + $this->store($storeId); + Gate::authorize('view-analytics'); + $validated = $request->validate([ + 'from' => ['required', 'date_format:Y-m-d'], + 'to' => ['required', 'date_format:Y-m-d', 'after_or_equal:from'], + 'granularity' => ['sometimes', Rule::in(['day', 'week', 'month'])], + ]); + $start = CarbonImmutable::parse($validated['from'])->startOfDay(); + $end = CarbonImmutable::parse($validated['to'])->startOfDay(); + + if ($start->diffInDays($end) > 364) { + throw ValidationException::withMessages(['to' => 'The date range may not exceed 365 days.']); + } + + $rows = DB::table('analytics_daily') + ->where('store_id', $storeId) + ->whereBetween('date', [$start->toDateString(), $end->toDateString()]) + ->orderBy('date') + ->get(); + $ordersCount = (int) $rows->sum('orders_count'); + $revenueAmount = (int) $rows->sum('revenue_amount'); + $visitsCount = (int) $rows->sum('visits_count'); + $granularity = $validated['granularity'] ?? 'day'; + $daily = $rows->groupBy(function (object $row) use ($granularity): string { + $date = CarbonImmutable::parse($row->date); + + return match ($granularity) { + 'week' => $date->startOfWeek(CarbonImmutable::MONDAY)->toDateString(), + 'month' => $date->startOfMonth()->toDateString(), + default => $date->toDateString(), + }; + })->map(function ($bucket, string $date): array { + $orders = (int) $bucket->sum('orders_count'); + $revenue = (int) $bucket->sum('revenue_amount'); + + return [ + 'date' => $date, + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => (int) $bucket->sum('visits_count'), + 'add_to_cart_count' => (int) $bucket->sum('add_to_cart_count'), + 'checkout_started_count' => (int) $bucket->sum('checkout_started_count'), + ]; + })->values(); + $topProducts = DB::table('order_lines') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $storeId) + ->whereBetween(DB::raw('date(orders.placed_at)'), [$start->toDateString(), $end->toDateString()]) + ->whereIn('orders.financial_status', ['paid', 'partially_refunded']) + ->select('order_lines.product_id') + ->selectRaw('order_lines.title_snapshot AS title') + ->selectRaw('SUM(order_lines.quantity) AS units_sold') + ->selectRaw('SUM(order_lines.total_amount) AS revenue_amount') + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->orderByDesc('revenue_amount') + ->limit(10) + ->get(); + + return response()->json([ + 'data' => [ + 'period' => ['from' => $start->toDateString(), 'to' => $end->toDateString()], + 'summary' => [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => (int) $rows->sum('add_to_cart_count'), + 'checkout_started_count' => (int) $rows->sum('checkout_started_count'), + 'conversion_rate' => $visitsCount > 0 ? round($ordersCount / $visitsCount, 4) : 0.0, + 'currency' => $this->store($storeId)->default_currency, + ], + 'daily' => $daily, + 'top_products' => $topProducts, + ], + ]); + } + + /** @return array> */ + private function collectionRules(int $storeId, bool $isUpdate, ?int $collectionId = null): array + { + $handleRule = Rule::unique('collections', 'handle') + ->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)); + + if ($collectionId !== null) { + $handleRule->ignore($collectionId); + } + + $productIds = ['array']; + $productIdsItem = [ + 'required', + 'integer', + 'distinct', + Rule::exists('products', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)), + ]; + + return [ + 'title' => [$isUpdate ? 'sometimes' : 'required', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', $handleRule], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'type' => [$isUpdate ? 'sometimes' : 'required', 'required', 'string', Rule::in(['manual', 'automated'])], + 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => $productIds, + 'product_ids.*' => $productIdsItem, + 'add_product_ids' => ['sometimes', 'array'], + 'add_product_ids.*' => $productIdsItem, + 'remove_product_ids' => ['sometimes', 'array'], + 'remove_product_ids.*' => $productIdsItem, + ]; + } + + /** @return array> */ + private function discountRules(Request $request, int $storeId, bool $isCreate, ?Discount $discount = null): array + { + $type = $request->input('type', $discount?->code === null ? 'automatic' : 'code'); + $valueType = $request->input('value_type', match ($discount?->type) { + 'percentage' => 'percent', + 'fixed_amount' => 'fixed', + 'free_shipping' => 'free_shipping', + default => null, + }); + $codeRule = Rule::unique('discounts', 'code') + ->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)); + + if ($discount !== null) { + $codeRule->ignore($discount->getKey()); + } + + $valueAmount = [$isCreate ? 'required' : 'sometimes', 'required', 'integer']; + + if ($valueType === 'percent') { + $valueAmount[] = 'min:1'; + $valueAmount[] = 'max:100'; + } elseif ($valueType === 'fixed') { + $valueAmount[] = 'min:1'; + } + + return [ + 'type' => [$isCreate ? 'required' : 'sometimes', 'required', 'string', Rule::in(['code', 'automatic'])], + 'code' => [$isCreate ? Rule::requiredIf($type === 'code') : 'sometimes', 'nullable', 'string', 'max:50', $codeRule], + 'value_type' => [$isCreate ? 'required' : 'sometimes', 'required', 'string', Rule::in(['percent', 'fixed', 'free_shipping'])], + 'value_amount' => $valueAmount, + 'starts_at' => ['sometimes', 'nullable', 'date'], + 'ends_at' => ['sometimes', 'nullable', 'date'], + 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'rules_json' => ['sometimes', 'array:minimum_purchase_amount,applicable_product_ids,applicable_collection_ids,customer_eligibility,once_per_customer'], + 'rules_json.minimum_purchase_amount' => ['sometimes', 'integer', 'min:0'], + 'rules_json.applicable_product_ids' => ['sometimes', 'array'], + 'rules_json.applicable_product_ids.*' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('products', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)), + ], + 'rules_json.applicable_collection_ids' => ['sometimes', 'array'], + 'rules_json.applicable_collection_ids.*' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('collections', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)), + ], + 'rules_json.customer_eligibility' => ['sometimes', 'string', Rule::in(['all', 'specific_customers', 'specific_segments'])], + 'rules_json.once_per_customer' => ['sometimes', 'boolean'], + ]; + } + + private function normalizeDiscountCode(Request $request): void + { + if ($request->exists('code') && is_string($request->input('code'))) { + $request->merge(['code' => mb_strtoupper(trim($request->input('code')))]); + } + } + + /** @param array $validated */ + private function validateDiscountDates(array $validated, ?Discount $discount = null): void + { + $startsAt = array_key_exists('starts_at', $validated) ? $validated['starts_at'] : $discount?->starts_at; + $endsAt = array_key_exists('ends_at', $validated) ? $validated['ends_at'] : $discount?->ends_at; + + if ($startsAt !== null && $endsAt !== null + && CarbonImmutable::parse($endsAt)->lessThanOrEqualTo(CarbonImmutable::parse($startsAt))) { + throw ValidationException::withMessages(['ends_at' => 'The end date must be after the start date.']); + } + } + + /** @param array $validated */ + private function validateEffectiveDiscountValue(array $validated, Discount $discount): void + { + $valueType = $validated['value_type'] ?? match ($discount->type) { + 'percentage' => 'percent', + 'fixed_amount' => 'fixed', + 'free_shipping' => 'free_shipping', + default => $discount->type, + }; + + if ($valueType === 'free_shipping') { + return; + } + + $amount = (int) ($validated['value_amount'] ?? $discount->value); + $isValid = $valueType === 'percent' + ? $amount >= 1 && $amount <= 100 + : $amount >= 1; + + if (! $isValid) { + throw ValidationException::withMessages([ + 'value_amount' => $valueType === 'percent' + ? 'The percentage discount must be between 1 and 100.' + : 'The fixed discount amount must be at least 1 minor unit.', + ]); + } + } + + private function store(int $storeId): Store + { + $store = app('current_store'); + abort_unless($store instanceof Store && (int) $store->id === $storeId, 404); + + /** @var User $user */ + $user = request()->user(); + abort_unless($user->stores()->whereKey($store->id)->exists(), 403); + + return $store; + } + + private function requireJsonObjectField(Request $request, string $field): void + { + $body = json_decode($request->getContent()); + + if (! is_object($body) || ! isset($body->{$field}) || ! is_object($body->{$field})) { + throw ValidationException::withMessages([$field => "The {$field} field must be an object."]); + } + } + + /** @return array */ + private function validateProductPayload(Request $request, int $storeId, bool $creating, ?Product $product = null): array + { + $submittedVariants = $request->input('variants'); + + foreach (is_array($submittedVariants) ? $submittedVariants : [] as $index => $variant) { + if (is_array($variant) && isset($variant['currency']) && is_string($variant['currency'])) { + $request->merge([ + 'variants' => collect($request->input('variants'))->map(function (mixed $item, int $position) use ($index, $variant): mixed { + if ($position === $index && is_array($item)) { + $item['currency'] = strtoupper($variant['currency']); + } + + return $item; + })->all(), + ]); + } + } + + $handleRule = Rule::unique('products', 'handle')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)); + + if ($product !== null) { + $handleRule->ignore($product->getKey()); + } + + $rules = [ + 'title' => [$creating ? 'required' : 'sometimes', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', $handleRule], + 'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tags' => ['sometimes', 'array', 'max:50'], + 'tags.*' => ['required', 'string', 'max:255'], + 'status' => ['sometimes', 'string', Rule::in($creating ? ['draft', 'active'] : ['draft', 'active', 'archived'])], + 'options' => ['sometimes', 'array', 'max:3'], + 'options.*' => ['required', 'array:name,position,values'], + 'options.*.name' => ['required', 'string', 'max:255'], + 'options.*.position' => ['required', 'integer', 'min:1', 'max:3', 'distinct'], + 'options.*.values' => ['sometimes', 'array', 'min:1', 'max:100'], + 'options.*.values.*' => ['required', 'string', 'max:255', 'distinct:ignore_case'], + 'collections' => ['sometimes', 'array', 'max:100'], + 'collections.*' => [ + 'required', 'integer', 'distinct', + Rule::exists('collections', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $storeId)), + ], + 'variants' => [$creating ? 'required' : 'sometimes', 'array', 'min:1', 'max:100'], + 'variants.*' => ['required', 'array:id,sku,barcode,price_amount,compare_at_amount,currency,weight_g,requires_shipping,is_default,position,status,option_values,inventory'], + 'variants.*.id' => ['sometimes', 'integer', $product === null ? 'prohibited' : Rule::exists('product_variants', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('product_id', $product->getKey()))], + 'variants.*.sku' => [$creating ? 'required' : 'sometimes', 'required', 'string', 'max:255', 'distinct'], + 'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'], + 'variants.*.price_amount' => [$creating ? 'required' : 'sometimes', 'required', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['sometimes', 'string', 'size:3', Rule::in([strtoupper((string) app('current_store')->default_currency)])], + 'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['sometimes', 'boolean'], + 'variants.*.is_default' => ['sometimes', 'boolean'], + 'variants.*.position' => ['sometimes', 'integer', 'min:1', 'max:100', 'distinct'], + 'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])], + 'variants.*.option_values' => ['sometimes', 'array', 'max:3'], + 'variants.*.option_values.*' => ['required', 'array:option_name,value'], + 'variants.*.option_values.*.option_name' => ['required', 'string', 'max:255'], + 'variants.*.option_values.*.value' => ['required', 'string', 'max:255'], + 'variants.*.inventory' => ['sometimes', 'array:quantity_on_hand,policy'], + 'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])], + 'delete_variant_ids' => [$creating ? 'prohibited' : 'sometimes', 'array', 'max:100'], + 'delete_variant_ids.*' => [ + 'required', 'integer', 'distinct', + Rule::exists('product_variants', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('product_id', $product?->getKey())), + ], + ]; + + $validated = $request->validate($rules); + + if (isset($validated['options'])) { + $optionNames = collect($validated['options'])->pluck('name')->map(static fn (string $name): string => mb_strtolower(trim($name))); + + if ($optionNames->unique()->count() !== $optionNames->count()) { + throw ValidationException::withMessages(['options' => 'Option names must be unique.']); + } + + if (($validated['variants'] ?? []) !== []) { + $optionValueSets = []; + + foreach ($validated['options'] as $option) { + $name = mb_strtolower(trim($option['name'])); + $optionValueSets[$name] = collect($option['values'] ?? [])->map(static fn (string $value): string => mb_strtolower(trim($value)))->all(); + } + + foreach ($validated['variants'] as $variantIndex => $variantData) { + $submittedOptionNames = collect($variantData['option_values'] ?? [])->pluck('option_name')->map(static fn (string $name): string => mb_strtolower(trim($name))); + + if ($submittedOptionNames->unique()->count() !== $submittedOptionNames->count()) { + throw ValidationException::withMessages(["variants.{$variantIndex}.option_values" => 'An option may appear only once per variant.']); + } + + foreach ($variantData['option_values'] ?? [] as $valueIndex => $optionValue) { + $name = mb_strtolower(trim($optionValue['option_name'])); + $value = mb_strtolower(trim($optionValue['value'])); + + if (! array_key_exists($name, $optionValueSets)) { + throw ValidationException::withMessages(["variants.{$variantIndex}.option_values.{$valueIndex}.option_name" => 'The option does not belong to this product.']); + } + + if ($optionValueSets[$name] !== [] && ! in_array($value, $optionValueSets[$name], true)) { + throw ValidationException::withMessages(["variants.{$variantIndex}.option_values.{$valueIndex}.value" => 'The option value is not defined for this product.']); + } + } + } + + $matrixSize = collect($validated['options'])->reduce(fn (int $size, array $option): int => $size * max(1, count($option['values'] ?? collect($validated['variants'])->flatMap(fn (array $variant): array => collect($variant['option_values'] ?? [])->filter(fn (array $choice): bool => mb_strtolower($choice['option_name']) === mb_strtolower($option['name']))->pluck('value')->unique()->all())->unique()->all())), 1); + + if ($matrixSize > 100) { + throw ValidationException::withMessages(['options' => 'The option matrix may not contain more than 100 variants.']); + } + } + } + + $variants = $validated['variants'] ?? []; + $defaultVariants = collect($variants)->filter(static fn (array $variant): bool => (bool) ($variant['is_default'] ?? false))->count(); + + if ($creating && $variants !== [] && $defaultVariants > 1) { + throw ValidationException::withMessages(['variants' => 'Exactly one variant may be the default.']); + } + + if ($creating && $variants !== [] && $defaultVariants === 0) { + $variants[0]['is_default'] = true; + $validated['variants'] = $variants; + } + + foreach ($variants as $position => $variant) { + if (isset($variant['compare_at_amount'], $variant['price_amount']) && $variant['compare_at_amount'] <= $variant['price_amount']) { + throw ValidationException::withMessages(["variants.{$position}.compare_at_amount" => 'The compare-at price must be greater than the variant price.']); + } + } + + $this->assertProductSkusAvailable($variants, $storeId, $product); + + return $validated; + } + + /** @param list> $variants */ + private function assertProductSkusAvailable(array $variants, int $storeId, ?Product $product): void + { + foreach ($variants as $variant) { + $sku = $variant['sku'] ?? null; + + if (! is_string($sku) || trim($sku) === '') { + continue; + } + + $duplicate = DB::table('product_variants') + ->join('products', 'products.id', '=', 'product_variants.product_id') + ->where('products.store_id', $storeId) + ->where('product_variants.sku', trim($sku)) + ->when($product !== null, fn (QueryBuilder $query): QueryBuilder => $query->where('product_variants.product_id', '!=', $product->getKey())) + ->when(isset($variant['id']), fn (QueryBuilder $query): QueryBuilder => $query->where('product_variants.id', '!=', $variant['id'])) + ->exists(); + + if ($duplicate) { + throw ValidationException::withMessages(['variants.sku' => 'The SKU is already in use in this store.']); + } + } + } + + /** @return array */ + private function orderListResource(Order $order): array + { + return [ + 'id' => $order->getKey(), + 'order_number' => $order->order_number, + 'status' => $order->status, + 'financial_status' => $order->financial_status, + 'fulfillment_status' => $order->fulfillment_status, + 'customer' => $order->customer === null ? null : [ + 'id' => $order->customer->getKey(), + 'name' => $order->customer->name, + 'email' => $order->customer->email, + ], + 'currency' => $order->currency, + 'subtotal_amount' => (int) $order->subtotal_amount, + 'discount_amount' => (int) $order->discount_amount, + 'shipping_amount' => (int) $order->shipping_amount, + 'tax_amount' => (int) $order->tax_amount, + 'total_amount' => (int) $order->total_amount, + 'line_count' => (int) $order->lines_count, + 'placed_at' => $order->placed_at?->toISOString(), + 'created_at' => $order->created_at?->toISOString(), + ]; + } + + /** @return array */ + private function orderResource(Order $order): array + { + return [ + 'id' => $order->getKey(), + 'store_id' => $order->store_id, + 'order_number' => $order->order_number, + 'status' => $order->status, + 'financial_status' => $order->financial_status, + 'fulfillment_status' => $order->fulfillment_status, + 'customer' => $order->customer === null ? null : [ + 'id' => $order->customer->getKey(), + 'name' => $order->customer->name, + 'email' => $order->customer->email, + ], + 'email' => $order->email, + 'currency' => $order->currency, + 'subtotal_amount' => (int) $order->subtotal_amount, + 'discount_amount' => (int) $order->discount_amount, + 'shipping_amount' => (int) $order->shipping_amount, + 'tax_amount' => (int) $order->tax_amount, + 'total_amount' => (int) $order->total_amount, + 'billing_address_json' => $order->billing_address_json, + 'shipping_address_json' => $order->shipping_address_json, + 'lines' => $order->lines->map(static fn ($line): array => [ + 'id' => $line->getKey(), + 'product_id' => $line->product_id, + 'variant_id' => $line->variant_id, + 'title_snapshot' => $line->title_snapshot, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->total_amount, + 'tax_lines_json' => $line->tax_lines_json ?? [], + 'discount_allocations_json' => $line->discount_allocations_json ?? [], + ])->all(), + 'payments' => $order->payments->map(static fn ($payment): array => [ + 'id' => $payment->getKey(), + 'provider' => $payment->provider, + 'method' => $payment->method, + 'provider_payment_id' => $payment->provider_payment_id, + 'status' => $payment->status, + 'amount' => (int) $payment->amount, + 'currency' => $payment->currency, + 'created_at' => $payment->created_at === null ? null : CarbonImmutable::parse($payment->created_at)->toISOString(), + ])->all(), + 'fulfillments' => $order->fulfillments->map(static fn ($fulfillment): array => [ + 'id' => $fulfillment->getKey(), + 'status' => $fulfillment->status, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toISOString(), + 'delivered_at' => $fulfillment->delivered_at?->toISOString(), + 'line_items' => $fulfillment->lines->map(static fn ($line): array => [ + 'order_line_id' => $line->order_line_id, + 'quantity' => (int) $line->quantity, + ])->all(), + ])->all(), + 'refunds' => $order->refunds->map(static fn ($refund): array => [ + 'id' => $refund->getKey(), + 'payment_id' => $refund->payment_id, + 'provider_refund_id' => $refund->provider_refund_id, + 'amount' => (int) $refund->amount, + 'reason' => $refund->reason, + 'status' => $refund->status === 'processed' ? 'completed' : $refund->status, + 'created_at' => $refund->created_at === null ? null : CarbonImmutable::parse($refund->created_at)->toISOString(), + 'line_items' => $refund->lines->map(static fn ($line): array => [ + 'order_line_id' => $line->order_line_id, + 'quantity' => (int) $line->quantity, + 'amount' => (int) $line->amount, + ])->all(), + ])->all(), + 'placed_at' => $order->placed_at?->toISOString(), + 'created_at' => $order->created_at?->toISOString(), + 'updated_at' => $order->updated_at?->toISOString(), + ]; + } + + /** @return list */ + private function permissionsForRole(string $role): array + { + return match ($role) { + 'owner', 'admin' => [ + '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', + ], + 'staff' => [ + 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', + 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', + 'read-content', 'write-content', + ], + 'support' => ['read-products', 'read-orders', 'read-customers', 'read-collections', 'read-discounts'], + default => [], + }; + } +} diff --git a/app/Http/Controllers/Api/OrderExportController.php b/app/Http/Controllers/Api/OrderExportController.php new file mode 100644 index 00000000..63a4fa64 --- /dev/null +++ b/app/Http/Controllers/Api/OrderExportController.php @@ -0,0 +1,120 @@ +currentStore($storeId); + Gate::authorize('viewAny', Order::class); + $payload = json_decode($request->getContent()); + + if (! is_object($payload) && $payload !== []) { + throw ValidationException::withMessages(['request' => 'The request body must be a JSON object.']); + } + + if (is_object($payload) && property_exists($payload, 'filters') && ! is_object($payload->filters)) { + throw ValidationException::withMessages(['filters' => 'The filters field must be an object.']); + } + + $validated = $request->validate([ + 'format' => ['sometimes', Rule::in(['csv'])], + 'filters' => ['sometimes', 'array'], + 'filters.status' => ['sometimes', Rule::in(['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])], + 'filters.financial_status' => ['sometimes', Rule::in(['pending', 'paid', 'partially_refunded', 'refunded'])], + 'filters.fulfillment_status' => ['sometimes', Rule::in(['unfulfilled', 'partial', 'fulfilled'])], + 'filters.customer_id' => [ + 'sometimes', 'integer', 'min:1', + Rule::exists('customers', 'id')->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $store->getKey())), + ], + 'filters.created_after' => ['sometimes', 'date'], + 'filters.created_before' => ['sometimes', 'date', 'after_or_equal:filters.created_after'], + 'filters.query' => ['sometimes', 'string', 'max:255'], + ]); + + $export = OrderExport::query()->create([ + 'store_id' => $store->getKey(), + 'requested_by_user_id' => $request->user()->getKey(), + 'format' => $validated['format'] ?? 'csv', + 'filters_json' => $validated['filters'] ?? [], + 'status' => 'queued', + ]); + + GenerateOrderExport::dispatch((int) $export->getKey())->onConnection('database'); + + return response()->json([ + 'export_id' => $export->getKey(), + 'status' => $export->status, + 'created_at' => $export->created_at?->toISOString(), + ], 202); + } + + public function show(int $storeId, int $exportId): JsonResponse + { + $store = $this->currentStore($storeId); + Gate::authorize('viewAny', Order::class); + $export = OrderExport::query() + ->where('store_id', $store->getKey()) + ->findOrFail($exportId); + $downloadExpiresAt = $export->status === 'completed' && filled($export->storage_key) + ? now()->addHour() + : null; + $downloadUrl = $downloadExpiresAt === null + ? null + : URL::temporarySignedRoute('api.admin.exports.download', $downloadExpiresAt, [ + 'storeId' => $store->getKey(), + 'exportId' => $export->getKey(), + ]); + + return response()->json(['data' => [ + 'id' => $export->getKey(), + 'status' => $export->status, + 'format' => $export->format, + 'row_count' => $export->row_count, + 'download_url' => $downloadUrl, + 'download_expires_at' => $downloadExpiresAt?->toISOString(), + 'created_at' => $export->created_at?->toISOString(), + 'completed_at' => $export->completed_at?->toISOString(), + ]]); + } + + public function download(int $storeId, int $exportId): StreamedResponse + { + $export = OrderExport::query() + ->where('store_id', $storeId) + ->where('status', 'completed') + ->findOrFail($exportId); + + abort_unless(is_string($export->storage_key) && Storage::disk('local')->exists($export->storage_key), 404); + + return Storage::disk('local')->download( + $export->storage_key, + "orders-export-{$export->getKey()}.csv", + ['Content-Type' => 'text/csv; charset=utf-8'], + ); + } + + private function currentStore(int $storeId): Store + { + $store = app('current_store'); + abort_unless($store instanceof Store && (int) $store->getKey() === $storeId, 404); + + return $store; + } +} diff --git a/app/Http/Controllers/Api/PlatformController.php b/app/Http/Controllers/Api/PlatformController.php new file mode 100644 index 00000000..58e6196f --- /dev/null +++ b/app/Http/Controllers/Api/PlatformController.php @@ -0,0 +1,109 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'billing_email' => ['required', 'email', 'max:255'], + ]); + + $organization = Organization::query()->create($validated); + + return response()->json(['data' => $organization->only(['id', 'name', 'billing_email', 'created_at', 'updated_at'])], 201); + } + + public function createStore(Request $request): Response + { + $validated = $request->validate([ + 'organization_id' => ['required', 'integer', 'exists:organizations,id'], + 'name' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'string', 'max:63', 'regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', 'unique:stores,handle'], + 'default_currency' => ['required', 'string', 'size:3', 'regex:/^[A-Z]{3}$/', function (string $attribute, mixed $value, \Closure $fail): void { + $currencyBundle = class_exists(ResourceBundle::class) ? ResourceBundle::create('en', 'ICUDATA-curr') : null; + $currencies = $currencyBundle instanceof ResourceBundle ? $currencyBundle->get('Currencies') : null; + + if (! $currencies instanceof ResourceBundle || ! $currencies->get($value) instanceof ResourceBundle) { + $fail('The selected currency must be a valid ISO 4217 code.'); + } + }], + 'default_locale' => ['required', 'string', 'max:12', function (string $attribute, mixed $value, \Closure $fail): void { + $normalizedLocale = Locale::canonicalize(str_replace('-', '_', $value)); + + if (! ResourceBundle::getLocales('') || ! in_array($normalizedLocale, ResourceBundle::getLocales(''), true)) { + $fail('The selected locale must be a valid locale code.'); + } + }], + 'timezone' => ['required', 'string', 'timezone'], + ]); + + $store = DB::transaction(function () use ($request, $validated): Store { + $store = Store::query()->create([ + ...$validated, + 'status' => 'active', + ]); + $store->users()->attach($request->user()->getKey(), ['role' => 'owner']); + + return $store; + }); + + return response()->json(['data' => $store->only([ + 'id', + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + 'created_at', + ])], 201); + } + + public function createInvitation(Request $request, int $storeId): Response + { + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'string', 'in:owner,admin,staff,support'], + ]); + $store = Store::query()->findOrFail($storeId); + + if ($store->users()->whereRaw('LOWER(users.email) = ?', [mb_strtolower($validated['email'])])->exists()) { + return response()->json(['message' => 'This user is already a member of the store.'], 409); + } + + $invitedAt = now(); + $expiresAt = $invitedAt->copy()->addDays(7); + $token = Str::random(64); + + $invitation = StoreInvitation::query()->create([ + 'store_id' => $store->id, + 'email' => mb_strtolower($validated['email']), + 'role' => $validated['role'], + 'token_hash' => hash('sha256', $token), + 'invited_at' => $invitedAt, + 'expires_at' => $expiresAt, + ]); + + return response()->json(['data' => [ + 'email' => $invitation->email, + 'role' => $invitation->role, + 'invited_at' => $invitation->invited_at, + 'expires_at' => $invitation->expires_at, + ]], 201); + } +} diff --git a/app/Http/Controllers/Api/ProductMediaUploadController.php b/app/Http/Controllers/Api/ProductMediaUploadController.php new file mode 100644 index 00000000..c74420f0 --- /dev/null +++ b/app/Http/Controllers/Api/ProductMediaUploadController.php @@ -0,0 +1,163 @@ +where('store_id', $storeId)->findOrFail($productId); + Gate::authorize('update', $product); + + $validated = $request->validate([ + 'filename' => ['required', 'string', 'max:255', 'regex:/\A[a-zA-Z0-9][a-zA-Z0-9._ -]*\.(?:jpe?g|png|webp|avif|mp4)\z/i'], + 'content_type' => ['required', 'string', Rule::in(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'video/mp4'])], + 'byte_size' => ['required', 'integer', 'min:1'], + ]); + $extensionsByMime = [ + 'image/jpeg' => ['jpg', 'jpeg'], + 'image/png' => ['png'], + 'image/webp' => ['webp'], + 'image/avif' => ['avif'], + 'video/mp4' => ['mp4'], + ]; + $extension = Str::lower(pathinfo($validated['filename'], PATHINFO_EXTENSION)); + $contentType = $validated['content_type']; + + if (! in_array($extension, $extensionsByMime[$contentType], true)) { + throw ValidationException::withMessages(['filename' => 'The filename extension must match the content type.']); + } + + $isVideo = $contentType === 'video/mp4'; + $maximumBytes = (int) config($isVideo ? 'shop.media.video_max_bytes' : 'shop.media.image_max_bytes'); + + if ($validated['byte_size'] > $maximumBytes) { + throw ValidationException::withMessages(['byte_size' => 'The media file exceeds the configured size limit.']); + } + + $expiresAt = now()->addMinutes(max((int) config('shop.media.signed_upload_minutes'), 1)); + $storageKey = "stores/{$storeId}/products/{$productId}/media/".Str::uuid().'.'.$extension; + $media = $product->media()->create([ + 'type' => $isVideo ? 'video' : 'image', + 'storage_key' => $storageKey, + 'mime_type' => $contentType, + 'byte_size' => $validated['byte_size'], + 'position' => (int) $product->media()->max('position') + 1, + 'status' => 'processing', + 'created_at' => now(), + ]); + + return response()->json([ + 'upload_url' => URL::temporarySignedRoute('api.product-media.upload', $expiresAt, ['mediaId' => $media->id]), + 'method' => 'PUT', + 'headers' => ['Content-Type' => $contentType], + 'storage_key' => $storageKey, + 'media_id' => $media->id, + 'expires_at' => $expiresAt->toISOString(), + ], 201); + } + + /** + * Store media bytes using the signed upload target. + */ + public function upload(Request $request, int $mediaId): JsonResponse + { + $media = ProductMedia::query()->findOrFail($mediaId); + + if ($media->status !== 'processing') { + return response()->json(['message' => 'This upload URL has already been used.'], 409); + } + + if ($request->header('Content-Type') !== $media->mime_type) { + throw ValidationException::withMessages(['content_type' => 'The content type does not match the upload request.']); + } + + $maximumBytes = (int) config($media->type === 'video' ? 'shop.media.video_max_bytes' : 'shop.media.image_max_bytes'); + $temporaryPath = tempnam(sys_get_temp_dir(), 'shop-media-'); + + if ($temporaryPath === false) { + return response()->json(['message' => 'The upload could not be processed.'], 500); + } + + $target = fopen($temporaryPath, 'wb'); + $source = $request->getContent(true); + $stored = false; + $actualBytes = 0; + + try { + if (! is_resource($target) || ! is_resource($source)) { + return response()->json(['message' => 'The upload could not be processed.'], 500); + } + + $actualBytes = stream_copy_to_stream($source, $target, $maximumBytes + 1); + + if ($actualBytes === false || $actualBytes > $maximumBytes) { + throw ValidationException::withMessages(['byte_size' => 'The media file exceeds the configured size limit.']); + } + + if ($actualBytes !== (int) $media->byte_size) { + throw ValidationException::withMessages(['byte_size' => 'The uploaded byte count does not match the requested byte size.']); + } + + fflush($target); + fclose($target); + $target = null; + + if ((new \finfo(FILEINFO_MIME_TYPE))->file($temporaryPath) !== $media->mime_type) { + throw ValidationException::withMessages(['content_type' => 'The uploaded file content does not match its content type.']); + } + + $stream = fopen($temporaryPath, 'rb'); + + if (! is_resource($stream)) { + return response()->json(['message' => 'The upload could not be processed.'], 500); + } + + try { + $stored = Storage::disk('public')->put($media->storage_key, $stream); + } finally { + fclose($stream); + } + } finally { + if (is_resource($target)) { + fclose($target); + } + + if (is_resource($source)) { + fclose($source); + } + + @unlink($temporaryPath); + } + + if (! $stored) { + $media->forceFill(['status' => 'failed'])->save(); + + return response()->json(['message' => 'The media file could not be stored.'], 500); + } + + if ($media->type === 'image') { + ProcessMediaUpload::dispatch($media->id)->onConnection('database'); + } else { + $media->forceFill(['status' => 'ready'])->save(); + } + + return response()->json(['media' => $media->refresh()]); + } +} diff --git a/app/Http/Controllers/Api/StoreConfigurationController.php b/app/Http/Controllers/Api/StoreConfigurationController.php new file mode 100644 index 00000000..8850ebdd --- /dev/null +++ b/app/Http/Controllers/Api/StoreConfigurationController.php @@ -0,0 +1,231 @@ + */ + private const ISO_COUNTRY_CODES = [ + 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', + 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', + 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', + 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', + 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', + 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', + 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', + 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', + 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', + 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', + 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', + 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', + 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', + 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW', + ]; + + public function pages(Request $request, int $storeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('viewAny', Page::class); + $filters = $request->validate([ + 'status' => ['sometimes', 'string', Rule::in(['draft', 'published'])], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]); + + return response()->json($resources->listPages($store, $filters)); + } + + public function createPage(Request $request, int $storeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('create', Page::class); + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', Rule::unique('pages', 'handle')->where('store_id', $store->getKey())], + 'body_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'status' => ['sometimes', 'string', Rule::in(['draft', 'published'])], + ]); + $page = $resources->createPage($store, $validated); + + return response()->json(['data' => $resources->pageResource($page)], 201); + } + + public function updatePage(Request $request, int $storeId, int $pageId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + $page = Page::query()->where('store_id', $store->getKey())->findOrFail($pageId); + Gate::authorize('update', $page); + $validated = $request->validate([ + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', Rule::unique('pages', 'handle')->where('store_id', $store->getKey())->ignore($page->getKey())], + 'body_html' => ['sometimes', 'nullable', 'string', 'max:65535'], + 'status' => ['sometimes', 'string', Rule::in(['draft', 'published'])], + ]); + $updated = $resources->updatePage($page, $validated); + + return response()->json(['data' => $resources->pageResource($updated)]); + } + + public function deletePage(int $storeId, int $pageId): JsonResponse + { + $store = $this->store($storeId); + $page = Page::query()->where('store_id', $store->getKey())->findOrFail($pageId); + Gate::authorize('delete', $page); + $page->delete(); + + return response()->json(['message' => 'Page deleted']); + } + + public function shippingZones(int $storeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-shipping'); + + return response()->json(['data' => $resources->listShippingZones($store)]); + } + + public function createShippingZone(Request $request, int $storeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-shipping'); + $validated = $this->validateShippingZone($request, $store, $resources); + $zone = $resources->createShippingZone($store, $validated); + + return response()->json(['data' => $resources->shippingZoneResource($zone->load('rates'))], 201); + } + + public function updateShippingZone(Request $request, int $storeId, int $zoneId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-shipping'); + $zone = ShippingZone::query()->where('store_id', $store->getKey())->findOrFail($zoneId); + $validated = $this->validateShippingZone($request, $store, $resources, $zone->getKey()); + $resources->updateShippingZone($zone, $validated); + + return response()->json(['data' => $resources->shippingZoneResource($zone->load('rates'))]); + } + + public function createShippingRate(Request $request, int $storeId, int $zoneId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-shipping'); + $zone = ShippingZone::query()->where('store_id', $store->getKey())->findOrFail($zoneId); + $this->requireJsonObjectField($request, 'config_json'); + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', Rule::in(['flat', 'weight', 'price', 'carrier'])], + 'config_json' => ['required', 'array', 'min:1'], + 'config_json.price_amount' => ['required_if:type,flat', 'integer', 'min:0'], + 'config_json.currency' => ['sometimes', 'string', 'size:3'], + 'config_json.tiers' => ['required_if:type,weight,price', 'array', 'min:1'], + 'config_json.tiers.*.min_weight_g' => ['required_if:type,weight', 'integer', 'min:0'], + 'config_json.tiers.*.max_weight_g' => ['sometimes', 'nullable', 'integer', 'gt:config_json.tiers.*.min_weight_g'], + 'config_json.tiers.*.min_order_amount' => ['required_if:type,price', 'integer', 'min:0'], + 'config_json.tiers.*.max_order_amount' => ['sometimes', 'nullable', 'integer', 'gt:config_json.tiers.*.min_order_amount'], + 'config_json.tiers.*.price_amount' => ['required_if:type,weight,price', 'integer', 'min:0'], + 'is_active' => ['sometimes', 'boolean'], + ]); + + if (isset($validated['config_json']['currency']) && strtoupper($validated['config_json']['currency']) !== strtoupper($store->default_currency)) { + throw ValidationException::withMessages(['config_json.currency' => 'The shipping rate currency must match the store currency.']); + } + + if (isset($validated['config_json']['currency'])) { + $validated['config_json']['currency'] = strtoupper($validated['config_json']['currency']); + } + + $rate = $resources->createShippingRate($zone, $validated); + + return response()->json(['data' => [ + 'id' => $rate->getKey(), + 'name' => $rate->name, + 'type' => $rate->type, + 'config_json' => $rate->config_json, + 'is_active' => $rate->is_active, + ]], 201); + } + + public function createTheme(Request $request, int $storeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + Gate::authorize('manage-themes'); + $validated = $request->validate([ + 'file' => ['required', 'file', 'mimes:zip', 'extensions:zip', 'max:51200'], + 'name' => ['sometimes', 'nullable', 'string', 'max:255'], + ]); + $data = $resources->installTheme($store, $validated['file'], $validated['name'] ?? null); + + return response()->json(['data' => $data], 201); + } + + public function publishTheme(int $storeId, int $themeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + $theme = Theme::query()->where('store_id', $store->getKey())->findOrFail($themeId); + Gate::authorize('manage-themes'); + + return response()->json(['data' => $resources->publishTheme($theme)]); + } + + public function updateThemeSettings(Request $request, int $storeId, int $themeId, AdminStoreConfigurationService $resources): JsonResponse + { + $store = $this->store($storeId); + $theme = Theme::query()->where('store_id', $store->getKey())->findOrFail($themeId); + Gate::authorize('manage-themes'); + $this->requireJsonObjectField($request, 'settings_json'); + $validated = $request->validate(['settings_json' => ['present', 'array']]); + + return response()->json(['data' => $resources->updateThemeSettings($theme, $validated['settings_json'])]); + } + + /** @return array{name: string, countries_json: list, regions_json?: list} */ + private function validateShippingZone(Request $request, Store $store, AdminStoreConfigurationService $resources, ?int $zoneId = null): array + { + $countries = $request->input('countries_json'); + + if (is_array($countries)) { + $countries = array_map(static fn (mixed $country): mixed => is_string($country) ? strtoupper(trim($country)) : $country, $countries); + $request->merge(['countries_json' => $countries]); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'countries_json' => ['required', 'array', 'min:1'], + 'countries_json.*' => ['required', 'string', 'size:2', 'distinct:strict', Rule::in(self::ISO_COUNTRY_CODES)], + 'regions_json' => ['sometimes', 'array'], + 'regions_json.*' => ['required', 'string', 'max:100'], + ]); + + if ($resources->countryOverlap($store, $validated['countries_json'], $zoneId)) { + throw ValidationException::withMessages(['countries_json' => 'One or more countries already belong to another shipping zone.']); + } + + return $validated; + } + + private function requireJsonObjectField(Request $request, string $field): void + { + $body = json_decode($request->getContent()); + + if (! is_object($body) || ! isset($body->{$field}) || ! is_object($body->{$field})) { + throw ValidationException::withMessages([$field => "The {$field} field must be an object."]); + } + } + + private function store(int $storeId): Store + { + return Store::query()->findOrFail($storeId); + } +} diff --git a/app/Http/Controllers/Api/StorefrontController.php b/app/Http/Controllers/Api/StorefrontController.php new file mode 100644 index 00000000..fbf43042 --- /dev/null +++ b/app/Http/Controllers/Api/StorefrontController.php @@ -0,0 +1,652 @@ +validate(['currency' => [ + 'nullable', + 'string', + 'size:3', + 'regex:/^[A-Za-z]{3}$/', + static function (string $attribute, mixed $value, \Closure $fail): void { + $currencies = class_exists(\ResourceBundle::class) + ? \ResourceBundle::create('en', 'ICUDATA-curr')?->get('Currencies') + : null; + + if (! $currencies instanceof \ResourceBundle || $currencies->get(strtoupper((string) $value)) === null) { + $fail('The selected currency is not a valid ISO 4217 currency code.'); + } + }, + ]]); + $customer = auth('customer')->user(); + $cart = $customer + ? $carts->getOrCreateActiveCart($this->store(), $customer) + : Cart::query()->create([ + 'store_id' => $this->store()->id, + 'currency' => $this->store()->default_currency, + 'cart_version' => 1, + 'status' => 'active', + ]); + + if (isset($validated['currency'])) { + $cart->update(['currency' => strtoupper($validated['currency'])]); + } + + return response()->json($this->cartPayload($cart->refresh()->load('lines.variant.product', 'lines.variant.inventoryItem')), 201); + } + + public function showCart(int $cartId): JsonResponse + { + return response()->json($this->cartPayload($this->cart($cartId))); + } + + public function addCartLine(Request $request, int $cartId, CartService $carts): JsonResponse + { + $validated = $request->validate([ + 'variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'expected_version' => ['sometimes', 'integer', 'min:1'], + ]); + + try { + $variant = ProductVariant::query()->whereHas('product', fn ($query) => $query->where('store_id', $this->store()->id))->findOrFail($validated['variant_id']); + $cart = $carts->add($this->cart($cartId), $variant, $validated['quantity'], $validated['expected_version'] ?? null); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'current_version' => $exception->currentVersion], 409); + } + + return response()->json($this->cartPayload($cart), 201); + } + + public function updateCartLine(Request $request, int $cartId, int $lineId, CartService $carts): JsonResponse + { + $validated = $request->validate([ + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ]); + + try { + $cart = $carts->updateQuantity($this->cart($cartId), $lineId, $validated['quantity'], $validated['cart_version'] ?? $validated['expected_version']); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'current_version' => $exception->currentVersion], 409); + } + + return response()->json($this->cartPayload($cart)); + } + + public function deleteCartLine(Request $request, int $cartId, int $lineId, CartService $carts): JsonResponse + { + $validated = $request->validate([ + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ]); + + try { + $cart = $carts->remove($this->cart($cartId), $lineId, $validated['cart_version'] ?? $validated['expected_version']); + } catch (CartVersionConflictException $exception) { + return response()->json(['message' => $exception->getMessage(), 'current_version' => $exception->currentVersion], 409); + } + + return response()->json($this->cartPayload($cart)); + } + + public function createCheckout(Request $request, CheckoutService $checkouts): JsonResponse + { + $validated = $request->validate([ + 'cart_id' => ['required', 'integer'], + 'email' => ['required', 'email', 'max:255'], + ]); + $checkout = $checkouts->start($this->cart($validated['cart_id']), auth('customer')->user()); + $checkout = $checkouts->setContact($checkout, $validated['email']); + $checkout = $checkouts->recalculate($checkout->load('cart.lines.variant.product')); + + return response()->json($this->checkoutPayload($checkout->load('cart.lines.variant.product')), 201); + } + + public function showCheckout(int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $checkout = $this->checkout($checkoutId); + + return response()->json($this->checkoutPayload($checkouts->recalculate($checkout->load('cart.lines.variant.product')))); + } + + public function updateAddress(Request $request, int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $validated = $request->validate([ + 'email' => ['sometimes', 'email', 'max:255'], + 'shipping_address' => ['sometimes', 'nullable', 'array'], + 'billing_address' => ['sometimes', 'nullable', 'array'], + 'use_shipping_as_billing' => ['sometimes', 'boolean'], + ]); + $currentCheckout = $this->checkout($checkoutId); + $email = (string) ($validated['email'] ?? $currentCheckout->email ?? ''); + $billingAddress = ($validated['use_shipping_as_billing'] ?? true) + ? null + : ($validated['billing_address'] ?? null); + $checkout = $checkouts->setAddress($currentCheckout, $email, $validated['shipping_address'] ?? [], $billingAddress); + + return response()->json($this->checkoutPayload($checkout->load('cart.lines.variant.product'))); + } + + public function updateShipping(Request $request, int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $validated = $request->validate(['shipping_method_id' => ['sometimes', 'nullable', 'integer']]); + $checkout = $checkouts->selectShippingMethod($this->checkout($checkoutId), $validated['shipping_method_id'] ?? null); + + return response()->json($this->checkoutPayload($checkout->load('cart.lines.variant.product'))); + } + + public function updatePayment(Request $request, int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $validated = $request->validate(['payment_method' => ['required', Rule::enum(PaymentMethod::class)]]); + $checkout = $this->checkout($checkoutId); + $method = PaymentMethod::from($validated['payment_method']); + $checkout = $checkout->status === 'payment_selected' + ? $checkouts->changePaymentMethod($checkout, $method) + : $checkouts->selectPaymentMethod($checkout, $method); + + return response()->json($this->checkoutPayload($checkout->load('cart.lines.variant.product'))); + } + + public function applyDiscount(Request $request, int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $validated = $request->validate(['code' => ['required', 'string', 'max:50']]); + $checkout = $checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']); + + return response()->json($this->checkoutPayload($checkout->load('cart.lines.variant.product'))); + } + + public function removeDiscount(int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $checkout = $checkouts->removeDiscount($this->checkout($checkoutId)); + + return response()->json($this->checkoutPayload($checkout)); + } + + public function pay(Request $request, int $checkoutId, CheckoutService $checkouts): JsonResponse + { + $validated = $request->validate([ + 'payment_method' => ['required', Rule::enum(PaymentMethod::class)], + 'card_number' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'max:32', 'regex:/^[0-9 ]+$/'], + 'card_expiry' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'regex:/^(0[1-9]|1[0-2])\/[0-9]{2}$/'], + 'card_cvc' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'regex:/^[0-9]{3,4}$/'], + 'card_holder' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'max:255'], + ]); + $checkout = $this->checkout($checkoutId); + $method = PaymentMethod::from($validated['payment_method']); + + if ($checkout->status === 'shipping_selected') { + $checkout = $checkouts->selectPaymentMethod($checkout, $method); + } elseif ($checkout->status === 'payment_selected' && $checkout->payment_method !== $method->value) { + $checkout = $checkouts->changePaymentMethod($checkout, $method); + } elseif ($checkout->status !== 'payment_selected') { + abort(409, 'Select an address and shipping method before payment.'); + } + + $paymentDetails = [...$validated, 'card_number' => isset($validated['card_number']) ? preg_replace('/\s+/', '', $validated['card_number']) : null]; + $order = $checkouts->pay($checkout, $paymentDetails); + + $response = [ + 'checkout_id' => $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, + ], + 'confirmation_url' => URL::signedRoute('storefront.confirmation', ['checkoutId' => $order->checkout_id]), + 'order_access_token' => hash_hmac('sha256', (string) $order->id, (string) config('app.key')), + ]; + + if ($method === PaymentMethod::BankTransfer) { + $response['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($response); + } + + public function showOrder(string $orderNumber): JsonResponse + { + $providedToken = (string) request()->query('token', ''); + $order = Order::query()->with('lines', 'payments', 'fulfillments.lines')->where('order_number', $orderNumber)->firstOrFail(); + + if ($providedToken === '' || ! hash_equals(hash_hmac('sha256', (string) $order->id, (string) config('app.key')), $providedToken)) { + abort(401, 'A valid order access token is required.'); + } + + 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_snapshot, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + ]), + 'totals' => [ + 'subtotal_amount' => $order->subtotal_amount, + 'discount_amount' => $order->discount_amount, + 'shipping_amount' => $order->shipping_amount, + 'tax_amount' => $order->tax_amount, + 'total_amount' => $order->total_amount, + ], + 'shipping_address' => $order->shipping_address_json, + 'fulfillments' => $order->fulfillments, + ]); + } + + public function search(Request $request, \App\Services\SearchService $search): JsonResponse + { + if (mb_strlen((string) $request->query('q', '')) > 200) { + return response()->json(['message' => 'The query may not be greater than 200 characters.'], 400); + } + + $validated = $request->validate([ + 'q' => ['required', 'string', 'min:1', 'max:200'], + 'sort' => ['sometimes', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'], + 'filters' => ['sometimes', 'string', 'max:4000'], + ]); + $queryText = trim($validated['q']); + $filters = []; + + if (isset($validated['filters'])) { + try { + $decodedFilters = json_decode($validated['filters'], false, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return response()->json(['message' => 'The filters parameter must contain a valid JSON object.'], 400); + } + + if (! is_object($decodedFilters)) { + return response()->json(['message' => 'The filters parameter must contain a JSON object.'], 400); + } + + $filters = \Illuminate\Support\Facades\Validator::make(get_object_vars($decodedFilters), [ + 'collection_id' => ['sometimes', 'integer', 'min:1'], + 'price_min' => ['sometimes', 'integer', 'min:0'], + 'price_max' => ['sometimes', 'integer', 'min:0'], + 'in_stock' => ['sometimes', 'boolean'], + 'tags' => ['sometimes', 'array', 'max:10'], + 'tags.*' => ['required', 'string', 'max:255'], + 'vendor' => ['sometimes', 'string', 'max:255'], + ])->validate(); + + if (isset($filters['price_min'], $filters['price_max']) && $filters['price_max'] < $filters['price_min']) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'filters.price_max' => 'The maximum price must be at least the minimum price.', + ]); + } + } + + $query = Product::query()->where('status', 'active')->whereNotNull('published_at'); + $ftsTerms = $search->fullTextExpression($this->store()->id, $queryText); + $ids = $search->rankedProductIds($this->store()->id, $ftsTerms, 10000); + + $query->where(function ($builder) use ($ids, $ftsTerms, $queryText): void { + if ($ids !== []) { + $builder->whereIn('id', $ids)->orWhere('title', 'like', '%'.addcslashes($queryText, '%_\\').'%'); + } elseif ($ftsTerms === '') { + $builder->whereRaw('1 = 0'); + } else { + $builder->where('title', 'like', '%'.addcslashes($queryText, '%_\\').'%') + ->orWhere('description_html', 'like', '%'.addcslashes($queryText, '%_\\').'%') + ->orWhere('vendor', 'like', '%'.addcslashes($queryText, '%_\\').'%'); + } + }); + + if (isset($filters['collection_id'])) { + $query->whereHas('collections', fn ($builder) => $builder->whereKey((int) $filters['collection_id'])); + } + + if (isset($filters['price_min']) || isset($filters['price_max'])) { + $query->whereHas('variants', function ($builder) use ($filters): void { + $builder->when(isset($filters['price_min']), fn ($variantQuery) => $variantQuery->where('price_amount', '>=', (int) $filters['price_min'])) + ->when(isset($filters['price_max']), fn ($variantQuery) => $variantQuery->where('price_amount', '<=', (int) $filters['price_max'])); + }); + } + + if (($filters['in_stock'] ?? false) === true) { + $query->whereHas('variants.inventoryItem', fn ($builder) => $builder->whereRaw('quantity_on_hand > quantity_reserved')); + } + + if (! empty($filters['vendor'])) { + $query->where('vendor', $filters['vendor']); + } + + foreach (array_slice($filters['tags'] ?? [], 0, 10) as $tag) { + $query->whereJsonContains('tags', (string) $tag); + } + + match ($validated['sort'] ?? 'relevance') { + 'price_asc' => $query->withMin('variants', 'price_amount')->orderBy('variants_min_price_amount'), + 'price_desc' => $query->withMin('variants', 'price_amount')->orderByDesc('variants_min_price_amount'), + 'newest' => $query->orderByDesc('published_at'), + 'best_selling' => $query->addSelect(['sold_units' => DB::table('order_lines') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->whereColumn('order_lines.product_id', 'products.id') + ->where('orders.store_id', $this->store()->id) + ->whereIn('orders.financial_status', ['paid', 'partially_refunded']) + ->where('orders.placed_at', '>=', now()->subDays(30)) + ->selectRaw('COALESCE(SUM(order_lines.quantity), 0)')])->orderByDesc('sold_units')->orderBy('title'), + default => $this->orderBySearchRelevance($query, $ids, $queryText), + }; + + $page = (int) ($validated['page'] ?? 1); + $perPage = (int) ($validated['per_page'] ?? 24); + $matchedProducts = (clone $query)->reorder()->select('products.id')->distinct()->toBase(); + $vendors = (clone $query) + ->reorder() + ->select('products.vendor') + ->selectRaw('COUNT(DISTINCT products.id) AS aggregate_count') + ->whereNotNull('products.vendor') + ->groupBy('products.vendor') + ->orderBy('products.vendor') + ->get() + ->map(static fn (object $facet): array => ['value' => $facet->vendor, 'count' => (int) $facet->aggregate_count]) + ->values(); + $minimumPrices = DB::table('product_variants') + ->joinSub($matchedProducts, 'matched_products', static fn ($join) => $join->on('matched_products.id', '=', 'product_variants.product_id')) + ->select('product_variants.product_id') + ->selectRaw('MIN(product_variants.price_amount) AS product_minimum_price') + ->groupBy('product_variants.product_id'); + $priceRange = DB::query() + ->fromSub($minimumPrices, 'matched_product_prices') + ->selectRaw('MIN(product_minimum_price) AS minimum_price, MAX(product_minimum_price) AS maximum_price') + ->first(); + $tagCounts = []; + DB::table('products') + ->joinSub($matchedProducts, 'matched_products', static fn ($join) => $join->on('matched_products.id', '=', 'products.id')) + ->select('products.tags') + ->orderBy('products.id') + ->chunk(500, static function ($rows) use (&$tagCounts): void { + foreach ($rows as $row) { + foreach (json_decode((string) $row->tags, true) ?: [] as $tag) { + if (is_string($tag)) { + $tagCounts[$tag] = ($tagCounts[$tag] ?? 0) + 1; + } + } + } + }); + $tags = collect($tagCounts) + ->map(static fn (int $count, string $value): array => ['value' => $value, 'count' => $count]) + ->values(); + $paginator = (clone $query) + ->withMin('variants', 'price_amount') + ->paginate($perPage, ['products.*'], 'page', $page); + $total = $paginator->total(); + $products = $paginator->getCollection()->load(['variants.inventoryItem', 'media']); + SearchQuery::create(['query' => $queryText, 'results_count' => $total, 'session_id' => session()->getId()]); + + return response()->json([ + 'query' => $queryText, + 'results' => $products->map(fn (Product $product): array => [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => (int) ($product->variants_min_price_amount ?? 0), + 'compare_at_amount' => $product->variants->sortBy('price_amount')->first()?->compare_at_amount, + 'currency' => $this->store()->default_currency, + 'image_url' => $product->media->first()?->url, + 'in_stock' => $product->variants->contains(fn ($variant): bool => ! $variant->inventoryItem || $variant->inventoryItem->policy === 'continue' || $variant->inventoryItem->quantity_on_hand > $variant->inventoryItem->quantity_reserved), + 'tags' => $product->tags, + ]), + 'facets' => [ + 'vendors' => $vendors, + 'tags' => $tags, + 'price_range' => ['min' => (int) ($priceRange->minimum_price ?? 0), 'max' => (int) ($priceRange->maximum_price ?? 0)], + ], + 'pagination' => ['current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $total, 'last_page' => $paginator->lastPage()], + ]); + } + + public function suggest(Request $request): JsonResponse + { + $validated = $request->validate(['q' => ['required', 'string', 'min:1', 'max:100'], 'limit' => ['sometimes', 'integer', 'min:1', 'max:10']]); + $text = trim($validated['q']); + $limit = (int) ($validated['limit'] ?? 5); + $products = Product::query()->where('status', 'active')->whereNotNull('published_at')->where('title', 'like', '%'.addcslashes($text, '%_\\').'%')->with(['variants', 'media'])->limit($limit)->get(); + $collections = Collection::query()->where('status', 'active')->where('title', 'like', '%'.addcslashes($text, '%_\\').'%')->limit($limit)->get(); + $suggestions = $products->map(fn (Product $product): array => [ + 'type' => 'product', + 'title' => $product->title, + 'handle' => $product->handle, + 'image_url' => $product->media->first()?->url, + 'price_amount' => (int) ($product->variants->min('price_amount') ?? 0), + 'currency' => $this->store()->default_currency, + ])->concat($collections->map(fn (Collection $collection): array => [ + 'type' => 'collection', + 'title' => $collection->title, + 'handle' => $collection->handle, + 'image_url' => null, + ]))->take($limit)->values(); + + return response()->json(['query' => $text, 'suggestions' => $suggestions]); + } + + public function recordAnalytics(Request $request): JsonResponse + { + $validated = $request->validate([ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', Rule::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' => ['sometimes', 'array'], + 'events.*.occurred_at' => ['required', 'date', 'after_or_equal:'.now()->subHour()->toIso8601String(), 'before_or_equal:'.now()->addHour()->toIso8601String()], + ]); + $accepted = 0; + + foreach ($validated['events'] as $event) { + $inserted = DB::table('analytics_events')->insertOrIgnore([ + 'store_id' => $this->store()->id, + 'customer_id' => auth('customer')->id(), + 'type' => $event['type'], + 'session_id' => $event['session_id'], + 'client_event_id' => $event['client_event_id'], + 'properties_json' => json_encode($event['properties'] ?? [], JSON_THROW_ON_ERROR), + 'occurred_at' => $event['occurred_at'], + 'payload' => json_encode($event['properties'] ?? [], JSON_THROW_ON_ERROR), + 'created_at' => $event['occurred_at'], + ]); + + $accepted += $inserted; + } + + return response()->json(['accepted' => $accepted, 'rejected' => count($validated['events']) - $accepted], 202); + } + + /** @param list $rankedProductIds */ + private function orderBySearchRelevance(\Illuminate\Database\Eloquent\Builder $query, array $rankedProductIds, string $queryText): void + { + $rankedProductIds = array_values(array_unique(array_map('intval', $rankedProductIds))); + + if ($rankedProductIds === []) { + $query->orderByRaw('CASE WHEN products.title LIKE ? THEN 0 ELSE 1 END', [$queryText.'%']) + ->orderBy('products.title'); + + return; + } + + $rankCases = collect($rankedProductIds) + ->map(static fn (int $productId, int $position): string => "WHEN {$productId} THEN {$position}") + ->implode(' '); + + $query->orderByRaw('CASE products.id '.$rankCases.' ELSE '.count($rankedProductIds).' END') + ->orderBy('products.title'); + } + + private function store(): Store + { + $store = app('current_store'); + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function cart(int $id): Cart + { + $cart = Cart::query()->with('lines.variant.product', 'lines.variant.inventoryItem')->findOrFail($id); + $customerId = auth('customer')->id(); + + abort_unless($customerId === null ? $cart->customer_id === null : (int) $cart->customer_id === (int) $customerId, 404); + + return $cart; + } + + private function checkout(int $id): Checkout + { + $checkout = Checkout::query()->where('store_id', $this->store()->id)->findOrFail($id); + $cart = $checkout->cart; + + if (($customerId = auth('customer')->id()) === null ? $cart->customer_id !== null : (int) $cart->customer_id !== (int) $customerId) { + abort(404); + } + + if ($checkout->status === 'expired' || ($checkout->status !== 'completed' && $checkout->expires_at?->isPast())) { + app(CheckoutService::class)->expire($checkout); + abort(410, 'This checkout has expired.'); + } + + return $checkout; + } + + /** @return array */ + private function cartPayload(Cart $cart): array + { + $cart->loadMissing('lines.variant.product.media', 'lines.variant.inventoryItem'); + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + $discount = (int) $cart->lines->sum('line_discount_amount'); + $itemCount = (int) $cart->lines->sum('quantity'); + + 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' => $cart->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'product_id' => $line->variant?->product_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' => (bool) $line->variant?->requires_shipping, + 'available_quantity' => $line->variant?->inventoryItem + ? max(0, $line->variant->inventoryItem->quantity_on_hand - $line->variant->inventoryItem->quantity_reserved) + : null, + ])->values(), + 'totals' => [ + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => max(0, $subtotal - $discount), + 'currency' => $cart->currency, + 'line_count' => $cart->lines->count(), + 'item_count' => $itemCount, + ], + 'created_at' => $cart->created_at, + 'updated_at' => $cart->updated_at, + ]; + } + + /** @return array */ + private function checkoutPayload(Checkout $checkout): array + { + $checkout->loadMissing('cart.lines.variant.product.media', 'cart.lines.variant.inventoryItem'); + $discountAllocations = $checkout->totals_json['discountAllocations'] ?? []; + $shippingMethods = app(CheckoutService::class)->availableShippingRates($checkout)->map(fn ($rate): array => [ + 'id' => $rate->id, + 'name' => $rate->name, + 'type' => $rate->type, + 'price_amount' => $rate->price_amount, + 'currency' => $checkout->cart?->currency, + 'estimated_days_min' => data_get($rate->config_json, 'estimated_days_min'), + 'estimated_days_max' => data_get($rate->config_json, 'estimated_days_max'), + ])->values(); + + 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, + 'shipping_address' => $checkout->shipping_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'billing_address' => $checkout->billing_address_json, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_method_id' => $checkout->shipping_method_id, + 'payment_method' => $checkout->payment_method, + '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' => max(0, $line->line_subtotal_amount - (int) ($discountAllocations[$line->variant_id] ?? $discountAllocations[(string) $line->variant_id] ?? 0)), + ])->values(), + 'available_shipping_methods' => $shippingMethods, + 'expires_at' => $checkout->expires_at, + 'created_at' => $checkout->created_at, + 'totals' => [ + 'subtotal' => $checkout->subtotal_amount, + 'discount' => $checkout->discount_amount, + 'shipping' => $checkout->shipping_amount, + 'tax' => $checkout->tax_amount, + 'total' => $checkout->total_amount, + 'currency' => $checkout->cart?->currency, + ], + ]; + } +} diff --git a/app/Http/Middleware/AuthenticateApiToken.php b/app/Http/Middleware/AuthenticateApiToken.php new file mode 100644 index 00000000..d4d469db --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiToken.php @@ -0,0 +1,94 @@ +attributes->get('api_token'); + + if ($existingToken instanceof PersonalAccessToken) { + $abilities = $existingToken->abilities ?? []; + + if (! $this->tokenMatchesStore($existingToken, $request, $abilities)) { + return response()->json(['message' => 'This token is not authorized for the requested store.'], 403); + } + + if ($requiredAbility && ! in_array($requiredAbility, $abilities, true) && ! in_array('*', $abilities, true)) { + return response()->json(['message' => 'This token is missing the required ability.'], 403); + } + + return $next($request); + } + + $plainTextToken = $request->bearerToken(); + + if (! $plainTextToken) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + $token = PersonalAccessToken::query() + ->where('token', hash('sha256', $plainTextToken)) + ->where(function ($query): void { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->first(); + + if (! $token || ! $token->tokenable instanceof \App\Models\User) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + $abilities = $token->abilities ?? []; + + if (! $this->tokenMatchesStore($token, $request, $abilities)) { + return response()->json(['message' => 'This token is not authorized for the requested store.'], 403); + } + + if ($requiredAbility && ! in_array($requiredAbility, $abilities, true) && ! in_array('*', $abilities, true)) { + return response()->json(['message' => 'This token is missing the required ability.'], 403); + } + + $token->forceFill(['last_used_at' => now()])->save(); + Auth::setUser($token->tokenable); + $request->setUserResolver(fn () => $token->tokenable); + $request->attributes->set('api_token', $token); + + return $next($request); + } + + /** @param list $abilities */ + private function tokenMatchesStore(PersonalAccessToken $token, Request $request, array $abilities): bool + { + if (! $request->is('api/admin/*')) { + return true; + } + + if ($request->is('api/admin/v1/platform/*')) { + return $token->store_id === null && in_array('manage-platform', $abilities, true); + } + + $requestedStoreId = $request->route('storeId'); + + if ($requestedStoreId === null) { + return false; + } + + if ($token->store_id === null) { + return in_array('manage-platform', $abilities, true); + } + + return (int) $token->store_id === (int) $requestedStoreId; + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..af15a662 --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,113 @@ +isAdminRequest($request); + $isAdminApiRequest = $request->is('api/admin/*'); + $store = $request->is('api/admin/*') + ? $this->resolveAdminApiStore($request) + : ($isAdminRequest ? $this->resolveAdminStore($request) : $this->resolveStorefrontStore($request)); + + if (! $store instanceof Store) { + if ($isAdminRequest && $request->user() && $request->user()->stores()->count() > 1) { + return redirect()->route('admin.select-store'); + } + + abort(404); + } + + if ($isAdminApiRequest && ! $request->user()->stores()->whereKey($store->id)->exists()) { + abort(403); + } + + if ($store->status === 'suspended') { + if (! $isAdminRequest && ! $isAdminApiRequest) { + abort(503, 'This storefront is temporarily unavailable.'); + } + + if (! $request->isMethodSafe()) { + abort(403, 'This store is suspended and cannot be changed.'); + } + } + + app()->instance('current_store', $store); + view()->share('currentStore', $store); + + return $next($request); + } + + private function isAdminRequest(Request $request): bool + { + if ($request->is('admin', 'admin/*')) { + return true; + } + + if (! str_starts_with($request->path(), 'livewire')) { + return false; + } + + $refererPath = parse_url((string) $request->headers->get('referer'), PHP_URL_PATH); + + return is_string($refererPath) && ($refererPath === '/admin' || str_starts_with($refererPath, '/admin/')); + } + + private function resolveStorefrontStore(Request $request): ?Store + { + $hostname = strtolower($request->getHost()); + $storeId = Cache::remember("store-domain:{$hostname}", now()->addMinutes(5), function () use ($hostname): ?int { + return StoreDomain::query() + ->where('hostname', $hostname) + ->where('type', 'storefront') + ->value('store_id'); + }); + + return $storeId ? Store::query()->find($storeId) : null; + } + + private function resolveAdminStore(Request $request): ?Store + { + $user = $request->user(); + if (! $user) { + return null; + } + + $storeId = $request->session()->get('current_store_id'); + $membershipStores = $user->stores()->orderBy('stores.id')->get(); + + if (! $storeId || ! $membershipStores->contains('id', (int) $storeId)) { + $request->session()->forget('current_store_id'); + + if ($membershipStores->count() > 1) { + return null; + } + + $storeId = $membershipStores->first()?->id; + } + + if (! $storeId) { + return null; + } + + $request->session()->put('current_store_id', $storeId); + + return $membershipStores->firstWhere('id', (int) $storeId); + } + + private function resolveAdminApiStore(Request $request): ?Store + { + $storeId = $request->route('storeId'); + + return $storeId ? Store::query()->find($storeId) : null; + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..fec1ce20 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,24 @@ + */ + public array $backoff = [60, 300]; + + public function __construct(public ?string $date = null) {} + + public function handle(AnalyticsService $analytics): void + { + $analytics->aggregateForDate($this->date); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..f46a7297 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,34 @@ +get()->keyBy('store_id'); + $pendingOrders = Order::withoutGlobalScopes() + ->where('payment_method', 'bank_transfer') + ->where('financial_status', 'pending') + ->whereNotNull('placed_at') + ->orderBy('id') + ->get(); + + foreach ($pendingOrders as $order) { + $settings = $settingsByStore->get($order->store_id)?->settings_json ?? []; + $expiryDays = max(1, (int) ($settings['bank_transfer_cancel_days'] ?? 7)); + + if ($order->placed_at->lte(now()->subDays($expiryDays))) { + $orders->cancelPending($order); + } + } + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..85a79699 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,40 @@ +subDays(14); + $cartIds = Cart::withoutGlobalScopes() + ->where('status', 'active') + ->where('updated_at', '<=', $cutoff) + ->pluck('id'); + + if ($cartIds->isEmpty()) { + return; + } + + Checkout::withoutGlobalScopes() + ->whereIn('cart_id', $cartIds) + ->whereNotIn('status', ['completed', 'expired']) + ->orderBy('id') + ->get() + ->each(fn (Checkout $checkout) => $checkouts->expire($checkout)); + + DB::table('carts')->whereIn('id', $cartIds)->where('status', 'active')->update([ + 'status' => 'abandoned', + 'updated_at' => now(), + ]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..2e331f99 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,34 @@ + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + public int $timeout = 20; + + /** @param array $payload */ + public function __construct( + public int $subscriptionId, + public string $deliveryId, + public string $eventType, + public array $payload, + public int $timestamp, + ) {} + + /** @param array $payload */ + public function handle(WebhookService $webhooks): void + { + $webhooks->deliver($this->subscriptionId, $this->deliveryId, $this->eventType, $this->payload, $this->timestamp); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..5bf97620 --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,27 @@ +whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->whereNotIn('status', ['completed', 'expired']) + ->orderBy('id') + ->chunkById(100, function ($expiredCheckouts) use ($checkouts): void { + foreach ($expiredCheckouts as $checkout) { + $checkouts->expire($checkout); + } + }); + } +} diff --git a/app/Jobs/GenerateAnalyticsExport.php b/app/Jobs/GenerateAnalyticsExport.php new file mode 100644 index 00000000..1d9db43a --- /dev/null +++ b/app/Jobs/GenerateAnalyticsExport.php @@ -0,0 +1,86 @@ +findOrFail($this->exportId); + $export->update(['status' => 'processing', 'error_message' => null]); + $store = Store::query()->findOrFail($export->store_id); + $report = $reports->report( + $store, + CarbonImmutable::parse($export->from_date, 'UTC'), + CarbonImmutable::parse($export->to_date, 'UTC'), + $export->channel, + $export->device, + ); + $stream = fopen('php://temp', 'w+'); + + if ($stream === false) { + throw new RuntimeException('Could not open a temporary stream for the analytics export.'); + } + + try { + fputcsv($stream, ['Date', 'Sales amount', 'Currency', 'Orders'], ',', '"', ''); + + foreach ($report['daily'] as $day) { + fputcsv($stream, [ + $day['date'], + $day['revenue_amount'], + $report['currency'], + $day['orders_count'], + ], ',', '"', ''); + } + + rewind($stream); + $contents = stream_get_contents($stream); + + if ($contents === false) { + throw new RuntimeException('The analytics export could not be read from its temporary stream.'); + } + + $path = 'analytics-exports/'.$export->store_id.'/'.$export->getKey().'.csv'; + + if (! Storage::disk('local')->put($path, $contents)) { + throw new RuntimeException('The analytics export could not be written to storage.'); + } + + $export->update([ + 'status' => 'completed', + 'storage_key' => $path, + 'completed_at' => now(), + 'error_message' => null, + ]); + } finally { + fclose($stream); + } + } + + public function failed(?Throwable $exception): void + { + AnalyticsExport::query()->whereKey($this->exportId)->update([ + 'status' => 'failed', + 'error_message' => $exception === null ? null : mb_substr($exception->getMessage(), 0, 2000), + ]); + } +} diff --git a/app/Jobs/GenerateOrderExport.php b/app/Jobs/GenerateOrderExport.php new file mode 100644 index 00000000..fcc7b5b3 --- /dev/null +++ b/app/Jobs/GenerateOrderExport.php @@ -0,0 +1,137 @@ +findOrFail($this->exportId); + $export->update(['status' => 'processing', 'error_message' => null]); + $path = 'order-exports/'.$export->store_id.'/'.$export->getKey().'.csv'; + $stream = fopen('php://temp', 'w+'); + + if ($stream === false) { + throw new \RuntimeException('Could not open a temporary stream for the order export.'); + } + + try { + fputcsv($stream, [ + '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', + ], ',', '"', ''); + $rowCount = 0; + $query = Order::withoutGlobalScopes() + ->where('store_id', $export->store_id) + ->with([ + 'customer' => fn ($customers) => $customers->where('store_id', $export->store_id), + 'checkout' => fn ($checkouts) => $checkouts->where('store_id', $export->store_id)->with('shippingMethod.zone'), + 'fulfillments', + ]); + $this->applyFilters($query, $export->filters_json ?? [], (int) $export->store_id); + + $query->orderBy('id')->chunkById(500, function ($orders) use ($stream, &$rowCount): void { + foreach ($orders as $order) { + fputcsv($stream, [ + $order->order_number, + $order->created_at?->toIso8601String(), + $order->status, + $order->financial_status, + $order->fulfillment_status, + $order->customer?->email ?? $order->email, + $order->customer?->name ?? data_get($order->shipping_address_json, 'name'), + $order->subtotal_amount, + $order->discount_amount, + $order->shipping_amount, + $order->tax_amount, + $order->total_amount, + $order->currency, + $this->storeShippingMethodName($order), + $order->fulfillments->pluck('tracking_number')->filter()->unique()->implode(', '), + ], ',', '"', ''); + $rowCount++; + } + }); + + rewind($stream); + + if (! Storage::disk('local')->put($path, $stream)) { + throw new \RuntimeException('The order export could not be written to storage.'); + } + + $export->update([ + 'status' => 'completed', + 'row_count' => $rowCount, + 'storage_key' => $path, + 'completed_at' => now(), + 'error_message' => null, + ]); + } finally { + fclose($stream); + } + } + + public function failed(?Throwable $exception): void + { + OrderExport::query()->whereKey($this->exportId)->update([ + 'status' => 'failed', + 'error_message' => $exception === null ? null : mb_substr($exception->getMessage(), 0, 2000), + ]); + } + + /** @param array $filters */ + private function applyFilters(\Illuminate\Database\Eloquent\Builder $query, array $filters, int $storeId): void + { + foreach (['status', 'financial_status', 'fulfillment_status', 'customer_id'] as $filter) { + if (isset($filters[$filter])) { + $query->where($filter, $filters[$filter]); + } + } + + if (isset($filters['created_after'])) { + $query->where('placed_at', '>=', CarbonImmutable::parse($filters['created_after'])->toDateTimeString()); + } + + if (isset($filters['created_before'])) { + $query->where('placed_at', '<=', CarbonImmutable::parse($filters['created_before'])->toDateTimeString()); + } + + if (isset($filters['query'])) { + $text = $filters['query']; + $query->where(function ($builder) use ($text, $storeId): void { + $builder->where('order_number', 'like', "%{$text}%") + ->orWhere('email', 'like', "%{$text}%") + ->orWhereHas('customer', fn ($customers) => $customers + ->withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('email', 'like', "%{$text}%")); + }); + } + } + + private function storeShippingMethodName(Order $order): ?string + { + $method = $order->checkout?->shippingMethod; + + return $method !== null && (int) $method->zone?->store_id === (int) $order->store_id + ? $method->name + : null; + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..69f10a12 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,133 @@ +findOrFail($this->mediaId); + + if ($media->type !== 'image' || $media->status === 'ready') { + return; + } + + $disk = Storage::disk('public'); + $original = $disk->get($media->storage_key); + $image = new \Imagick; + + try { + if (! $image->readImageBlob($original)) { + throw new RuntimeException('The uploaded image could not be decoded.'); + } + + if ($image->getNumberImages() > 1) { + $image->setIteratorIndex(0); + $firstFrame = $image->getImage(); + $image->clear(); + $image->destroy(); + $image = $firstFrame; + } + + if (method_exists($image, 'autoOrientImage')) { + $image->autoOrientImage(); + } + + $width = $image->getImageWidth(); + $height = $image->getImageHeight(); + + if ($width < 1 || $height < 1) { + throw new RuntimeException('The uploaded image has invalid dimensions.'); + } + + $extension = strtolower(pathinfo($media->storage_key, PATHINFO_EXTENSION)); + $format = $extension === 'jpg' ? 'jpeg' : $extension; + $directory = "media/{$media->product_id}/{$media->id}"; + $sizes = ['thumbnail' => 150, 'small' => 300, 'medium' => 600, 'large' => 1200]; + + foreach ($sizes as $size => $maxDimension) { + $sized = clone $image; + + try { + $sized->setImagePage(0, 0, 0, 0); + + if ($sized->getImageWidth() > $maxDimension || $sized->getImageHeight() > $maxDimension) { + $sized->thumbnailImage($maxDimension, $maxDimension, true, false); + } + + $sized->setImageFormat($format); + $sized->setImageCompressionQuality(84); + + if (! $disk->put("{$directory}/{$size}.{$extension}", $sized->getImageBlob())) { + throw new RuntimeException("Could not store the {$size} image derivative."); + } + + if (\Imagick::queryFormats('WEBP') !== []) { + $webp = clone $sized; + + try { + $webp->setImageFormat('webp'); + $webp->setImageCompressionQuality(82); + + if (! $disk->put("{$directory}/{$size}.webp", $webp->getImageBlob())) { + throw new RuntimeException("Could not store the {$size} WebP derivative."); + } + } finally { + $webp->clear(); + $webp->destroy(); + } + } + } finally { + $sized->clear(); + $sized->destroy(); + } + } + + $media->forceFill(['width' => $width, 'height' => $height, 'status' => 'ready'])->save(); + } catch (Throwable $exception) { + if ($this->attempts() >= $this->tries) { + $this->markFailed($media, $exception); + } + + throw $exception; + } finally { + $image->clear(); + $image->destroy(); + } + } + + public function failed(?Throwable $exception): void + { + $media = ProductMedia::query()->find($this->mediaId); + + if ($media !== null) { + $this->markFailed($media, $exception ?? new RuntimeException('Image processing failed.')); + } + } + + private function markFailed(ProductMedia $media, Throwable $exception): void + { + $media->forceFill(['status' => 'failed'])->save(); + Log::channel('daily')->error('Product media processing failed.', [ + 'media_id' => $media->id, + 'product_id' => $media->product_id, + 'exception' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/ReindexStoreProducts.php b/app/Jobs/ReindexStoreProducts.php new file mode 100644 index 00000000..04577b58 --- /dev/null +++ b/app/Jobs/ReindexStoreProducts.php @@ -0,0 +1,67 @@ +insertOrIgnore(['store_id' => $this->storeId]); + DB::table('search_settings')->where('store_id', $this->storeId)->update(['index_status' => 'processing']); + + try { + $productCount = Product::withoutGlobalScopes()->where('store_id', $this->storeId)->count(); + DB::table('products_fts')->where('store_id', (string) $this->storeId)->delete(); + DB::table('search_settings')->where('store_id', $this->storeId)->update([ + 'documents_count' => 0, + 'pending_updates' => $productCount, + ]); + + Product::withoutGlobalScopes() + ->where('store_id', $this->storeId) + ->orderBy('id') + ->chunkById(500, function ($products): void { + $documents = $products->map(static fn (Product $product): array => [ + 'store_id' => (string) $product->store_id, + 'product_id' => (string) $product->id, + 'title' => $product->title, + 'description' => strip_tags((string) $product->description_html), + 'vendor' => (string) $product->vendor, + 'product_type' => (string) $product->product_type, + 'tags' => implode(' ', $product->tags ?? []), + ])->all(); + + if ($documents !== []) { + DB::table('products_fts')->insert($documents); + DB::table('search_settings')->where('store_id', $this->storeId)->update([ + 'documents_count' => DB::raw('documents_count + '.count($documents)), + 'pending_updates' => DB::raw('MAX(0, pending_updates - '.count($documents).')'), + ]); + } + }); + + DB::table('search_settings')->where('store_id', $this->storeId)->update([ + 'index_status' => 'ready', + 'last_reindex_at' => now(), + 'last_reindex_duration_seconds' => (int) ceil(microtime(true) - $startedAt), + 'documents_count' => DB::table('products_fts')->where('store_id', (string) $this->storeId)->count(), + 'pending_updates' => 0, + ]); + } catch (Throwable $exception) { + DB::table('search_settings')->where('store_id', $this->storeId)->update(['index_status' => 'failed']); + + throw $exception; + } + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..999c33bf --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,163 @@ +dispatchOrder($event->order, 'order.created'); + } + + public function orderPaid(OrderPaid $event): void + { + $this->dispatchOrder($event->order, 'order.paid'); + } + + public function checkoutCompleted(CheckoutCompleted $event): void + { + $this->dispatchOrder($event->order, 'checkout.completed'); + } + + public function orderFulfilled(OrderFulfilled $event): void + { + $this->dispatchOrder($event->order, 'order.fulfilled'); + } + + public function orderCancelled(OrderCancelled $event): void + { + $this->dispatchOrder($event->order, 'order.cancelled'); + } + + public function orderRefunded(OrderRefunded $event): void + { + $order = $event->order; + $this->dispatchOrder($order, 'order.refunded', [ + 'refund' => [ + 'id' => $event->refund->getKey(), + 'amount' => (int) $event->refund->amount, + 'currency' => $order->currency, + 'reason' => $event->refund->reason, + 'status' => $event->refund->status, + ], + ]); + } + + public function productCreated(ProductCreated $event): void + { + $this->dispatchProduct($event->product, 'product.created'); + } + + public function productUpdated(ProductUpdated $event): void + { + $this->dispatchProduct($event->product, 'product.updated'); + } + + public function productDeleted(ProductDeleted $event): void + { + $store = Store::query()->findOrFail($event->storeId); + $this->webhooks->dispatch($store, 'product.deleted', $event->product); + } + + public function productStatusChanged(ProductStatusChanged $event): void + { + if ($event->newlyCreated) { + return; + } + + $eventType = $event->newStatus === 'archived' ? 'product.deleted' : 'product.updated'; + $this->dispatchProduct($event->product, $eventType); + } + + public function fulfillmentShipped(FulfillmentShipped $event): void + { + $order = $event->fulfillment->order; + + if ($order?->fulfillment_status === 'fulfilled') { + $this->dispatchOrder($order, 'order.fulfilled'); + } + } + + public function fulfillmentDelivered(FulfillmentDelivered $event): void + { + $order = $event->fulfillment->order; + + if ($order?->fulfillment_status === 'fulfilled') { + $this->dispatchOrder($order, 'order.fulfilled'); + } + } + + /** @param array $extra */ + private function dispatchOrder(Order $order, string $eventType, array $extra = []): void + { + $order->loadMissing('lines'); + $payload = [ + 'id' => $order->getKey(), + 'store_id' => $order->store_id, + 'order_number' => $order->order_number, + 'status' => $order->status, + 'financial_status' => $order->financial_status, + 'fulfillment_status' => $order->fulfillment_status, + 'currency' => $order->currency, + 'subtotal_amount' => (int) $order->subtotal_amount, + 'discount_amount' => (int) $order->discount_amount, + 'shipping_amount' => (int) $order->shipping_amount, + 'tax_amount' => (int) $order->tax_amount, + 'total_amount' => (int) $order->total_amount, + 'line_items' => $order->lines->map(static fn ($line): array => [ + 'id' => $line->getKey(), + 'product_id' => $line->product_id, + 'variant_id' => $line->variant_id, + 'title' => $line->title_snapshot, + 'variant_title' => $line->variant_title_snapshot, + 'sku' => $line->sku_snapshot, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->total_amount, + ])->all(), + 'placed_at' => $order->placed_at?->toIso8601String(), + ]; + + $this->webhooks->dispatch( + Store::query()->findOrFail($order->store_id), + $eventType, + array_replace($payload, $extra), + ); + } + + private function dispatchProduct(Product $product, string $eventType): void + { + $this->webhooks->dispatch(Store::query()->findOrFail($product->store_id), $eventType, [ + 'id' => $product->getKey(), + 'store_id' => $product->store_id, + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'tags' => $product->tags, + 'published_at' => $product->published_at?->toIso8601String(), + 'created_at' => $product->created_at?->toIso8601String(), + 'updated_at' => $product->updated_at?->toIso8601String(), + ]); + } +} diff --git a/app/Listeners/SendCustomerOrderNotifications.php b/app/Listeners/SendCustomerOrderNotifications.php new file mode 100644 index 00000000..75efa280 --- /dev/null +++ b/app/Listeners/SendCustomerOrderNotifications.php @@ -0,0 +1,72 @@ +send($event->order, CustomerOrderNotification::ORDER_CONFIRMATION); + } + + public function orderRefunded(OrderRefunded $event): void + { + if (! $event->notifyCustomer) { + return; + } + + $this->send($event->order, CustomerOrderNotification::REFUND, [ + 'amount' => (int) $event->refund->amount, + 'reason' => $event->refund->reason, + ]); + } + + public function fulfillmentShipped(FulfillmentShipped $event): void + { + if (! $event->notifyCustomer) { + return; + } + + $fulfillment = $event->fulfillment; + $order = $fulfillment->order; + + if (! $order instanceof Order) { + return; + } + + $this->send($order, CustomerOrderNotification::SHIPPED, [ + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + ]); + } + + public function orderCancelled(OrderCancelled $event): void + { + if (! $event->notifyCustomer) { + return; + } + + $this->send($event->order, CustomerOrderNotification::CANCELLED); + } + + /** @param array $details */ + private function send(Order $order, string $notificationType, array $details = []): void + { + if (blank($order->email)) { + return; + } + + Mail::to($order->email)->queue( + (new CustomerOrderNotification($order, $notificationType, $details))->afterCommit(), + ); + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..101b01a6 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,159 @@ +toDateString(); + $this->customStartDate = $today; + $this->customEndDate = $today; + } + + public function updated(string $property): void + { + $this->validateOnly($property, $this->rules()); + + if ($this->dateRange === 'custom' && in_array($property, ['customStartDate', 'customEndDate'], true)) { + $this->validateReportFilters(); + } + } + + /** @return array */ + public function loadAnalytics(AnalyticsReportingService $reports): array + { + Gate::authorize('view-analytics'); + $this->validateReportFilters(); + [$from, $to] = $this->dateBounds(); + + return $reports->report(app('current_store'), $from, $to, $this->channelFilter, $this->deviceFilter); + } + + public function exportCsv(): void + { + Gate::authorize('view-analytics'); + $this->validateReportFilters(); + [$from, $to] = $this->dateBounds(); + $store = app('current_store'); + $export = AnalyticsExport::query()->create([ + 'store_id' => $store->getKey(), + 'requested_by_user_id' => auth()->id(), + 'from_date' => $from->toDateString(), + 'to_date' => $to->toDateString(), + 'channel' => $this->channelFilter, + 'device' => $this->deviceFilter, + 'status' => 'queued', + ]); + + $this->exportId = (int) $export->getKey(); + $this->isExporting = true; + $this->exportUrl = null; + $this->exportError = null; + + GenerateAnalyticsExport::dispatch($this->exportId)->onConnection('database'); + } + + public function pollExport(): void + { + Gate::authorize('view-analytics'); + + if ($this->exportId === null) { + return; + } + + $export = AnalyticsExport::query() + ->where('store_id', app('current_store')->getKey()) + ->findOrFail($this->exportId); + + if ($export->status === 'completed' && filled($export->storage_key)) { + $this->isExporting = false; + $this->exportUrl = route('admin.analytics.exports.download', ['analyticsExport' => $export->getKey()]); + $this->exportError = null; + + return; + } + + if ($export->status === 'failed') { + $this->isExporting = false; + $this->exportError = 'The analytics export could not be generated. Try again.'; + + return; + } + + $this->isExporting = true; + } + + public function render(AnalyticsReportingService $reports): mixed + { + return view('livewire.admin.analytics.index', [ + 'analytics' => $this->loadAnalytics($reports), + ])->layout('layouts.admin'); + } + + /** @return array> */ + protected function rules(): array + { + return [ + 'dateRange' => ['required', Rule::in(['today', 'last_7_days', 'last_30_days', 'custom'])], + 'customStartDate' => ['nullable', 'date_format:Y-m-d'], + 'customEndDate' => ['nullable', 'date_format:Y-m-d'], + 'channelFilter' => ['required', Rule::in(['all', 'storefront', 'api'])], + 'deviceFilter' => ['required', Rule::in(['all', 'desktop', 'mobile', 'tablet'])], + ]; + } + + private function validateReportFilters(): void + { + $this->validate([ + ...$this->rules(), + 'customStartDate' => [Rule::requiredIf($this->dateRange === 'custom'), 'nullable', 'date_format:Y-m-d'], + 'customEndDate' => [Rule::requiredIf($this->dateRange === 'custom'), 'nullable', 'date_format:Y-m-d', 'after_or_equal:customStartDate'], + ]); + + if ($this->dateRange === 'custom' + && CarbonImmutable::parse($this->customStartDate)->diffInDays(CarbonImmutable::parse($this->customEndDate)) > 364) { + throw ValidationException::withMessages(['customEndDate' => 'Choose a date range of 365 days or less.']); + } + } + + /** @return array{CarbonImmutable, CarbonImmutable} */ + private function dateBounds(): array + { + $today = CarbonImmutable::today('UTC'); + + return match ($this->dateRange) { + 'today' => [$today, $today], + 'last_7_days' => [$today->subDays(6), $today], + 'custom' => [CarbonImmutable::parse($this->customStartDate, 'UTC'), CarbonImmutable::parse($this->customEndDate, 'UTC')], + default => [$today->subDays(29), $today], + }; + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..394846a1 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,94 @@ + */ + public Collection $installedApps; + + public function boot(): void + { + $this->restoreCurrentStore(); + Gate::authorize('manage-apps'); + } + + public function mount(): void + { + $this->storeId = $this->currentStore()->id; + $this->refreshInstalledApps(); + } + + public function uninstallApp(int $appId): void + { + Gate::authorize('manage-apps'); + $store = $this->currentStore(); + $deleted = DB::table('app_installations') + ->where('store_id', $store->id) + ->where('app_id', $appId) + ->delete(); + + abort_if($deleted === 0, 404); + + $this->refreshInstalledApps(); + session()->flash('status', 'App uninstalled.'); + } + + public function render(): mixed + { + return view('livewire.admin.apps.index', [ + 'installedApps' => $this->installedAppsForCurrentStore(), + ])->layout('layouts.admin'); + } + + private function refreshInstalledApps(): void + { + $this->installedApps = $this->installedAppsForCurrentStore(); + } + + /** @return Collection */ + private function installedAppsForCurrentStore(): Collection + { + return DB::table('app_installations') + ->join('apps', 'apps.id', '=', 'app_installations.app_id') + ->where('app_installations.store_id', $this->currentStore()->id) + ->orderBy('apps.name') + ->get([ + 'apps.id', + 'app_installations.id as installation_id', + 'apps.name', + 'apps.description', + 'apps.scopes_json', + 'app_installations.status', + 'app_installations.created_at', + ]); + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + + abort_unless($store instanceof Store, 403); + + return $store; + } + + private function restoreCurrentStore(): void + { + if (app()->bound('current_store') || $this->storeId === null) { + return; + } + + app()->instance('current_store', Store::query()->findOrFail($this->storeId)); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..c1333e56 --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,87 @@ +restoreCurrentStore(); + Gate::authorize('manage-apps'); + } + + public function mount(int $installation): void + { + $this->installationId = $installation; + $this->storeId = $this->currentStore()->id; + $this->installation(); + } + + public function render(): mixed + { + $installation = $this->installation(); + $settings = json_decode((string) $installation->settings_json, true) ?: []; + $requestedScopes = json_decode((string) $installation->scopes_json, true) ?: []; + $subscriptions = DB::table('webhook_subscriptions') + ->where('store_id', $this->currentStore()->id) + ->where('app_installation_id', $this->installationId) + ->orderBy('event_type') + ->get(['id', 'event_type', 'target_url', 'status']); + + return view('livewire.admin.apps.show', [ + 'installation' => $installation, + 'grantedScopes' => data_get($settings, 'granted_scopes', $requestedScopes), + 'subscriptions' => $subscriptions, + 'apiCallCount' => 0, + 'lastApiCallAt' => null, + ])->layout('layouts.admin'); + } + + private function installation(): object + { + return DB::table('app_installations') + ->join('apps', 'apps.id', '=', 'app_installations.app_id') + ->where('app_installations.store_id', $this->currentStore()->id) + ->where('app_installations.id', $this->installationId) + ->first([ + 'app_installations.id as installation_id', + 'app_installations.store_id', + 'app_installations.status', + 'app_installations.settings_json', + 'app_installations.created_at', + 'apps.name', + 'apps.description', + 'apps.scopes_json', + ]) ?? abort(404); + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + + abort_unless($store instanceof Store, 403); + + return $store; + } + + private function restoreCurrentStore(): void + { + if (app()->bound('current_store') || ! isset($this->storeId)) { + return; + } + + app()->instance('current_store', Store::query()->findOrFail($this->storeId)); + } +} diff --git a/app/Livewire/Admin/Auth/ForgotPassword.php b/app/Livewire/Admin/Auth/ForgotPassword.php new file mode 100644 index 00000000..f2befce9 --- /dev/null +++ b/app/Livewire/Admin/Auth/ForgotPassword.php @@ -0,0 +1,24 @@ +validate(['email' => ['required', 'email', 'max:255']]); + Password::broker('users')->sendResetLink(['email' => $this->email]); + $this->statusMessage = 'If an account with that email exists, we sent a password reset link.'; + } + + 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..19e1a4b5 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,62 @@ +validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + $key = 'admin-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages(['email' => 'Too many login attempts. Try again in a minute.']); + } + + RateLimiter::hit($key, 60); + + if (! Auth::guard('web')->attempt(['email' => $this->email, 'password' => $this->password, 'status' => 'active'], $this->remember)) { + throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']); + } + + /** @var User $user */ + $user = Auth::guard('web')->user(); + $stores = $user->stores()->orderBy('stores.name')->get(['stores.id']); + + if ($stores->isEmpty()) { + Auth::guard('web')->logout(); + throw ValidationException::withMessages(['email' => 'This account does not have access to a store.']); + } + + RateLimiter::clear($key); + $user->forceFill(['last_login_at' => now()])->save(); + session()->regenerate(); + + if ($stores->count() > 1) { + session()->forget('current_store_id'); + + return $this->redirectRoute('admin.select-store'); + } + + session()->put('current_store_id', $stores->first()->id); + + return $this->redirectRoute('admin.dashboard'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.login')->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Auth/Logout.php b/app/Livewire/Admin/Auth/Logout.php new file mode 100644 index 00000000..31224664 --- /dev/null +++ b/app/Livewire/Admin/Auth/Logout.php @@ -0,0 +1,23 @@ +logout(); + session()->invalidate(); + session()->regenerateToken(); + + return $this->redirectRoute('admin.login'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.logout')->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Auth/ResetPassword.php b/app/Livewire/Admin/Auth/ResetPassword.php new file mode 100644 index 00000000..ca0fc59d --- /dev/null +++ b/app/Livewire/Admin/Auth/ResetPassword.php @@ -0,0 +1,53 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + public function resetPassword(): mixed + { + $data = $this->validate([ + 'token' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'confirmed', PasswordRule::defaults()], + ]); + $status = Password::broker('users')->reset($data, function (User $user, string $password): void { + $user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save(); + event(new PasswordResetEvent($user)); + }); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages(['email' => __($status)]); + } + + session()->flash('status', 'Your password has been reset. Please sign in.'); + + return $this->redirectRoute('admin.login'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.reset-password')->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..31beeff2 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,67 @@ +findOrFail($collection); + $this->collectionId = $saved->id; + $this->title = $saved->title; + $this->handle = $saved->handle; + $this->descriptionHtml = strip_tags($saved->description_html ?? ''); + $this->status = $saved->status; + } + } + + public function save(HandleGenerator $handles): mixed + { + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', 'in:draft,active,archived'], + ]); + $storeId = app('current_store')->id; + $attributes = [ + 'title' => $this->title, + 'handle' => $handles->generate($this->handle ?: $this->title, 'collections', $storeId, $this->collectionId), + 'description_html' => e($this->descriptionHtml), + 'status' => $this->status, + ]; + + if ($this->collectionId) { + Collection::query()->findOrFail($this->collectionId)->update($attributes); + } else { + Collection::create(['store_id' => $storeId, ...$attributes]); + } + + session()->flash('status', 'Collection saved.'); + + return $this->redirectRoute('admin.collections'); + } + + public function render(): mixed + { + return view('livewire.admin.collections.form', ['isEditing' => $this->collectionId !== null])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..f22ebf53 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,26 @@ + $canManage, + 'collections' => Collection::query()->withCount('products')->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.addcslashes($this->search, '%_\\').'%'))->orderBy('title')->paginate(15), + ])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..537664ac --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,23 @@ +withCount('orders')->when($this->search !== '', fn ($query) => $query->where(static fn ($nested) => $nested->where('email', 'like', '%'.$this->search.'%')->orWhere('name', 'like', '%'.$this->search.'%')))->orderBy('name'); + + return view('livewire.admin.customers.index', ['customers' => $customers->paginate(20)])->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..a74e401e --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,47 @@ +customerId = $customer; + $saved = $this->customer(); + Gate::authorize('view', $saved); + $this->name = $saved->name ?? ''; + $this->marketingOptIn = $saved->marketing_opt_in; + } + + public function save(): void + { + $customer = $this->customer(); + Gate::authorize('update', $customer); + $this->validate(['name' => ['nullable', 'string', 'max:255'], 'marketingOptIn' => ['boolean']]); + $customer->update(['name' => $this->name, 'marketing_opt_in' => $this->marketingOptIn]); + session()->flash('status', 'Customer saved.'); + } + + public function render(): mixed + { + $customer = $this->customer()->load('addresses', 'orders.lines'); + Gate::authorize('view', $customer); + + return view('livewire.admin.customers.show', ['customer' => $customer])->layout('layouts.admin'); + } + + private function customer(): Customer + { + return Customer::query()->findOrFail($this->customerId); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..8cbaa851 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,28 @@ +user()->can('view-admin'), 403); + $storeId = app('current_store')->id; + $orders = Order::query()->where('store_id', $storeId); + $todayOrders = (clone $orders)->whereDate('created_at', today()); + + return view('livewire.admin.dashboard', [ + 'ordersToday' => $todayOrders->count(), + 'revenueToday' => (int) (clone $todayOrders)->whereIn('financial_status', ['paid', 'partially_refunded'])->sum('total_amount'), + 'openOrders' => (clone $orders)->whereIn('status', ['pending', 'paid'])->count(), + 'productsCount' => Product::query()->where('store_id', $storeId)->count(), + 'recentOrders' => (clone $orders)->with('customer')->latest('placed_at')->limit(8)->get(), + 'visits' => AnalyticsEvent::query()->where('store_id', $storeId)->where('type', 'page_view')->whereDate('created_at', today())->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..601772e1 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,237 @@ + */ + public array $tokenAbilities = ['read-products']; + + public string $tokenExpiresAt = ''; + + #[Locked] + public ?string $generatedToken = null; + + public bool $showGenerateTokenModal = false; + + public bool $showWebhookModal = false; + + public ?int $editingWebhookId = null; + + public string $webhookEventType = ''; + + public string $webhookUrl = ''; + + public string $webhookStatus = 'active'; + + public function mount(): void + { + Gate::authorize('manage-developers'); + } + + public function openTokenModal(): void + { + Gate::authorize('manage-developers'); + $this->resetValidation(); + $this->newTokenName = ''; + $this->tokenAbilities = ['read-products']; + $this->tokenExpiresAt = ''; + $this->showGenerateTokenModal = true; + } + + public function generateToken(ApiTokenService $apiTokens): void + { + Gate::authorize('manage-developers'); + $store = $this->currentStore(); + $allowedAbilities = $apiTokens->availableAbilities(); + + $validated = $this->validate([ + 'newTokenName' => ['required', 'string', 'max:255'], + 'tokenAbilities' => ['required', 'array', 'min:1'], + 'tokenAbilities.*' => ['required', 'string', 'distinct', Rule::in($allowedAbilities)], + 'tokenExpiresAt' => ['nullable', 'date', 'after:today'], + ], [ + 'tokenAbilities.min' => 'Select at least one API permission.', + 'tokenExpiresAt.after' => 'Choose an expiry date after today.', + ]); + + $expiresAt = filled($validated['tokenExpiresAt']) + ? CarbonImmutable::parse($validated['tokenExpiresAt'])->endOfDay() + : null; + + $issued = $apiTokens->create( + auth()->user(), + $store, + $validated['newTokenName'], + $validated['tokenAbilities'], + $expiresAt, + ); + + $this->generatedToken = $issued['plain_text_token']; + $this->showGenerateTokenModal = false; + $this->newTokenName = ''; + $this->tokenAbilities = ['read-products']; + $this->tokenExpiresAt = ''; + session()->flash('status', 'API token created. Copy it now; it will not be shown again.'); + } + + public function dismissGeneratedToken(): void + { + Gate::authorize('manage-developers'); + $this->generatedToken = null; + } + + public function revokeToken(int $tokenId, ApiTokenService $apiTokens): void + { + Gate::authorize('manage-developers'); + $token = PersonalAccessToken::query() + ->where('store_id', $this->currentStore()->getKey()) + ->findOrFail($tokenId); + + $apiTokens->revoke($token, $this->currentStore()); + $this->generatedToken = null; + session()->flash('status', 'API token revoked.'); + } + + public function openWebhookModal(?int $webhookId = null): void + { + Gate::authorize('manage-developers'); + $this->resetValidation(); + $this->editingWebhookId = null; + $this->webhookEventType = ''; + $this->webhookUrl = ''; + $this->webhookStatus = 'active'; + + if ($webhookId !== null) { + $webhook = $this->webhookForCurrentStore($webhookId); + $this->editingWebhookId = $webhook->id; + $this->webhookEventType = $webhook->event_type; + $this->webhookUrl = $webhook->target_url; + $this->webhookStatus = $webhook->status; + } + + $this->showWebhookModal = true; + } + + public function saveWebhook(): void + { + Gate::authorize('manage-developers'); + $store = $this->currentStore(); + + $validated = $this->validate([ + 'webhookEventType' => ['required', Rule::in(self::WEBHOOK_EVENT_TYPES)], + 'webhookUrl' => ['required', 'string', 'url:http,https', 'max:2048'], + 'webhookStatus' => ['required', Rule::in(self::WEBHOOK_STATUSES)], + ], [ + 'webhookEventType.in' => 'Select a supported webhook event.', + 'webhookUrl.url' => 'Enter a valid HTTP or HTTPS endpoint URL.', + 'webhookStatus.in' => 'Select a supported webhook status.', + ]); + + $attributes = [ + 'event_type' => $validated['webhookEventType'], + 'target_url' => $validated['webhookUrl'], + 'status' => $validated['webhookStatus'], + ]; + + if ($this->editingWebhookId === null) { + WebhookSubscription::query()->create([ + ...$attributes, + 'store_id' => $store->getKey(), + 'app_installation_id' => null, + 'signing_secret_encrypted' => bin2hex(random_bytes(32)), + ]); + } else { + $this->webhookForCurrentStore($this->editingWebhookId)->update($attributes); + } + + $this->showWebhookModal = false; + $this->resetWebhookForm(); + session()->flash('status', 'Webhook subscription saved.'); + } + + public function deleteWebhook(int $webhookId): void + { + Gate::authorize('manage-developers'); + $this->webhookForCurrentStore($webhookId)->delete(); + + if ($this->editingWebhookId === $webhookId) { + $this->showWebhookModal = false; + $this->resetWebhookForm(); + } + + session()->flash('status', 'Webhook subscription deleted.'); + } + + public function render(): mixed + { + Gate::authorize('manage-developers'); + $storeId = $this->currentStore()->getKey(); + + return view('livewire.admin.developers.index', [ + 'tokens' => PersonalAccessToken::query() + ->where('store_id', $storeId) + ->where('tokenable_type', (new User)->getMorphClass()) + ->with('tokenable:id,name') + ->orderByDesc('created_at') + ->get(), + 'webhooks' => WebhookSubscription::query() + ->where('store_id', $storeId) + ->with('latestDelivery') + ->orderByDesc('created_at') + ->get(), + 'tokenAbilityOptions' => app(ApiTokenService::class)->availableAbilities(), + 'webhookEventTypes' => self::WEBHOOK_EVENT_TYPES, + ])->layout('layouts.admin'); + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function webhookForCurrentStore(int $webhookId): WebhookSubscription + { + return WebhookSubscription::query() + ->where('store_id', $this->currentStore()->getKey()) + ->findOrFail($webhookId); + } + + private function resetWebhookForm(): void + { + $this->editingWebhookId = null; + $this->webhookEventType = ''; + $this->webhookUrl = ''; + $this->webhookStatus = 'active'; + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..f29cb177 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,267 @@ + */ + public array $specificProductIds = []; + + /** @var list */ + 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 function mount(?int $discount = null): void + { + Gate::authorize('manage-discounts'); + $this->startsAt = now()->format('Y-m-d\TH:i'); + + if ($discount === null) { + return; + } + + $saved = $this->discountQuery()->findOrFail($discount); + $rules = $saved->rules_json ?? []; + $this->discountId = $saved->id; + $this->type = ($rules['activation_method'] ?? null) === 'automatic' || $saved->code === null ? 'automatic' : 'code'; + $this->title = $saved->title; + $this->code = $saved->code ?? ''; + $this->valueType = $saved->type; + $this->valueAmount = $saved->type === 'free_shipping' ? null : (int) $saved->value; + $this->minimumPurchaseAmount = $saved->minimum_subtotal_amount; + $this->specificProductIds = $this->existingIds($rules['product_ids'] ?? [], Product::class); + $this->specificCollectionIds = $this->existingIds($rules['collection_ids'] ?? [], Collection::class); + $this->usageLimit = $saved->usage_limit; + $this->onePerCustomer = (bool) ($rules['one_per_customer'] ?? false); + $this->startsAt = $saved->starts_at?->format('Y-m-d\TH:i') ?? now()->format('Y-m-d\TH:i'); + $this->endsAt = $saved->ends_at?->format('Y-m-d\TH:i'); + $this->isActive = $saved->is_active; + } + + public function generateCode(): void + { + Gate::authorize('manage-discounts'); + + do { + $code = Str::upper(Str::random(10)); + } while ($this->discountQuery()->where('code', $code)->exists()); + + $this->code = $code; + $this->resetValidation('code'); + } + + public function addProduct(int $productId): void + { + Gate::authorize('manage-discounts'); + $product = Product::query()->where('store_id', $this->storeId())->findOrFail($productId); + + if (! in_array($product->id, $this->specificProductIds, true)) { + $this->specificProductIds[] = $product->id; + } + + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + Gate::authorize('manage-discounts'); + $this->specificProductIds = array_values(array_filter( + $this->specificProductIds, + static fn (int $selectedId): bool => $selectedId !== $productId, + )); + } + + public function addCollection(int $collectionId): void + { + Gate::authorize('manage-discounts'); + $collection = Collection::query()->where('store_id', $this->storeId())->findOrFail($collectionId); + + if (! in_array($collection->id, $this->specificCollectionIds, true)) { + $this->specificCollectionIds[] = $collection->id; + } + + $this->collectionSearch = ''; + } + + public function removeCollection(int $collectionId): void + { + Gate::authorize('manage-discounts'); + $this->specificCollectionIds = array_values(array_filter( + $this->specificCollectionIds, + static fn (int $selectedId): bool => $selectedId !== $collectionId, + )); + } + + public function save(): mixed + { + Gate::authorize('manage-discounts'); + + $discount = $this->discountId === null + ? null + : $this->discountQuery()->findOrFail($this->discountId); + + $this->code = $this->type === 'code' ? Str::upper(trim($this->code)) : ''; + $valueRules = $this->valueType === 'free_shipping' + ? ['nullable', 'integer'] + : ['required', 'integer', 'min:1']; + if ($this->valueType === 'percentage') { + $valueRules[] = 'max:100'; + } + $uniqueCodeRule = Rule::unique('discounts', 'code') + ->where(fn (QueryBuilder $query): QueryBuilder => $query->where('store_id', $this->storeId())); + + if ($discount !== null) { + $uniqueCodeRule->ignore($discount); + } + + $validated = $this->validate([ + 'type' => ['required', Rule::in(['code', 'automatic'])], + 'title' => ['required', 'string', 'max:255'], + 'code' => [Rule::requiredIf($this->type === 'code'), 'nullable', 'string', 'max:64', $uniqueCodeRule], + 'valueType' => ['required', Rule::in(['percentage', 'fixed_amount', 'free_shipping'])], + 'valueAmount' => $valueRules, + 'minimumPurchaseAmount' => ['nullable', 'integer', 'min:0'], + 'specificProductIds' => ['array'], + 'specificProductIds.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $this->storeId())], + 'specificCollectionIds' => ['array'], + 'specificCollectionIds.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $this->storeId())], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'onePerCustomer' => ['boolean'], + 'startsAt' => ['required', 'date'], + 'endsAt' => ['nullable', 'date', 'after_or_equal:startsAt'], + 'isActive' => ['boolean'], + ]); + + $rules = [ + 'activation_method' => $validated['type'], + 'one_per_customer' => (bool) $validated['onePerCustomer'], + ]; + + if ($validated['specificProductIds'] !== []) { + $rules['product_ids'] = array_values(array_unique(array_map('intval', $validated['specificProductIds']))); + } + + if ($validated['specificCollectionIds'] !== []) { + $rules['collection_ids'] = array_values(array_unique(array_map('intval', $validated['specificCollectionIds']))); + } + + $attributes = [ + 'store_id' => $this->storeId(), + 'title' => $validated['title'], + 'code' => $validated['type'] === 'code' ? $validated['code'] : null, + 'type' => $validated['valueType'], + 'value' => $validated['valueType'] === 'free_shipping' ? 0 : (int) $validated['valueAmount'], + 'minimum_subtotal_amount' => $validated['minimumPurchaseAmount'], + 'usage_limit' => $validated['usageLimit'], + 'starts_at' => $validated['startsAt'], + 'ends_at' => $validated['endsAt'], + 'is_active' => (bool) $validated['isActive'], + 'rules_json' => $rules, + ]; + + if ($discount === null) { + $discount = Discount::create($attributes); + } else { + $discount->update($attributes); + } + + session()->flash('status', 'Discount saved.'); + + return $this->redirectRoute('admin.discounts'); + } + + public function render(): mixed + { + Gate::authorize('manage-discounts'); + $storeId = $this->storeId(); + + return view('livewire.admin.discounts.form', [ + 'isEditing' => $this->discountId !== null, + 'products' => $this->searchResults(Product::query()->where('store_id', $storeId), $this->productSearch), + 'collections' => $this->searchResults(Collection::query()->where('store_id', $storeId), $this->collectionSearch), + 'selectedProducts' => Product::query()->where('store_id', $storeId)->whereIn('id', $this->specificProductIds)->get(['id', 'title']), + 'selectedCollections' => Collection::query()->where('store_id', $storeId)->whereIn('id', $this->specificCollectionIds)->get(['id', 'title']), + ])->layout('layouts.admin'); + } + + private function storeId(): int + { + $store = app()->bound('current_store') ? app('current_store') : null; + abort_unless($store instanceof Store, 404); + + return (int) $store->getKey(); + } + + private function discountQuery(): Builder + { + return Discount::query()->where('store_id', $this->storeId()); + } + + /** @param array $ids + * @param class-string $model + * @return list + */ + private function existingIds(array $ids, string $model): array + { + return $model::query() + ->where('store_id', $this->storeId()) + ->whereIn('id', array_map('intval', $ids)) + ->pluck('id') + ->map(static fn (int|string $id): int => (int) $id) + ->all(); + } + + /** @param Builder|Builder $query + * @return \Illuminate\Support\Collection + */ + private function searchResults(Builder $query, string $search): \Illuminate\Support\Collection + { + if (mb_strlen(trim($search)) < 2) { + return collect(); + } + + return $query->where('title', 'like', '%'.trim($search).'%') + ->orderBy('title') + ->limit(8) + ->get(['id', 'title']); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..6e97d1dd --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,66 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + if (! in_array($this->statusFilter, ['all', 'active', 'expired', 'scheduled'], true)) { + $this->statusFilter = 'all'; + } + + $this->resetPage(); + } + + public function toggle(int $discountId): void + { + Gate::authorize('manage-discounts'); + $discount = Discount::query()->findOrFail($discountId); + $discount->update(['is_active' => ! $discount->is_active]); + } + + public function delete(int $discountId): void + { + Gate::authorize('manage-discounts'); + Discount::query()->findOrFail($discountId)->delete(); + } + + public function render(): mixed + { + Gate::authorize('view-discounts'); + $now = now(); + $search = trim($this->search); + $discounts = Discount::query() + ->when($search !== '', fn ($query) => $query->where('code', 'like', "%{$search}%")) + ->when($this->statusFilter === 'active', fn ($query) => $query + ->where('is_active', true) + ->where('starts_at', '<=', $now) + ->where(fn ($dates) => $dates->whereNull('ends_at')->orWhere('ends_at', '>=', $now))) + ->when($this->statusFilter === 'scheduled', fn ($query) => $query->where('is_active', true)->where('starts_at', '>', $now)) + ->when($this->statusFilter === 'expired', fn ($query) => $query->whereNotNull('ends_at')->where('ends_at', '<', $now)) + ->latest() + ->paginate(15); + + return view('livewire.admin.discounts.index', [ + 'canManage' => Gate::allows('manage-discounts'), + 'discounts' => $discounts, + ])->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..116837c1 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,133 @@ + */ + public array $quantities = []; + + /** @var array */ + public array $policies = []; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStockFilter(): void + { + if (! in_array($this->stockFilter, ['all', 'in_stock', 'low_stock', 'out_of_stock'], true)) { + $this->stockFilter = 'all'; + } + + $this->resetPage(); + } + + public function saveInventoryItem(int $inventoryItemId): void + { + $store = $this->currentStore(); + $inventoryItem = InventoryItem::query() + ->where('store_id', $store->id) + ->whereHas('variant.product') + ->with(['variant.product']) + ->findOrFail($inventoryItemId); + + Gate::authorize('update', $inventoryItem->variant->product); + + $this->quantities[$inventoryItemId] ??= $inventoryItem->quantity_on_hand; + $this->policies[$inventoryItemId] ??= $inventoryItem->policy; + + $validated = $this->validate([ + "quantities.{$inventoryItemId}" => ['required', 'integer', 'min:0'], + "policies.{$inventoryItemId}" => ['required', Rule::in(['deny', 'continue'])], + ]); + + $inventoryItem->update([ + 'quantity_on_hand' => (int) $validated['quantities'][$inventoryItemId], + 'policy' => $validated['policies'][$inventoryItemId], + ]); + + session()->flash('status', 'Inventory updated.'); + } + + #[Computed] + public function inventoryItems(): LengthAwarePaginator + { + return $this->inventoryItemsQuery()->paginate(20); + } + + public function render(): mixed + { + Gate::authorize('viewAny', Product::class); + + $inventoryItems = $this->inventoryItems; + + foreach ($inventoryItems as $inventoryItem) { + $this->quantities[$inventoryItem->id] ??= $inventoryItem->quantity_on_hand; + $this->policies[$inventoryItem->id] ??= $inventoryItem->policy; + } + + return view('livewire.admin.inventory.index', [ + 'inventoryItems' => $inventoryItems, + 'canManageInventory' => Gate::allows('manage-products'), + ]) + ->layout('layouts.admin'); + } + + private function inventoryItemsQuery(): Builder + { + $store = $this->currentStore(); + $query = InventoryItem::query() + ->where('store_id', $store->id) + ->whereHas('variant.product') + ->orderBy('id') + ->with(['variant.product', 'variant.optionValues.option']); + + $searchTerm = trim($this->search); + + if ($searchTerm !== '') { + $pattern = '%'.addcslashes($searchTerm, '%_\\').'%'; + + $query->whereHas('variant', function (Builder $variantQuery) use ($pattern): void { + $variantQuery + ->where('sku', 'like', $pattern) + ->orWhereHas('product', fn (Builder $productQuery) => $productQuery->where('title', 'like', $pattern)); + }); + } + + return match ($this->stockFilter) { + 'in_stock' => $query->whereColumn('quantity_on_hand', '>', 'quantity_reserved'), + 'low_stock' => $query->whereRaw('quantity_on_hand - quantity_reserved between ? and ?', [1, self::LOW_STOCK_THRESHOLD]), + 'out_of_stock' => $query->whereColumn('quantity_on_hand', '<=', 'quantity_reserved'), + default => $query, + }; + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..d31bb0c7 --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,48 @@ +bound('current_store') ? app('current_store') : null; + $this->currentStoreName = $store instanceof Store ? $store->name : 'Select a store'; + $this->currentStoreId = $store instanceof Store ? (int) $store->id : null; + $this->stores = $user->stores()->orderBy('stores.name')->get(['stores.id', 'stores.name']); + } + + public function switchStore(string $storeId): mixed + { + $user = Auth::user(); + abort_unless($user !== null, 401); + + $store = $user->stores()->whereKey($storeId)->first(); + abort_unless($store instanceof Store, 403); + + session()->put('current_store_id', $store->id); + + return $this->redirectRoute('admin.dashboard'); + } + + public function render(): mixed + { + return view('livewire.admin.layout.top-bar'); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..fb92544b --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,677 @@ + */ + public SupportCollection $menus; + + public ?int $editingMenuId = null; + + /** @var array>}> */ + public array $menuItems = []; + + public ?array $editingItem = null; + + public ?int $editingItemIndex = null; + + public ?int $editingItemParentIndex = null; + + public ?string $editingItemId = null; + + public string $itemLabel = ''; + + public string $itemType = 'link'; + + public string $itemUrl = ''; + + public ?string $itemResourceId = null; + + public ?string $itemParentId = null; + + public bool $showItemModal = false; + + public function boot(): void + { + $this->restoreCurrentStore(); + Gate::authorize('manage-navigation'); + } + + public function mount(): void + { + $store = $this->currentStore(); + $this->storeId = $store->id; + + foreach ([['main-menu', 'Main Menu'], ['footer-menu', 'Footer Menu']] as [$handle, $title]) { + NavigationMenu::query()->firstOrCreate( + ['store_id' => $store->id, 'handle' => $handle], + ['title' => $title], + ); + } + + $this->refreshMenus(); + $mainMenu = $this->menus->firstWhere('handle', 'main-menu') ?? $this->menus->first(); + + if ($mainMenu) { + $this->selectMenu($mainMenu->id); + } + } + + public function selectMenu(int $menuId): void + { + Gate::authorize('manage-navigation'); + + $menu = $this->menusQuery()->findOrFail($menuId); + $this->editingMenuId = $menu->id; + $this->loadMenuItems($menu); + } + + public function addItem(?string $parentId = null): void + { + Gate::authorize('manage-navigation'); + $this->currentMenu(); + + if ($parentId !== null && ! $this->rootItemExists($parentId)) { + abort(404); + } + + $this->resetItemForm(); + $this->itemParentId = $parentId; + $this->showItemModal = true; + } + + public function editItem(string $itemId): void + { + Gate::authorize('manage-navigation'); + $this->currentMenu(); + $location = $this->findItem($itemId); + + if (! $location) { + abort(404); + } + + $item = $location['item']; + $this->editingItem = $item; + $this->editingItemId = $itemId; + $this->editingItemIndex = $location['index']; + $this->editingItemParentIndex = $location['parentIndex']; + $this->itemLabel = $item['label']; + $this->itemType = $item['type']; + $this->itemUrl = (string) ($item['url'] ?? ''); + $this->itemResourceId = isset($item['resource_id']) ? (string) $item['resource_id'] : null; + $this->itemParentId = $location['parentId']; + $this->showItemModal = true; + } + + public function saveItem(): void + { + Gate::authorize('manage-navigation'); + $this->currentMenu(); + + $this->itemParentId = filled($this->itemParentId) ? $this->itemParentId : null; + $store = $this->currentStore(); + $resourceTable = $this->resourceTable($this->itemType); + $rules = [ + 'itemLabel' => ['required', 'string', 'max:255'], + 'itemType' => ['required', Rule::in(['link', 'page', 'collection', 'product'])], + 'itemUrl' => ['nullable', 'string', 'max:2048'], + 'itemResourceId' => ['nullable', 'integer'], + 'itemParentId' => ['nullable', 'string', 'max:80'], + ]; + + if ($this->itemType === 'link') { + $rules['itemUrl'] = ['required', 'string', 'max:2048']; + } elseif ($resourceTable !== null) { + $rules['itemResourceId'] = [ + 'required', + 'integer', + Rule::exists($resourceTable, 'id')->where('store_id', $store->id), + ]; + } + + $validated = $this->validate($rules); + + if ($this->itemType === 'link' && ! $this->isAllowedNavigationUrl($this->itemUrl)) { + $this->addError('itemUrl', 'Enter a safe relative URL or an HTTP or HTTPS URL.'); + + return; + } + + if ($this->itemParentId !== null && ! $this->rootItemExists($this->itemParentId)) { + $this->addError('itemParentId', 'Choose a top-level item in this menu.'); + + return; + } + + if ($this->editingItemId !== null && $this->itemParentId === $this->editingItemId) { + $this->addError('itemParentId', 'An item cannot be its own parent.'); + + return; + } + + $oldItem = $this->editingItemId === null ? null : $this->findItem($this->editingItemId)['item'] ?? null; + $children = $oldItem['children'] ?? []; + + if ($this->itemParentId !== null && $children !== []) { + $this->addError('itemParentId', 'An item with submenu links must remain at the top level.'); + + return; + } + + $item = [ + 'id' => $this->editingItemId ?? 'new-'.Str::uuid(), + 'label' => $validated['itemLabel'], + 'type' => $validated['itemType'], + 'url' => $validated['itemType'] === 'link' ? $validated['itemUrl'] : null, + 'resource_id' => $validated['itemType'] === 'link' ? null : (string) $validated['itemResourceId'], + 'children' => $children, + ]; + + if ($this->editingItemId !== null) { + $this->removeItemFromTree($this->editingItemId); + } + + $this->insertItem($item, $this->itemParentId); + $this->resetItemForm(); + $this->showItemModal = false; + } + + public function removeItem(string $itemId): void + { + Gate::authorize('manage-navigation'); + $this->currentMenu(); + + if (! $this->removeItemFromTree($itemId)) { + abort(404); + } + } + + public function reorderItems(array|string $itemId, ?int $position = null, string $parentId = 'root'): void + { + Gate::authorize('manage-navigation'); + $this->currentMenu(); + + if (is_array($itemId)) { + $this->reorderMenuGroups($itemId); + + return; + } + + if ($position === null) { + $this->addError('menuItems', 'The new item position is required.'); + + return; + } + + $location = $this->findItem($itemId); + + if (! $location) { + abort(404); + } + + $newParentId = $parentId === 'root' ? null : $parentId; + + if ($newParentId !== null && ! $this->rootItemExists($newParentId)) { + abort(404); + } + + if ($newParentId === $itemId) { + $this->addError('menuItems', 'An item cannot be its own parent.'); + + return; + } + + if ($newParentId !== null && ($location['item']['children'] ?? []) !== []) { + $this->addError('menuItems', 'An item with submenu links must remain at the top level.'); + + return; + } + + $item = $this->removeItemFromTree($itemId); + + if (! is_array($item)) { + abort(404); + } + + $this->insertItem($item, $newParentId, $position); + } + + public function saveMenu(): void + { + Gate::authorize('manage-navigation'); + $menu = $this->currentMenu(); + $menuItems = $this->validatedMenuItems(); + + DB::transaction(function () use ($menu, $menuItems): void { + $menu->items()->delete(); + + foreach ($menuItems as $position => $item) { + $parent = $menu->items()->create($this->itemAttributes($item, $position)); + + foreach ($item['children'] ?? [] as $childPosition => $child) { + NavigationItem::query()->create([ + 'menu_id' => $menu->id, + 'parent_id' => $parent->id, + ...$this->itemAttributes($child, $childPosition), + ]); + } + } + }); + + $this->loadMenuItems($this->currentMenu()); + session()->flash('status', 'Navigation menu saved.'); + } + + public function render(): mixed + { + $store = $this->currentStore(); + $resources = [ + 'pages' => Page::query()->where('store_id', $store->id)->orderBy('title')->get(['id', 'title']), + 'collections' => Collection::query()->where('store_id', $store->id)->orderBy('title')->get(['id', 'title']), + 'products' => Product::query()->where('store_id', $store->id)->orderBy('title')->get(['id', 'title']), + ]; + + return view('livewire.admin.navigation.index', [ + 'menus' => $this->menusQuery()->withCount('items')->get(), + 'selectedMenu' => $this->editingMenu, + 'resources' => $resources, + ])->layout('layouts.admin'); + } + + #[Computed] + public function editingMenu(): ?NavigationMenu + { + return $this->editingMenuId === null ? null : $this->menusQuery()->find($this->editingMenuId); + } + + private function refreshMenus(): void + { + $this->menus = $this->menusQuery()->orderBy('id')->get(); + } + + private function menusQuery(): \Illuminate\Database\Eloquent\Builder + { + return NavigationMenu::query()->where('store_id', $this->currentStore()->id); + } + + private function currentMenu(): NavigationMenu + { + if ($this->editingMenuId === null) { + abort(404); + } + + return $this->menusQuery()->findOrFail($this->editingMenuId); + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + + abort_unless($store instanceof Store, 403); + + return $store; + } + + private function restoreCurrentStore(): void + { + if (app()->bound('current_store') || $this->storeId === null) { + return; + } + + app()->instance('current_store', Store::query()->findOrFail($this->storeId)); + } + + private function loadMenuItems(NavigationMenu $menu): void + { + $this->menuItems = $menu->items() + ->whereNull('parent_id') + ->with('children') + ->orderBy('position') + ->get() + ->map(fn (NavigationItem $item): array => $this->toMenuItemArray($item)) + ->all(); + } + + /** @return array{id: string, label: string, type: string, url: ?string, resource_id: ?string, children: array>} */ + private function toMenuItemArray(NavigationItem $item): array + { + return [ + 'id' => (string) $item->id, + 'label' => $item->label, + 'type' => $item->type, + 'url' => $item->url, + 'resource_id' => $item->resource_id === null ? null : (string) $item->resource_id, + 'children' => $item->children->map(fn (NavigationItem $child): array => [ + 'id' => (string) $child->id, + 'label' => $child->label, + 'type' => $child->type, + 'url' => $child->url, + 'resource_id' => $child->resource_id === null ? null : (string) $child->resource_id, + 'children' => [], + ])->all(), + ]; + } + + private function resetItemForm(): void + { + $this->resetValidation(); + $this->editingItem = null; + $this->editingItemId = null; + $this->editingItemIndex = null; + $this->editingItemParentIndex = null; + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = null; + $this->itemParentId = null; + } + + /** @return array{parentId: ?string, parentIndex: ?int, index: int, item: array}|null */ + private function findItem(string $itemId): ?array + { + foreach ($this->menuItems as $index => $item) { + if ((string) $item['id'] === $itemId) { + return ['parentId' => null, 'parentIndex' => null, 'index' => $index, 'item' => $item]; + } + + foreach ($item['children'] ?? [] as $childIndex => $child) { + if ((string) $child['id'] === $itemId) { + return ['parentId' => (string) $item['id'], 'parentIndex' => $index, 'index' => $childIndex, 'item' => $child]; + } + } + } + + return null; + } + + private function rootItemExists(string $itemId): bool + { + foreach ($this->menuItems as $item) { + if ((string) $item['id'] === $itemId) { + return true; + } + } + + return false; + } + + /** @return array|null */ + private function removeItemFromTree(string $itemId): ?array + { + foreach ($this->menuItems as $index => $item) { + if ((string) $item['id'] === $itemId) { + array_splice($this->menuItems, $index, 1); + + return $item; + } + + foreach ($item['children'] ?? [] as $childIndex => $child) { + if ((string) $child['id'] === $itemId) { + array_splice($this->menuItems[$index]['children'], $childIndex, 1); + + return $child; + } + } + } + + return null; + } + + /** @param array $item */ + private function insertItem(array $item, ?string $parentId, ?int $position = null): void + { + $position = max(0, $position ?? PHP_INT_MAX); + + if ($parentId === null) { + array_splice($this->menuItems, min($position, count($this->menuItems)), 0, [$item]); + $this->menuItems = array_values($this->menuItems); + + return; + } + + foreach ($this->menuItems as $index => $parent) { + if ((string) $parent['id'] !== $parentId) { + continue; + } + + $children = $parent['children'] ?? []; + array_splice($children, min($position, count($children)), 0, [$item]); + $this->menuItems[$index]['children'] = array_values($children); + + return; + } + + abort(404); + } + + /** @param array $order */ + private function reorderMenuGroups(array $order): void + { + if (array_is_list($order)) { + $orderedItems = $this->orderedItems($this->menuItems, $order); + + if ($orderedItems === null) { + $this->addError('menuItems', 'The menu order is invalid.'); + + return; + } + + $this->menuItems = $orderedItems; + + return; + } + + foreach ($order as $parentId => $itemIds) { + if (! is_array($itemIds)) { + $this->addError('menuItems', 'The menu order is invalid.'); + + return; + } + + if ((string) $parentId === 'root') { + $orderedItems = $this->orderedItems($this->menuItems, $itemIds); + + if ($orderedItems === null) { + $this->addError('menuItems', 'The top-level menu order is invalid.'); + + return; + } + + $this->menuItems = $orderedItems; + + continue; + } + + foreach ($this->menuItems as $index => $item) { + if ((string) $item['id'] !== (string) $parentId) { + continue; + } + + $orderedItems = $this->orderedItems($item['children'] ?? [], $itemIds); + + if ($orderedItems === null) { + $this->addError('menuItems', 'A submenu order is invalid.'); + + return; + } + + $this->menuItems[$index]['children'] = $orderedItems; + + continue 2; + } + + abort(404); + } + } + + /** @param array> $items + * @param array $order + * @return array>|null + */ + private function orderedItems(array $items, array $order): ?array + { + if (count($items) !== count($order)) { + return null; + } + + $itemsById = []; + + foreach ($items as $item) { + $itemsById[(string) $item['id']] = $item; + } + + $orderedItems = []; + + foreach ($order as $itemId) { + if (! is_string($itemId) && ! is_int($itemId)) { + return null; + } + + $itemKey = (string) $itemId; + + if (! array_key_exists($itemKey, $itemsById)) { + return null; + } + + $orderedItems[] = $itemsById[$itemKey]; + unset($itemsById[$itemKey]); + } + + return $itemsById === [] ? $orderedItems : null; + } + + /** @return array */ + private function itemAttributes(array $item, int $position): array + { + $resourceId = ($item['type'] ?? null) === 'link' ? null : (int) $item['resource_id']; + + return [ + 'type' => $item['type'], + 'label' => $item['label'], + 'url' => ($item['type'] ?? null) === 'link' ? $item['url'] : null, + 'resource_id' => $resourceId, + 'position' => $position, + ]; + } + + /** @return array> */ + private function validatedMenuItems(): array + { + $rules = [ + 'menuItems' => ['present', 'array'], + 'menuItems.*' => ['required', 'array:id,label,type,url,resource_id,children'], + 'menuItems.*.id' => ['nullable', 'string', 'max:80'], + 'menuItems.*.label' => ['required', 'string', 'max:255'], + 'menuItems.*.type' => ['required', Rule::in(['link', 'page', 'collection', 'product'])], + 'menuItems.*.url' => ['nullable', 'string', 'max:2048'], + 'menuItems.*.resource_id' => ['nullable', 'integer'], + 'menuItems.*.children' => ['present', 'array'], + 'menuItems.*.children.*' => ['required', 'array:id,label,type,url,resource_id,children'], + 'menuItems.*.children.*.id' => ['nullable', 'string', 'max:80'], + 'menuItems.*.children.*.label' => ['required', 'string', 'max:255'], + 'menuItems.*.children.*.type' => ['required', Rule::in(['link', 'page', 'collection', 'product'])], + 'menuItems.*.children.*.url' => ['nullable', 'string', 'max:2048'], + 'menuItems.*.children.*.resource_id' => ['nullable', 'integer'], + 'menuItems.*.children.*.children' => ['present', 'array', 'size:0'], + ]; + + $validator = Validator::make(['menuItems' => $this->menuItems], $rules); + $store = $this->currentStore(); + $validator->after(function ($validator) use ($store): void { + foreach ($this->menuItems as $index => $item) { + $this->validateTarget($validator, $item, "menuItems.{$index}", $store); + + foreach ($item['children'] ?? [] as $childIndex => $child) { + $this->validateTarget($validator, $child, "menuItems.{$index}.children.{$childIndex}", $store); + } + } + }); + + if ($validator->fails()) { + throw ValidationException::withMessages($validator->errors()->toArray()); + } + + return $validator->validated()['menuItems']; + } + + /** @param array $item */ + private function validateTarget(\Illuminate\Validation\Validator $validator, array $item, string $key, Store $store): void + { + if (! in_array($item['type'] ?? null, ['link', 'page', 'collection', 'product'], true)) { + return; + } + + if ($item['type'] === 'link') { + if (! $this->isAllowedNavigationUrl((string) ($item['url'] ?? ''))) { + $validator->errors()->add("{$key}.url", 'Enter a safe relative URL or an HTTP or HTTPS URL.'); + } + + return; + } + + $class = $this->resourceClass($item['type']); + $resourceId = $item['resource_id'] ?? null; + + if (! $class || ! is_numeric($resourceId) || ! $class::query()->where('store_id', $store->id)->whereKey((int) $resourceId)->exists()) { + $validator->errors()->add("{$key}.resource_id", 'Choose an available resource from this store.'); + } + } + + private function resourceTable(string $type): ?string + { + return match ($type) { + 'page' => 'pages', + 'collection' => 'collections', + 'product' => 'products', + default => null, + }; + } + + /** @return class-string|null */ + private function resourceClass(string $type): ?string + { + return match ($type) { + 'page' => Page::class, + 'collection' => Collection::class, + 'product' => Product::class, + default => null, + }; + } + + private function isAllowedNavigationUrl(string $url): bool + { + if ($url === '' || preg_match('/[\x00-\x20\\\\]/', $url) === 1) { + return false; + } + + if (str_starts_with($url, '/') && ! str_starts_with($url, '//')) { + return true; + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + return in_array(strtolower((string) $scheme), ['http', 'https'], true) && filter_var($url, FILTER_VALIDATE_URL) !== false; + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..09d1fdf6 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,27 @@ +with('customer')->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) + ->when($this->search !== '', fn ($query) => $query->where('order_number', 'like', '%'.addcslashes($this->search, '%_\\').'%')) + ->latest('placed_at'); + + return view('livewire.admin.orders.index', ['orders' => $orders->paginate(15)])->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..005ac6ff --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,135 @@ + */ + public array $fulfillmentLines = []; + + public function mount(int $order): void + { + $this->orderId = $order; + $this->setRemainingFulfillmentQuantities($this->order()); + } + + public function confirmPayment(OrderService $orders): void + { + $order = $this->order(); + Gate::authorize('update', $order); + $orders->confirmBankTransfer($order); + session()->flash('status', 'Payment confirmed.'); + } + + public function cancel(OrderService $orders): void + { + $order = $this->order(); + Gate::authorize('update', $order); + $orders->cancelPending($order); + session()->flash('status', 'Order cancelled.'); + } + + public function refund(RefundService $refunds): void + { + $order = $this->order(); + Gate::authorize('processRefund', $order); + $this->validate(['refundAmount' => ['required', 'integer', 'min:1'], 'refundReason' => ['nullable', 'string', 'max:500']]); + $refunds->refund($order, (int) $this->refundAmount, $this->refundReason ?: null, $this->restock); + $this->reset(['refundAmount', 'refundReason', 'restock']); + session()->flash('status', 'Refund processed.'); + } + + public function fulfill(FulfillmentService $fulfillments): void + { + $order = $this->order(); + Gate::authorize('createFulfillment', $order); + + $validated = $this->validate([ + 'fulfillmentLines' => ['required', 'array', 'min:1'], + 'fulfillmentLines.*' => ['required', 'integer', 'min:0'], + 'trackingCompany' => ['nullable', 'string', 'max:255'], + 'trackingNumber' => ['nullable', 'string', 'max:255'], + 'trackingUrl' => ['nullable', 'url', 'regex:/\Ahttps?:\/\/\S+\z/i', 'max:255'], + ]); + $lineQuantities = collect($validated['fulfillmentLines']) + ->map(fn (int|string $quantity): int => (int) $quantity) + ->filter(fn (int $quantity): bool => $quantity > 0) + ->all(); + + if ($lineQuantities === []) { + $this->addError('fulfillmentLines', 'Select at least one item to fulfill.'); + + return; + } + + $fulfillments->create($order, $lineQuantities, [ + 'tracking_company' => $this->trackingCompany ?: null, + 'tracking_number' => $this->trackingNumber ?: null, + 'tracking_url' => $this->trackingUrl ?: null, + ]); + $this->reset(['trackingCompany', 'trackingNumber', 'trackingUrl']); + $this->setRemainingFulfillmentQuantities($this->order()); + session()->flash('status', 'Fulfillment created.'); + } + + public function markAsShipped(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $order = $this->order(); + Gate::authorize('update', $order); + $fulfillment = $order->fulfillments()->findOrFail($fulfillmentId); + + $fulfillments->markShipped($fulfillment); + session()->flash('status', 'Fulfillment marked as shipped.'); + } + + public function markAsDelivered(int $fulfillmentId, FulfillmentService $fulfillments): void + { + $order = $this->order(); + Gate::authorize('update', $order); + $fulfillment = $order->fulfillments()->findOrFail($fulfillmentId); + + $fulfillments->markDelivered($fulfillment); + session()->flash('status', 'Fulfillment marked as delivered.'); + } + + public function render(): mixed + { + $order = $this->order()->load('customer', 'lines.variant', 'lines.fulfillmentLines', 'payments', 'refunds', 'fulfillments.lines.orderLine'); + Gate::authorize('view', $order); + + return view('livewire.admin.orders.show', ['order' => $order])->layout('layouts.admin'); + } + + private function order(): Order + { + return Order::query()->with('lines.fulfillmentLines')->findOrFail($this->orderId); + } + + private function setRemainingFulfillmentQuantities(Order $order): void + { + $this->fulfillmentLines = $order->lines->mapWithKeys(fn ($line): array => [ + $line->id => max(0, $line->quantity - (int) $line->fulfillmentLines->sum('quantity')), + ])->all(); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..09f1fb4c --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,72 @@ +findOrFail($page); + Gate::authorize('view', $saved); + $this->pageId = $saved->id; + $this->title = $saved->title; + $this->handle = $saved->handle; + $this->bodyHtml = strip_tags($saved->body_html ?? ''); + $this->status = $saved->status; + } else { + Gate::authorize('create', Page::class); + } + } + + public function save(HandleGenerator $handles): mixed + { + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'bodyHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', 'in:draft,published,archived'], + ]); + $storeId = app('current_store')->id; + $attributes = [ + 'title' => $this->title, + 'handle' => $handles->generate($this->handle ?: $this->title, 'pages', $storeId, $this->pageId), + 'body_html' => e($this->bodyHtml), + 'status' => $this->status, + 'published_at' => $this->status === 'published' ? now() : null, + ]; + + if ($this->pageId) { + $page = Page::query()->findOrFail($this->pageId); + Gate::authorize('update', $page); + $page->update($attributes); + } else { + Gate::authorize('create', Page::class); + Page::create(['store_id' => $storeId, ...$attributes]); + } + + session()->flash('status', 'Page saved.'); + + return $this->redirectRoute('admin.pages'); + } + + public function render(): mixed + { + return view('livewire.admin.pages.form', ['isEditing' => $this->pageId !== null])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..19acda20 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,28 @@ +findOrFail($pageId); + Gate::authorize('delete', $page); + $page->delete(); + session()->flash('status', 'Page deleted.'); + } + + public function render(): mixed + { + Gate::authorize('viewAny', Page::class); + + return view('livewire.admin.pages.index', ['pages' => Page::query()->orderBy('title')->paginate(15)])->layout('layouts.admin'); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..8b1f1df8 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,506 @@ + */ + public array $options = []; + + /** @var array */ + public array $variants = []; + + /** @var list */ + public array $collectionIds = []; + + /** @var array */ + public array $uploads = []; + + public function mount(?int $product = null): void + { + if ($product) { + $saved = Product::query()->with(['variants.inventoryItem', 'options.values', 'collections'])->findOrFail($product); + Gate::authorize('update', $saved); + $variant = $saved->variants->firstWhere('is_default', true) ?? $saved->variants->first(); + $this->productId = $saved->id; + $this->title = $saved->title; + $this->handle = $saved->handle; + $this->descriptionHtml = app(HtmlSanitizer::class)->sanitize($saved->description_html); + $this->vendor = $saved->vendor ?? ''; + $this->productType = $saved->product_type ?? ''; + $this->status = $saved->status; + $this->priceAmount = (int) ($variant?->price_amount ?? 0); + $this->compareAtAmount = $variant?->compare_at_amount; + $this->quantityOnHand = (int) ($variant?->inventoryItem?->quantity_on_hand ?? 0); + $this->sku = $variant?->sku ?? ''; + $this->options = $saved->options->map(fn ($option): array => [ + 'name' => $option->name, + 'values' => $option->values->pluck('value')->implode(', '), + ])->values()->all(); + $this->variants = $saved->variants->where('status', 'active')->map(fn (ProductVariant $savedVariant): array => [ + 'id' => $savedVariant->id, + 'title' => $savedVariant->title ?: 'Default', + 'sku' => $savedVariant->sku, + 'price_amount' => (int) $savedVariant->price_amount, + 'compare_at_amount' => $savedVariant->compare_at_amount, + 'quantity_on_hand' => (int) ($savedVariant->inventoryItem?->quantity_on_hand ?? 0), + 'quantity_reserved' => (int) ($savedVariant->inventoryItem?->quantity_reserved ?? 0), + 'inventory_policy' => $savedVariant->inventoryItem?->policy ?? 'deny', + 'requires_shipping' => (bool) $savedVariant->requires_shipping, + ])->values()->all(); + $this->collectionIds = $saved->collections->modelKeys(); + } else { + Gate::authorize('create', Product::class); + } + } + + public function addOption(): void + { + if (count($this->options) >= 3) { + $this->addError('options', 'A product can have at most three options.'); + + return; + } + + $this->options[] = ['name' => '', 'values' => '']; + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + $this->resetValidation('options'); + } + + public function save(ProductService $products, VariantMatrixService $matrix): mixed + { + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'priceAmount' => ['required', 'integer', 'min:0'], + 'compareAtAmount' => ['nullable', 'integer', 'min:0'], + 'quantityOnHand' => ['required', 'integer', 'min:0'], + 'sku' => ['nullable', 'string', 'max:100'], + 'status' => ['required', 'in:draft,active,archived'], + 'options' => ['array', 'max:3'], + 'options.*.name' => ['required', 'string', 'max:255'], + 'options.*.values' => ['required', 'string', 'max:2000'], + 'variants' => ['array'], + 'variants.*.id' => ['required', 'integer'], + 'variants.*.sku' => ['nullable', 'string', 'max:100'], + 'variants.*.price_amount' => ['required', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['nullable', 'integer', 'min:0'], + 'variants.*.quantity_on_hand' => ['required', 'integer', 'min:0'], + 'variants.*.inventory_policy' => ['required', 'in:deny,continue'], + 'variants.*.requires_shipping' => ['boolean'], + 'collectionIds' => ['array'], + 'collectionIds.*' => ['integer', 'distinct'], + 'uploads' => ['array', 'max:20'], + 'uploads.*' => ['image', 'mimes:jpg,jpeg,png,webp,avif', 'max:'.(int) ceil(config('shop.media.image_max_bytes', 10485760) / 1024)], + ]); + + if ($this->compareAtAmount !== null && $this->compareAtAmount <= $this->priceAmount) { + throw ValidationException::withMessages(['compareAtAmount' => 'The compare-at price must be greater than the variant price.']); + } + + foreach ($this->variants as $index => $variant) { + $compareAtAmount = $variant['compare_at_amount'] ?? null; + + if ($compareAtAmount !== null && $compareAtAmount !== '' && (int) $compareAtAmount <= (int) $variant['price_amount']) { + throw ValidationException::withMessages(["variants.{$index}.compare_at_amount" => 'The compare-at price must be greater than the variant price.']); + } + } + + $optionMatrix = $this->optionMatrix(); + $initialProductState = null; + $initialStatus = null; + + $product = DB::transaction(function () use ($products, $matrix, $optionMatrix, &$initialProductState, &$initialStatus): Product { + + if ($this->productId) { + $product = Product::query()->findOrFail($this->productId); + Gate::authorize('update', $product); + $initialProductState = $this->productState($product); + $initialStatus = $product->status; + $product = $products->update($product, [ + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => app(HtmlSanitizer::class)->sanitize($this->descriptionHtml), + 'vendor' => $this->vendor, + 'product_type' => $this->productType, + ], false); + } else { + $store = app('current_store'); + abort_unless($store instanceof Store, 404); + $product = $products->create($store, [ + 'title' => $this->title, + 'handle' => $this->handle ?: $this->title, + 'description_html' => app(HtmlSanitizer::class)->sanitize($this->descriptionHtml), + 'vendor' => $this->vendor ?: null, + 'product_type' => $this->productType ?: null, + 'status' => 'draft', + 'variants' => [[ + 'sku' => $this->sku ?: null, + 'price_amount' => $this->priceAmount, + 'compare_at_amount' => $this->compareAtAmount, + 'quantity_on_hand' => $this->quantityOnHand, + 'is_default' => true, + 'requires_shipping' => true, + ]], + ]); + } + + $defaultVariant = $product->variants()->where('is_default', true)->first() ?? $product->variants()->firstOrFail(); + $this->assertSkuAvailable($this->sku, $product, $defaultVariant->id, 'sku'); + $reservedQuantity = (int) ($defaultVariant->inventoryItem?->quantity_reserved ?? 0); + + if ($this->quantityOnHand < $reservedQuantity) { + throw ValidationException::withMessages(['quantityOnHand' => 'Available quantity cannot be lower than quantity reserved for active checkouts.']); + } + + $defaultVariant->forceFill([ + 'sku' => $this->sku ?: null, + 'price_amount' => $this->priceAmount, + 'compare_at_amount' => $this->compareAtAmount, + ])->save(); + $defaultVariant->inventoryItem()->updateOrCreate([], [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => $this->quantityOnHand, + ]); + + $rebuiltVariants = $matrix->rebuild($product, $optionMatrix); + $isCreatingMatrix = ! $this->productId && $optionMatrix !== []; + + foreach ($rebuiltVariants as $rebuiltVariant) { + $submitted = collect($this->variants)->firstWhere('id', $rebuiltVariant->id); + + if ($submitted === null) { + if ($isCreatingMatrix && $rebuiltVariant->is_default) { + $this->assertSkuAvailable($this->sku, $product, $rebuiltVariant->id, 'sku'); + $rebuiltVariant->forceFill(['sku' => $this->sku ?: null])->save(); + $rebuiltVariant->inventoryItem()->updateOrCreate([], [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => $this->quantityOnHand, + 'policy' => 'deny', + ]); + } + + continue; + } + + $this->assertSkuAvailable($submitted['sku'] ?? '', $product, $rebuiltVariant->id, 'variants'); + $inventory = $rebuiltVariant->inventoryItem; + $reserved = (int) ($inventory?->quantity_reserved ?? 0); + + if ((int) $submitted['quantity_on_hand'] < $reserved) { + throw ValidationException::withMessages(['variants' => 'Available quantity cannot be lower than quantity reserved for active checkouts.']); + } + + $rebuiltVariant->forceFill([ + 'sku' => filled($submitted['sku'] ?? null) ? trim((string) $submitted['sku']) : null, + 'price_amount' => (int) $submitted['price_amount'], + 'compare_at_amount' => $submitted['compare_at_amount'] === '' ? null : ($submitted['compare_at_amount'] ?? null), + 'requires_shipping' => (bool) ($submitted['requires_shipping'] ?? false), + ])->save(); + $rebuiltVariant->inventoryItem()->updateOrCreate([], [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => (int) $submitted['quantity_on_hand'], + 'policy' => $submitted['inventory_policy'], + ]); + } + + $ownedCollectionIds = Collection::query()->whereIn('id', array_map('intval', $this->collectionIds))->pluck('id')->all(); + + if (count($ownedCollectionIds) !== count(array_unique(array_map('intval', $this->collectionIds)))) { + throw ValidationException::withMessages(['collectionIds' => 'Choose collections belonging to this store.']); + } + + $product->collections()->sync($ownedCollectionIds); + + if ($product->status !== $this->status) { + Gate::authorize($this->status === 'archived' ? 'archive-products' : 'manage-products'); + $products->transitionStatus($product, ProductStatus::from($this->status), false); + } + + return $product; + }); + + if ($initialProductState !== null && $initialStatus !== null) { + $product = $product->refresh(); + + if ($initialProductState !== $this->productState($product)) { + if ($initialStatus !== $product->status) { + ProductStatusChanged::dispatch($product, $initialStatus, $product->status); + } else { + ProductUpdated::dispatch($product); + } + } + } + + $this->storeUploads($product); + session()->flash('status', 'Product saved.'); + + return $this->redirectRoute('admin.products.edit', ['product' => $product->id]); + } + + public function uploadMedia(): void + { + $product = $this->authorizedProduct(); + $this->validate([ + 'uploads' => ['required', 'array', 'min:1', 'max:20'], + 'uploads.*' => ['image', 'mimes:jpg,jpeg,png,webp,avif', 'max:'.(int) ceil(config('shop.media.image_max_bytes', 10485760) / 1024)], + ]); + + $this->storeUploads($product); + session()->flash('status', 'Images uploaded and queued for processing.'); + } + + public function updateMediaAlt(int $mediaId, string $altText): void + { + $product = $this->authorizedProduct(); + validator(['alt_text' => $altText], ['alt_text' => ['nullable', 'string', 'max:255']])->validate(); + $product->media()->whereKey($mediaId)->firstOrFail()->update(['alt_text' => filled($altText) ? $altText : null]); + } + + public function moveMedia(int $mediaId, int $direction): void + { + abort_unless(in_array($direction, [-1, 1], true), 422); + $product = $this->authorizedProduct(); + $items = $product->media()->orderBy('position')->orderBy('id')->get(); + $index = $items->search(fn (ProductMedia $media): bool => $media->id === $mediaId); + + if ($index === false) { + abort(404); + } + + $targetIndex = $index + $direction; + + if (! $items->has($targetIndex)) { + return; + } + + DB::transaction(function () use ($items, $index, $targetIndex): void { + $first = $items[$index]; + $second = $items[$targetIndex]; + $firstPosition = $first->position; + $first->update(['position' => $second->position]); + $second->update(['position' => $firstPosition]); + }); + } + + public function deleteMedia(int $mediaId): void + { + $product = $this->authorizedProduct(); + $product->media()->whereKey($mediaId)->firstOrFail()->delete(); + $product->media()->orderBy('position')->orderBy('id')->get()->each(function (ProductMedia $media, int $position): void { + if ($media->position !== $position) { + $media->update(['position' => $position]); + } + }); + } + + public function render(): mixed + { + return view('livewire.admin.products.form', [ + 'isEditing' => $this->productId !== null, + 'collections' => Collection::query()->orderBy('title')->get(['id', 'title']), + 'hasOptionMatrix' => $this->options !== [], + 'mediaItems' => $this->productId ? Product::query()->findOrFail($this->productId)->media : collect(), + ])->layout('layouts.admin'); + } + + private function authorizedProduct(): Product + { + abort_if($this->productId === null, 422, 'Save the product before managing its media.'); + $product = Product::query()->findOrFail($this->productId); + Gate::authorize('update', $product); + + return $product; + } + + /** @return array */ + private function productState(Product $product): array + { + $product->refresh(); + + return [ + 'product' => $product->only(['title', 'handle', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at']), + 'variants' => $product->variants() + ->with(['inventoryItem', 'optionValues']) + ->orderBy('id') + ->get() + ->map(static fn (ProductVariant $variant): array => [ + 'id' => (int) $variant->getKey(), + 'sku' => $variant->sku, + 'barcode' => $variant->barcode, + 'price_amount' => (int) $variant->price_amount, + 'compare_at_amount' => $variant->compare_at_amount, + 'currency' => $variant->currency, + 'weight_g' => $variant->weight_g, + 'requires_shipping' => (bool) $variant->requires_shipping, + 'is_default' => (bool) $variant->is_default, + 'position' => (int) $variant->position, + 'status' => $variant->status, + 'inventory' => $variant->inventoryItem?->only(['quantity_on_hand', 'quantity_reserved', 'policy']), + 'option_value_ids' => $variant->optionValues->modelKeys(), + ]) + ->all(), + 'options' => $product->options() + ->with('values') + ->orderBy('position') + ->get() + ->map(static fn ($option): array => [ + 'name' => $option->name, + 'values' => $option->values->sortBy('position')->pluck('value')->values()->all(), + ]) + ->all(), + 'collection_ids' => $product->collections()->orderBy('collections.id')->pluck('collections.id')->map(static fn ($id): int => (int) $id)->all(), + ]; + } + + private function storeUploads(Product $product): void + { + foreach ($this->uploads as $upload) { + $extensionByMime = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/avif' => 'avif', + ]; + $mimeType = $upload->getMimeType(); + $extension = $extensionByMime[$mimeType] ?? null; + + if ($extension === null) { + throw ValidationException::withMessages(['uploads' => 'Upload a JPEG, PNG, WebP, or AVIF image.']); + } + + $storageKey = "stores/{$product->store_id}/products/{$product->id}/media/".Str::uuid().".{$extension}"; + $storedKey = $upload->storePubliclyAs(dirname($storageKey), basename($storageKey), 'public'); + + if ($storedKey !== $storageKey) { + throw new \RuntimeException('The product image could not be stored.'); + } + + try { + $media = $product->media()->create([ + 'type' => 'image', + 'storage_key' => $storageKey, + 'mime_type' => $mimeType, + 'byte_size' => (int) $upload->getSize(), + 'position' => (int) $product->media()->max('position') + 1, + 'status' => 'processing', + 'created_at' => now(), + ]); + } catch (\Throwable $exception) { + Storage::disk('public')->delete($storageKey); + throw $exception; + } + + ProcessMediaUpload::dispatch($media->id)->onConnection('database'); + } + + $this->reset('uploads'); + } + + /** @return array}> */ + private function optionMatrix(): array + { + $normalized = []; + $seenNames = []; + + foreach ($this->options as $position => $option) { + $name = trim((string) ($option['name'] ?? '')); + $nameKey = mb_strtolower($name); + + if ($name === '' || isset($seenNames[$nameKey])) { + throw ValidationException::withMessages(["options.{$position}.name" => 'Option names must be present and unique.']); + } + + $seenNames[$nameKey] = true; + $values = array_map('trim', explode(',', (string) ($option['values'] ?? ''))); + $seenValues = []; + + foreach ($values as $valuePosition => $value) { + $valueKey = mb_strtolower($value); + + if ($value === '' || isset($seenValues[$valueKey])) { + throw ValidationException::withMessages(["options.{$position}.values" => 'Option values must be present and unique.']); + } + + $seenValues[$valueKey] = true; + } + + $normalized[] = ['name' => $name, 'values' => $values]; + } + + return $normalized; + } + + private function assertSkuAvailable(?string $sku, Product $product, int $ignoreVariantId, string $field): void + { + $normalizedSku = trim((string) $sku); + + if ($normalizedSku === '') { + return; + } + + $exists = DB::table('product_variants') + ->join('products', 'products.id', '=', 'product_variants.product_id') + ->where('products.store_id', $product->store_id) + ->where('product_variants.sku', $normalizedSku) + ->where('product_variants.id', '!=', $ignoreVariantId) + ->exists(); + + if ($exists) { + throw ValidationException::withMessages([$field => 'This SKU is already in use in this store.']); + } + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..ee5b68dd --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,41 @@ +findOrFail($productId); + Gate::authorize('delete', $product); + app(\App\Services\ProductService::class)->delete($product); + session()->flash('status', 'Product deleted.'); + } + + public function render(): mixed + { + Gate::authorize('viewAny', Product::class); + $products = Product::query()->with(['variants.inventoryItem'])->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%'.addcslashes($this->search, '%_\\').'%')) + ->when($this->status !== 'all', fn ($query) => $query->where('status', $this->status)) + ->latest() + ->paginate(15); + + return view('livewire.admin.products.index', [ + 'products' => $products, + 'canCreate' => Gate::allows('create', Product::class), + 'canUpdate' => Gate::allows('manage-products'), + 'canDelete' => Gate::allows('archive-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..af808182 --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,169 @@ + */ + public array $synonymGroups = []; + + public string $stopWords = ''; + + public function mount(): void + { + Gate::authorize('manage-store-settings'); + $settings = DB::table('search_settings')->where('store_id', $this->store()->id)->first(); + $groups = json_decode((string) ($settings->synonyms_json ?? '[]'), true); + $stopWords = json_decode((string) ($settings->stop_words_json ?? '[]'), true); + $this->synonymGroups = collect(is_array($groups) ? $groups : []) + ->map(static fn (mixed $group): string => is_array($group) ? implode(', ', $group) : '') + ->values() + ->all(); + $this->stopWords = implode(', ', is_array($stopWords) ? $stopWords : []); + } + + public function addSynonymGroup(): void + { + Gate::authorize('manage-store-settings'); + + if (count($this->synonymGroups) >= 20) { + $this->addError('synonymGroups', 'You can define up to 20 synonym groups.'); + + return; + } + + $this->synonymGroups[] = ''; + } + + public function removeSynonymGroup(int $index): void + { + Gate::authorize('manage-store-settings'); + unset($this->synonymGroups[$index]); + $this->synonymGroups = array_values($this->synonymGroups); + $this->resetValidation('synonymGroups'); + } + + public function save(): void + { + Gate::authorize('manage-store-settings'); + $this->validate([ + 'synonymGroups' => ['array', 'max:20'], + 'synonymGroups.*' => ['nullable', 'string', 'max:500'], + 'stopWords' => ['nullable', 'string', 'max:2000'], + ]); + + $synonyms = []; + $seenWords = []; + + foreach ($this->synonymGroups as $index => $rawGroup) { + $words = collect(explode(',', $rawGroup)) + ->map(static fn (string $word): string => trim($word)) + ->filter() + ->unique(fn (string $word): string => mb_strtolower($word)) + ->values() + ->all(); + + if ($words === []) { + continue; + } + + if (count($words) < 2) { + throw ValidationException::withMessages(["synonymGroups.{$index}" => 'A synonym group needs at least two distinct words.']); + } + + foreach ($words as $word) { + $key = mb_strtolower($word); + + if (isset($seenWords[$key])) { + throw ValidationException::withMessages(["synonymGroups.{$index}" => 'Each word can only appear in one synonym group.']); + } + + $seenWords[$key] = true; + } + + $synonyms[] = $words; + } + + $normalizedStopWords = collect(preg_split('/[,\s]+/u', $this->stopWords) ?: []) + ->map(static fn (string $word): string => mb_strtolower(trim($word))) + ->filter() + ->unique() + ->values() + ->all(); + + DB::table('search_settings')->updateOrInsert( + ['store_id' => $this->store()->id], + [ + 'synonyms_json' => json_encode($synonyms, JSON_THROW_ON_ERROR), + 'stop_words_json' => json_encode($normalizedStopWords, JSON_THROW_ON_ERROR), + 'updated_at' => now(), + ], + ); + + session()->flash('status', 'Search settings saved.'); + } + + public function reindex(): void + { + Gate::authorize('manage-store-settings'); + $storeId = $this->store()->id; + DB::table('search_settings')->insertOrIgnore(['store_id' => $storeId]); + $settings = DB::table('search_settings')->where('store_id', $storeId)->first(); + + if (in_array($settings->index_status, ['queued', 'processing'], true)) { + $this->addError('reindex', 'A search reindex is already in progress.'); + + return; + } + + DB::table('search_settings')->where('store_id', $storeId)->update([ + 'index_status' => 'queued', + 'documents_count' => 0, + 'pending_updates' => DB::table('products')->where('store_id', $storeId)->count(), + ]); + + ReindexStoreProducts::dispatch($storeId)->onQueue('search'); + session()->flash('status', 'Search reindex queued.'); + } + + public function refreshIndexStatus(): void + { + $this->authorizeSettings(); + } + + public function render(): mixed + { + $this->authorizeSettings(); + $storeId = $this->store()->id; + $settings = DB::table('search_settings')->where('store_id', $storeId)->first(); + $indexStatus = $settings->index_status ?? 'ready'; + + return view('livewire.admin.search.settings', [ + 'indexStatus' => $indexStatus, + 'lastReindexedAt' => $settings?->last_reindex_at, + 'documentsCount' => (int) ($settings->documents_count ?? DB::table('products_fts')->where('store_id', (string) $storeId)->count()), + 'pendingUpdates' => (int) ($settings->pending_updates ?? 0), + 'progress' => (int) ((($settings->documents_count ?? 0) / max(1, ($settings->documents_count ?? 0) + ($settings->pending_updates ?? 0))) * 100), + ])->layout('layouts.admin'); + } + + private function authorizeSettings(): void + { + Gate::authorize('manage-store-settings'); + } + + private function store(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Livewire/Admin/SelectStore.php b/app/Livewire/Admin/SelectStore.php new file mode 100644 index 00000000..e10c4746 --- /dev/null +++ b/app/Livewire/Admin/SelectStore.php @@ -0,0 +1,44 @@ +stores = $user->stores()->orderBy('stores.name')->get(['stores.id', 'stores.name']); + + if ($this->stores->isEmpty()) { + abort(403); + } + + session()->forget('current_store_id'); + } + + public function select(string $storeId): mixed + { + $user = Auth::user(); + abort_unless($user !== null, 401); + + $store = $user->stores()->whereKey($storeId)->first(); + abort_unless($store instanceof Store, 403); + + session()->put('current_store_id', $store->id); + + return $this->redirectRoute('admin.dashboard'); + } + + public function render(): mixed + { + return view('livewire.admin.select-store')->layout('layouts.auth'); + } +} diff --git a/app/Livewire/Admin/Settings/Domains.php b/app/Livewire/Admin/Settings/Domains.php new file mode 100644 index 00000000..c1979b4a --- /dev/null +++ b/app/Livewire/Admin/Settings/Domains.php @@ -0,0 +1,145 @@ +authorizeStoreSettings(); + $this->loadDomains(); + } + + public function openAddDomainModal(): void + { + $this->authorizeStoreSettings(); + $this->resetValidation(); + $this->newHostname = ''; + $this->newType = StoreDomainType::Storefront->value; + $this->showAddDomainModal = true; + } + + public function closeAddDomainModal(): void + { + $this->authorizeStoreSettings(); + $this->showAddDomainModal = false; + $this->resetValidation(); + } + + public function addDomain(): void + { + $store = $this->authorizeStoreSettings(); + $hostname = strtolower(trim($this->newHostname)); + $this->newHostname = $hostname; + + $validated = $this->validate([ + 'newHostname' => ['required', 'string', 'max:253', 'regex:/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/', 'unique:store_domains,hostname'], + 'newType' => ['required', 'string', Rule::enum(StoreDomainType::class)], + ]); + $isFirstDomainForType = ! $store->domains()->where('type', $validated['newType'])->where('is_primary', true)->exists(); + + $domain = $store->domains()->create([ + 'hostname' => $validated['newHostname'], + 'type' => $validated['newType'], + 'is_primary' => $isFirstDomainForType, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + + $this->showAddDomainModal = false; + $this->loadDomains(); + Cache::forget("store-domain:{$domain->hostname}"); + $this->dispatch('toast', type: 'success', message: 'Domain added.'); + } + + public function removeDomain(int $domainId): void + { + $store = $this->authorizeStoreSettings(); + $domain = $store->domains()->findOrFail($domainId); + $hostname = $domain->hostname; + $wasPrimary = $domain->is_primary; + $type = $domain->type; + + DB::transaction(function () use ($domain, $store, $type, $wasPrimary): void { + $domain->delete(); + + if ($wasPrimary) { + $replacement = $store->domains()->where('type', $type)->orderBy('id')->first(); + $replacement?->update(['is_primary' => true]); + } + }); + + Cache::forget("store-domain:{$hostname}"); + $this->loadDomains(); + $this->dispatch('toast', type: 'success', message: 'Domain removed.'); + } + + public function setPrimary(int $domainId): void + { + $store = $this->authorizeStoreSettings(); + $domain = $store->domains()->findOrFail($domainId); + + DB::transaction(function () use ($domain, $store): void { + $store->domains()->where('type', $domain->type)->update(['is_primary' => false]); + $domain->update(['is_primary' => true]); + }); + + Cache::forget("store-domain:{$domain->hostname}"); + $this->loadDomains(); + $this->dispatch('toast', type: 'success', message: 'Primary domain updated.'); + } + + public function render(): mixed + { + $this->authorizeStoreSettings(); + + return view('livewire.admin.settings.domains', ['domains' => $this->domains]); + } + + private function authorizeStoreSettings(): Store + { + $store = $this->currentStore(); + Gate::authorize('manage-store-settings'); + + return $store; + } + + private function loadDomains(): void + { + $this->domains = $this->currentStore()->domains()->orderBy('type')->orderBy('hostname')->get(); + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + $user = auth()->user(); + + if (! $store instanceof Store || ! $user?->stores()->whereKey($store->id)->exists()) { + $storeId = session()->get('current_store_id'); + $store = $user === null || $storeId === null ? null : $user->stores()->whereKey($storeId)->first(); + } + + abort_unless($store instanceof Store, 404); + app()->instance('current_store', $store); + view()->share('currentStore', $store); + + return $store; + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php new file mode 100644 index 00000000..b0295704 --- /dev/null +++ b/app/Livewire/Admin/Settings/General.php @@ -0,0 +1,94 @@ + 'English', + 'de' => 'German', + 'fr' => 'French', + 'es' => 'Spanish', + 'it' => 'Italian', + 'nl' => 'Dutch', + 'pt' => 'Portuguese', + 'sv' => 'Swedish', + 'da' => 'Danish', + 'fi' => 'Finnish', + 'pl' => 'Polish', + 'cs' => 'Czech', + 'ja' => 'Japanese', + 'zh' => 'Chinese', + ]; + + public string $storeName = ''; + + public string $storeHandle = ''; + + public string $contactEmail = ''; + + public string $defaultCurrency = 'EUR'; + + public string $defaultLocale = 'en'; + + public string $timezone = 'Europe/Berlin'; + + public function mount(): void + { + Gate::authorize('manage-store-settings'); + $store = app('current_store'); + $settings = $store->settings?->settings_json ?? []; + $this->storeName = $settings['store_name'] ?? $store->name; + $this->storeHandle = $store->handle; + $this->contactEmail = $settings['contact_email'] ?? ''; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + } + + public function save(): void + { + Gate::authorize('manage-store-settings'); + $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'contactEmail' => ['required', 'email', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'size:3'], + 'defaultLocale' => ['required', 'string', 'max:12', function (string $attribute, mixed $value, \Closure $fail): void { + $normalizedLocale = \Locale::canonicalize(str_replace('-', '_', $value)); + + if (! in_array($normalizedLocale, \ResourceBundle::getLocales(''), true)) { + $fail('The selected locale must be a valid locale code.'); + } + }], + 'timezone' => ['required', 'timezone'], + ]); + $store = app('current_store'); + $store->update([ + 'name' => $this->storeName, + 'default_currency' => strtoupper($this->defaultCurrency), + 'default_locale' => $this->defaultLocale, + 'timezone' => $this->timezone, + ]); + $settings = $store->settings?->settings_json ?? []; + StoreSettings::updateOrCreate(['store_id' => $store->id], ['settings_json' => [...$settings, 'store_name' => $this->storeName, 'contact_email' => $this->contactEmail]]); + session()->flash('status', 'Store settings saved.'); + } + + public function render(): mixed + { + $locales = self::LOCALE_OPTIONS; + + if (! array_key_exists($this->defaultLocale, $locales)) { + $locales[$this->defaultLocale] = "Current locale ({$this->defaultLocale})"; + } + + return view('livewire.admin.settings.general', [ + 'locales' => $locales, + 'timezones' => \DateTimeZone::listIdentifiers(), + ])->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..33d4f721 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,364 @@ + */ + public array $weightTiers = [['min_weight_g' => 0, 'max_weight_g' => null, 'price_amount' => 499]]; + + /** @var list */ + public array $priceTiers = [['min_order_amount' => 0, 'max_order_amount' => null, 'price_amount' => 499]]; + + public string $testCountryCode = 'DE'; + + public string $testRegionCode = ''; + + public string $testCity = ''; + + public string $testPostalCode = ''; + + public int $testSubtotalAmount = 0; + + public int $testWeightGrams = 0; + + /** @var array{zone: string, rates: list}|null */ + public ?array $testResult = null; + + public bool $hasTestedAddress = false; + + public function saveZone(): void + { + Gate::authorize('manage-shipping'); + $this->validate([ + 'zoneName' => ['required', 'string', 'max:255'], + 'countries' => ['required', 'string', 'max:500'], + 'regions' => ['nullable', 'string', 'max:500'], + ]); + $countryCodes = array_values(array_filter(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), explode(',', $this->countries))))); + + if ($countryCodes === []) { + throw ValidationException::withMessages(['countries' => 'Add at least one country code.']); + } + + foreach ($countryCodes as $countryCode) { + validator(['country' => $countryCode], ['country' => ['required', 'size:2']])->validate(); + } + + $regionCodes = $this->normalizeRegions($countryCodes); + $zone = $this->editingZoneId === null + ? new ShippingZone(['store_id' => app('current_store')->id]) + : ShippingZone::query()->findOrFail($this->editingZoneId); + $zone->fill(['name' => $this->zoneName, 'countries' => $countryCodes, 'regions' => $regionCodes]); + $zone->save(); + $this->zoneId = $zone->id; + $this->editingZoneId = null; + session()->flash('status', 'Shipping zone saved.'); + } + + public function editZone(int $zoneId): void + { + Gate::authorize('manage-shipping'); + $zone = ShippingZone::query()->findOrFail($zoneId); + $this->editingZoneId = $zone->id; + $this->zoneName = $zone->name; + $this->countries = implode(', ', $zone->countries ?? []); + $this->regions = implode(', ', $zone->regions ?? []); + } + + public function cancelZoneEdit(): void + { + $this->editingZoneId = null; + $this->zoneName = ''; + $this->countries = 'DE'; + $this->regions = ''; + $this->resetValidation(); + } + + public function saveRate(): void + { + Gate::authorize('manage-shipping'); + $rules = [ + 'zoneId' => ['required', 'integer'], + 'rateName' => ['required', 'string', 'max:255'], + 'rateType' => ['required', 'in:flat,weight,price,carrier'], + 'rateActive' => ['boolean'], + 'minOrderAmount' => ['nullable', 'integer', 'min:0'], + 'maxOrderAmount' => ['nullable', 'integer', 'gte:minOrderAmount'], + ]; + + if (in_array($this->rateType, ['flat', 'carrier'], true)) { + $rules['rateAmount'] = ['required', 'integer', 'min:0']; + } + + if ($this->rateType === 'weight') { + $rules['weightTiers'] = ['required', 'array', 'min:1', 'max:20']; + $rules['weightTiers.*.min_weight_g'] = ['required', 'integer', 'min:0']; + $rules['weightTiers.*.max_weight_g'] = ['nullable', 'integer', 'gt:weightTiers.*.min_weight_g']; + $rules['weightTiers.*.price_amount'] = ['required', 'integer', 'min:0']; + } + + if ($this->rateType === 'price') { + $rules['priceTiers'] = ['required', 'array', 'min:1', 'max:20']; + $rules['priceTiers.*.min_order_amount'] = ['required', 'integer', 'min:0']; + $rules['priceTiers.*.max_order_amount'] = ['nullable', 'integer', 'gt:priceTiers.*.min_order_amount']; + $rules['priceTiers.*.price_amount'] = ['required', 'integer', 'min:0']; + } + + if ($this->rateType === 'carrier') { + $rules['carrier'] = ['required', 'in:ups,fedex,dhl']; + $rules['carrierService'] = ['required', 'string', 'max:80']; + } + + $this->validate($rules); + $zone = ShippingZone::query()->findOrFail($this->zoneId); + $config = match ($this->rateType) { + 'flat' => ['price_amount' => $this->rateAmount], + 'weight' => ['tiers' => $this->validatedTiers($this->weightTiers, 'min_weight_g', 'max_weight_g')], + 'price' => ['tiers' => $this->validatedTiers($this->priceTiers, 'min_order_amount', 'max_order_amount')], + 'carrier' => ['carrier' => $this->carrier, 'service' => $this->carrierService, 'price_amount' => $this->rateAmount], + }; + $attributes = [ + 'name' => $this->rateName, + 'type' => $this->rateType, + 'price_amount' => in_array($this->rateType, ['flat', 'carrier'], true) ? $this->rateAmount : 0, + 'min_order_amount' => $this->minOrderAmount, + 'max_order_amount' => $this->maxOrderAmount, + 'config_json' => $config, + 'is_active' => $this->rateActive, + ]; + $rate = $this->editingRateId === null + ? $zone->rates()->make() + : $this->findRateForCurrentStore($this->editingRateId); + $rate->fill($attributes); + $zone->rates()->save($rate); + $this->editingRateId = null; + $this->rateName = 'Standard Shipping'; + $this->rateType = 'flat'; + $this->rateAmount = 499; + $this->rateActive = true; + session()->flash('status', 'Shipping rate saved.'); + } + + public function editRate(int $rateId): void + { + Gate::authorize('manage-shipping'); + $rate = $this->findRateForCurrentStore($rateId); + $this->zoneId = $rate->shipping_zone_id; + $this->editingRateId = $rate->id; + $this->rateName = $rate->name; + $this->rateType = $rate->type; + $this->rateAmount = (int) ($rate->config_json['price_amount'] ?? $rate->price_amount); + $this->minOrderAmount = $rate->min_order_amount; + $this->maxOrderAmount = $rate->max_order_amount; + $this->rateActive = $rate->is_active; + $this->carrier = $rate->config_json['carrier'] ?? 'ups'; + $this->carrierService = $rate->config_json['service'] ?? 'ground'; + $tiers = $rate->config_json['tiers'] ?? $rate->config_json['ranges'] ?? []; + + if ($rate->type === 'weight') { + $this->weightTiers = collect($tiers)->map(static fn (array $tier): array => [ + 'min_weight_g' => (int) ($tier['min_weight_g'] ?? $tier['min_g'] ?? 0), + 'max_weight_g' => isset($tier['max_weight_g']) ? (int) $tier['max_weight_g'] : (isset($tier['max_g']) ? (int) $tier['max_g'] : null), + 'price_amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0), + ])->values()->all() ?: [['min_weight_g' => 0, 'max_weight_g' => null, 'price_amount' => 499]]; + } + + if ($rate->type === 'price') { + $this->priceTiers = collect($tiers)->map(static fn (array $tier): array => [ + 'min_order_amount' => (int) ($tier['min_order_amount'] ?? $tier['min_amount'] ?? 0), + 'max_order_amount' => isset($tier['max_order_amount']) ? (int) $tier['max_order_amount'] : (isset($tier['max_amount']) ? (int) $tier['max_amount'] : null), + 'price_amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0), + ])->values()->all() ?: [['min_order_amount' => 0, 'max_order_amount' => null, 'price_amount' => 499]]; + } + } + + public function prepareRate(int $zoneId): void + { + Gate::authorize('manage-shipping'); + ShippingZone::query()->findOrFail($zoneId); + $this->cancelRateEdit(); + $this->zoneId = $zoneId; + } + + public function cancelRateEdit(): void + { + $this->editingRateId = null; + $this->rateName = 'Standard Shipping'; + $this->rateType = 'flat'; + $this->rateAmount = 499; + $this->minOrderAmount = null; + $this->maxOrderAmount = null; + $this->rateActive = true; + $this->resetValidation(); + } + + public function toggleRate(int $rateId): void + { + Gate::authorize('manage-shipping'); + $rate = $this->findRateForCurrentStore($rateId); + $rate->update(['is_active' => ! $rate->is_active]); + } + + public function deleteRate(int $rateId): void + { + Gate::authorize('manage-shipping'); + $this->findRateForCurrentStore($rateId)->delete(); + } + + public function addWeightTier(): void + { + $this->weightTiers[] = ['min_weight_g' => 0, 'max_weight_g' => null, 'price_amount' => 499]; + } + + public function removeWeightTier(int $index): void + { + unset($this->weightTiers[$index]); + $this->weightTiers = array_values($this->weightTiers); + } + + public function addPriceTier(): void + { + $this->priceTiers[] = ['min_order_amount' => 0, 'max_order_amount' => null, 'price_amount' => 499]; + } + + public function removePriceTier(int $index): void + { + unset($this->priceTiers[$index]); + $this->priceTiers = array_values($this->priceTiers); + } + + public function testShippingAddress(ShippingCalculator $calculator): void + { + Gate::authorize('manage-shipping'); + $this->validate([ + 'testCountryCode' => ['required', 'string', 'size:2'], + 'testRegionCode' => ['nullable', 'string', 'max:10'], + 'testCity' => ['nullable', 'string', 'max:255'], + 'testPostalCode' => ['nullable', 'string', 'max:20'], + 'testSubtotalAmount' => ['required', 'integer', 'min:0'], + 'testWeightGrams' => ['required', 'integer', 'min:0'], + ]); + + $address = ['country_code' => strtoupper($this->testCountryCode), 'province_code' => strtoupper($this->testRegionCode)]; + $zone = $calculator->matchingZone((int) app('current_store')->id, $address['country_code'], $address['province_code']); + $rates = $calculator->ratesForAddress((int) app('current_store')->id, $address, $this->testSubtotalAmount, $this->testWeightGrams); + $this->hasTestedAddress = true; + $this->testResult = $zone === null ? null : [ + 'zone' => $zone->name, + 'rates' => $rates->map(static fn (ShippingRate $rate): array => [ + 'name' => $rate->name, + 'type' => $rate->type, + 'price_amount' => (int) $rate->price_amount, + ])->all(), + ]; + } + + public function deleteZone(int $zoneId): void + { + Gate::authorize('manage-shipping'); + ShippingZone::query()->findOrFail($zoneId)->delete(); + } + + public function render(): mixed + { + Gate::authorize('manage-shipping'); + + return view('livewire.admin.settings.shipping', ['zones' => ShippingZone::query()->with('rates')->orderBy('name')->get()])->layout('layouts.admin'); + } + + /** @param list $countryCodes + * @return list + */ + private function normalizeRegions(array $countryCodes): array + { + $regions = array_values(array_filter(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), explode(',', $this->regions))))); + + foreach ($regions as $index => $region) { + if (preg_match('/\A[A-Z]{2}-[A-Z0-9]{1,3}\z/', $region) !== 1) { + if (count($countryCodes) !== 1 || preg_match('/\A[A-Z0-9]{1,3}\z/', $region) !== 1) { + throw ValidationException::withMessages(['regions' => 'Use region codes like US-CA.']); + } + + $regions[$index] = $countryCodes[0].'-'.$region; + } + + [$countryCode] = explode('-', $regions[$index], 2); + + if (! in_array($countryCode, $countryCodes, true)) { + throw ValidationException::withMessages(['regions' => 'Each region must belong to a selected country.']); + } + } + + return $regions; + } + + /** @param list> $tiers + * @return list> + */ + private function validatedTiers(array $tiers, string $minimumKey, string $maximumKey): array + { + $normalized = array_map(static function (array $tier) use ($minimumKey, $maximumKey): array { + $tier[$minimumKey] = (int) ($tier[$minimumKey] ?? 0); + $tier[$maximumKey] = ($tier[$maximumKey] ?? null) === '' ? null : (($tier[$maximumKey] ?? null) === null ? null : (int) $tier[$maximumKey]); + $tier['price_amount'] = (int) ($tier['price_amount'] ?? 0); + + return $tier; + }, $tiers); + $sorted = collect($normalized)->sortBy($minimumKey)->values()->all(); + + foreach ($sorted as $index => $tier) { + $maximum = $tier[$maximumKey] ?? null; + + if ($maximum !== null && $maximum <= $tier[$minimumKey]) { + throw ValidationException::withMessages(["rateTiers.{$index}" => 'The maximum must be greater than the minimum.']); + } + + if ($index < count($sorted) - 1 && ($maximum === null || $maximum >= $sorted[$index + 1][$minimumKey])) { + throw ValidationException::withMessages(["rateTiers.{$index}" => 'Tier ranges cannot overlap and only the final tier can be open-ended.']); + } + } + + return $sorted; + } + + private function findRateForCurrentStore(int $rateId): ShippingRate + { + return ShippingRate::query()->whereHas('zone', fn ($zones) => $zones->where('store_id', app('current_store')->id))->findOrFail($rateId); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..31eb906a --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,246 @@ + */ + public array $manualRates = [['zone_name' => 'DE', 'rate_percentage' => '19.00']]; + + public function mount(): void + { + Gate::authorize('manage-shipping'); + $taxes = TaxSetting::query()->where('store_id', app('current_store')->id)->first(); + $this->pricesIncludeTax = $taxes?->prices_include_tax ?? true; + $this->mode = $taxes?->mode ?? 'manual'; + $this->provider = $taxes?->provider ?? 'none'; + $this->defaultRate = $taxes?->defaultTaxRateForCalculator() ?? 1900; + $this->defaultRatePercentage = $this->basisPointsToPercentage($this->defaultRate); + $configuration = $taxes?->config_json ?? []; + $this->fallback = $configuration['fallback'] ?? 'allow'; + $this->manualRates = $this->rowsFromRates($taxes?->taxRatesForCalculator() ?? []); + + if (filled($configuration['provider_api_key_encrypted'] ?? null)) { + try { + $this->providerApiKey = Crypt::decryptString($configuration['provider_api_key_encrypted']); + $this->hasSavedProviderKey = true; + $this->providerApiKey = ''; + } catch (\Throwable) { + $this->hasSavedProviderKey = false; + } + } + } + + public function addManualRate(): void + { + if (count($this->manualRates) >= 20) { + $this->addError('manualRates', 'A maximum of 20 manual tax rates can be configured.'); + + return; + } + + $this->manualRates[] = ['zone_name' => '', 'rate_percentage' => '0.00']; + } + + public function removeManualRate(int $index): void + { + unset($this->manualRates[$index]); + $this->manualRates = array_values($this->manualRates); + + if ($this->manualRates === []) { + $this->manualRates = [['zone_name' => '', 'rate_percentage' => '0.00']]; + } + } + + public function save(): void + { + Gate::authorize('manage-shipping'); + $rules = [ + 'mode' => ['required', 'in:manual,provider'], + 'pricesIncludeTax' => ['boolean'], + 'provider' => ['required', 'in:stripe_tax,none'], + 'providerApiKey' => ['nullable', 'string', 'max:255'], + 'fallback' => ['required', 'in:allow,block'], + 'defaultRatePercentage' => ['required', 'string', 'regex:/\A(?:100(?:\.0{1,2})?|\d{1,2}(?:\.\d{1,2})?)\z/'], + ]; + + if ($this->mode === 'manual') { + $rules['manualRates'] = ['required', 'array', 'min:1', 'max:20']; + $rules['manualRates.*.zone_name'] = ['required', 'string', 'max:10', 'regex:/\A[A-Z]{2}(?:-[A-Z0-9]{1,3})?\z/i']; + $rules['manualRates.*.rate_percentage'] = ['required', 'string', 'regex:/\A(?:100(?:\.0{1,2})?|\d{1,2}(?:\.\d{1,2})?)\z/']; + } + + if ($this->mode === 'provider' && $this->provider === 'stripe_tax' && ! $this->hasSavedProviderKey) { + $rules['providerApiKey'] = ['required', 'string', 'min:8', 'max:255']; + } + + $this->validate($rules); + $taxes = TaxSetting::query()->where('store_id', app('current_store')->id)->first(); + $configuration = $taxes?->config_json ?? []; + $defaultRate = $this->percentageToBasisPoints($this->defaultRatePercentage); + $rates = $this->mode === 'manual' ? $this->ratesFromRows() : ($taxes?->taxRatesForCalculator() ?? []); + $taxRateRows = $this->rowsForStorage($rates); + $encryptedProviderKey = filled($this->providerApiKey) + ? Crypt::encryptString($this->providerApiKey) + : ($configuration['provider_api_key_encrypted'] ?? null); + $configuration = [ + ...$configuration, + 'default_tax_rate' => $defaultRate, + 'tax_rates' => $taxRateRows, + 'fallback' => $this->fallback, + ]; + + if ($encryptedProviderKey !== null) { + $configuration['provider_api_key_encrypted'] = $encryptedProviderKey; + } + + TaxSetting::updateOrCreate(['store_id' => app('current_store')->id], [ + 'mode' => $this->mode, + 'provider' => $this->mode === 'provider' ? $this->provider : 'none', + 'prices_include_tax' => $this->pricesIncludeTax, + 'config_json' => $configuration, + 'default_rate' => $defaultRate, + 'rates_json' => $rates, + ]); + + $this->defaultRate = $defaultRate; + $this->hasSavedProviderKey = $encryptedProviderKey !== null; + $this->providerApiKey = ''; + session()->flash('status', 'Tax settings saved.'); + } + + public function render(): mixed + { + Gate::authorize('manage-shipping'); + + return view('livewire.admin.settings.taxes')->layout('layouts.admin'); + } + + /** @param array $rates + * @return list + */ + private function rowsFromRates(array $rates): array + { + $rows = []; + + foreach ($rates as $countryCode => $rate) { + if (is_array($rate)) { + foreach ($rate as $provinceCode => $provinceRate) { + $rows[] = [ + 'zone_name' => strtoupper($countryCode.'-'.$provinceCode), + 'rate_percentage' => $this->basisPointsToPercentage((int) $provinceRate), + ]; + } + } elseif (is_numeric($rate)) { + $rows[] = [ + 'zone_name' => strtoupper((string) $countryCode), + 'rate_percentage' => $this->basisPointsToPercentage((int) $rate), + ]; + } + } + + return $rows === [] ? [['zone_name' => 'DE', 'rate_percentage' => '19.00']] : $rows; + } + + /** @return array> */ + private function ratesFromRows(): array + { + $rates = []; + + foreach ($this->manualRates as $index => $row) { + $zoneName = strtoupper(trim($row['zone_name'])); + $rate = $this->percentageToBasisPoints($row['rate_percentage']); + $parts = explode('-', $zoneName, 2); + $countryCode = $parts[0]; + $provinceCode = $parts[1] ?? null; + + if ($provinceCode === null) { + if (array_key_exists($countryCode, $rates)) { + throw ValidationException::withMessages(["manualRates.{$index}.zone_name" => 'Each tax zone must be unique.']); + } + + $rates[$countryCode] = $rate; + + continue; + } + + if (isset($rates[$countryCode]) && ! is_array($rates[$countryCode])) { + throw ValidationException::withMessages(["manualRates.{$index}.zone_name" => 'A country-wide rate cannot be combined with regional rates for the same country.']); + } + + $rates[$countryCode] ??= []; + + if (array_key_exists($provinceCode, $rates[$countryCode])) { + throw ValidationException::withMessages(["manualRates.{$index}.zone_name" => 'Each tax zone must be unique.']); + } + + $rates[$countryCode][$provinceCode] = $rate; + } + + return $rates; + } + + /** @param array> $rates + * @return list + */ + private function rowsForStorage(array $rates): array + { + $rows = []; + + foreach ($rates as $countryCode => $rate) { + if (is_array($rate)) { + foreach ($rate as $provinceCode => $provinceRate) { + $rows[] = ['country_code' => $countryCode, 'province_code' => $provinceCode, 'rate' => $provinceRate]; + } + } else { + $rows[] = ['country_code' => $countryCode, 'rate' => $rate]; + } + } + + return $rows; + } + + private function percentageToBasisPoints(string $percentage): int + { + if (preg_match('/\A(\d{1,3})(?:\.(\d{1,2}))?\z/', trim($percentage), $matches) !== 1) { + throw ValidationException::withMessages(['defaultRatePercentage' => 'Enter a percentage between 0 and 100 with up to two decimal places.']); + } + + $whole = (int) $matches[1]; + $fraction = (int) str_pad($matches[2] ?? '', 2, '0'); + $basisPoints = $whole * 100 + $fraction; + + if ($basisPoints > 10000) { + throw ValidationException::withMessages(['defaultRatePercentage' => 'The tax rate cannot exceed 100%.']); + } + + return $basisPoints; + } + + private function basisPointsToPercentage(int $basisPoints): string + { + return intdiv($basisPoints, 100).'.'.str_pad((string) ($basisPoints % 100), 2, '0', STR_PAD_LEFT); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..c5d3accd --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,446 @@ +theme = $this->findTheme($theme->getKey()); + $this->settings = $this->theme->settings?->settings_json ?? []; + $this->sections = $this->sectionsForTheme($this->theme); + $this->selectedSection = $this->sections[0]['key'] ?? null; + $this->loadSelectedSectionSettings(); + $this->refreshPreview(); + } + + public function selectSection(string $sectionKey): void + { + Gate::authorize('manage-themes'); + $theme = $this->currentTheme(); + $sections = $this->sectionsForTheme($theme); + $section = collect($sections)->firstWhere('key', $sectionKey); + + abort_unless($section !== null, 404); + + $this->saveSelectedSectionToSettings($sections); + $this->sections = $sections; + $this->selectedSection = $sectionKey; + $this->loadSelectedSectionSettings(); + } + + public function updateSetting(string $key, mixed $value): void + { + Gate::authorize('manage-themes'); + $sections = $this->sectionsForTheme($this->currentTheme()); + $field = data_get(collect($sections)->firstWhere('key', $this->selectedSection), 'fields', []); + $field = collect($field)->firstWhere('key', $key); + + abort_unless($field !== null, 404); + + $value = $this->validatedSettingValue($field, $value); + $this->sectionSettings[$key] = $value; + data_set($this->settings, $field['path'], $value); + } + + public function save(): void + { + Gate::authorize('manage-themes'); + + $theme = $this->currentTheme(); + $sections = $this->sectionsForTheme($theme); + $this->saveSelectedSectionToSettings($sections); + $settings = $theme->settings?->settings_json ?? []; + + foreach ($sections as $section) { + foreach ($section['fields'] as $field) { + $value = data_get($this->settings, $field['path'], $field['default']); + $value = $this->validatedSettingValue($field, $value); + data_set($settings, $field['path'], $value); + data_set($this->settings, $field['path'], $value); + } + } + + $theme->settings()->updateOrCreate( + ['theme_id' => $theme->getKey()], + ['settings_json' => $settings, 'updated_at' => now()], + ); + + $this->theme = $theme->fresh(['settings']); + session()->flash('status', 'Theme settings saved.'); + } + + public function publish(): void + { + Gate::authorize('manage-themes'); + + $theme = $this->currentTheme(); + $storeId = $this->currentStore()->getKey(); + $publishedAt = now(); + + DB::transaction(function () use ($theme, $storeId, $publishedAt): void { + Theme::query() + ->where('store_id', $storeId) + ->where('id', '!=', $theme->getKey()) + ->update(['status' => 'draft', 'is_active' => false]); + + $theme->update([ + 'status' => 'published', + 'is_active' => true, + 'published_at' => $publishedAt, + ]); + }); + + $this->theme = $theme->fresh(['settings']); + session()->flash('status', "{$theme->name} is now the published theme."); + } + + public function saveAndPublish(): void + { + $this->save(); + $this->publish(); + } + + public function refreshPreview(): void + { + Gate::authorize('manage-themes'); + $this->previewVersion = now()->getTimestamp(); + } + + public function render(): mixed + { + Gate::authorize('manage-themes'); + + $this->theme = $this->currentTheme(); + $this->sections = $this->sectionsForTheme($this->theme); + + if ($this->selectedSection === null || ! collect($this->sections)->contains('key', $this->selectedSection)) { + $this->selectedSection = $this->sections[0]['key'] ?? null; + $this->loadSelectedSectionSettings(); + } + + $this->previewUrl = route('home', ['_theme_preview' => $this->previewVersion]); + + return view('livewire.admin.themes.editor') + ->layout('layouts.admin-theme-editor', ['theme' => $this->theme]); + } + + private function sectionsForTheme(Theme $theme): array + { + $manifestContent = DB::table('theme_files') + ->where('theme_id', $theme->getKey()) + ->where('path', 'theme.json') + ->value('content'); + + $manifest = is_string($manifestContent) ? json_decode($manifestContent, true) : null; + $schema = is_array($manifest) ? ($manifest['settings_schema'] ?? null) : null; + + if (is_array($schema) && is_array($schema['properties'] ?? null)) { + $sections = $this->sectionsFromSchema( + $schema['properties'], + $theme->settings?->settings_json ?? [], + $schema['required'] ?? [], + ); + + if ($sections !== []) { + return $sections; + } + } + + return $this->defaultSections(); + } + + /** @param array $properties + * @param array $settings + * @return list>}> + */ + private function sectionsFromSchema(array $properties, array $settings, mixed $rootRequired): array + { + $sections = []; + $rootRequired = is_array($rootRequired) ? $rootRequired : []; + $sectionProperties = $properties; + unset($sectionProperties['required']); + + foreach ($sectionProperties as $sectionKey => $definition) { + if (! $this->isSafeKey($sectionKey) || ! is_array($definition)) { + continue; + } + + $nested = $definition['properties'] ?? null; + if (($definition['type'] ?? null) === 'object' && is_array($nested)) { + $fields = $this->fieldsFromProperties($nested, (string) $sectionKey, $settings, $definition['required'] ?? []); + $label = (string) ($definition['title'] ?? str($sectionKey)->headline()); + } else { + $fields = $this->fieldsFromProperties([$sectionKey => $definition], '', $settings, $rootRequired); + $label = 'General'; + } + + if ($fields !== []) { + $key = ($definition['type'] ?? null) === 'object' && is_array($nested) + ? (string) $sectionKey + : 'general'; + + $sections[$key] ??= ['key' => $key, 'label' => $label, 'fields' => []]; + array_push($sections[$key]['fields'], ...$fields); + } + } + + return array_values($sections); + } + + /** @param array $properties + * @param array $settings + * @return list> + */ + private function fieldsFromProperties(array $properties, string $prefix, array $settings, mixed $requiredProperties): array + { + $requiredProperties = is_array($requiredProperties) ? $requiredProperties : []; + $fields = []; + + foreach ($properties as $property => $definition) { + if (! $this->isSafeKey($property) || ! is_array($definition)) { + continue; + } + + $path = $prefix === '' ? (string) $property : $prefix.'.'.$property; + $field = $this->fieldFromDefinition($property, $path, $definition, in_array($property, $requiredProperties, true)); + + if ($field === null) { + continue; + } + + $field['default'] = data_get($settings, $path, $field['default']); + $fields[] = $field; + } + + return $fields; + } + + /** @param array $definition + * @return array|null + */ + private function fieldFromDefinition(string $key, string $path, array $definition, bool $required): ?array + { + $schemaType = $definition['type'] ?? null; + $enum = $definition['enum'] ?? null; + $inputType = $definition['x-ui-type'] ?? $definition['ui'] ?? null; + + if (is_array($enum) && $enum !== []) { + $type = 'select'; + } elseif ($inputType === 'textarea' || ($schemaType === 'string' && ($definition['format'] ?? null) === 'textarea')) { + $type = 'textarea'; + } elseif ($inputType === 'color' || ($schemaType === 'string' && ($definition['format'] ?? null) === 'color')) { + $type = 'color'; + } else { + $type = match ($schemaType) { + 'string' => 'text', + 'integer', 'number' => 'number', + 'boolean' => 'checkbox', + default => null, + }; + } + + if ($type === null) { + return null; + } + + return [ + 'key' => $this->settingKey($path), + 'path' => $path, + 'label' => (string) ($definition['title'] ?? str($key)->headline()), + 'type' => $type, + 'options' => $enum ?? [], + 'required' => $required, + 'default' => $definition['default'] ?? $this->defaultValueFor($type, $enum ?? []), + ]; + } + + /** @return list>}> */ + private function defaultSections(): array + { + return [ + [ + 'key' => 'header', + 'label' => 'Header', + 'fields' => [ + $this->field('logo_url', 'Logo URL', 'text', null), + $this->field('sticky_header', 'Keep header visible while scrolling', 'checkbox', true), + $this->field('announcement.enabled', 'Show announcement bar', 'checkbox', false), + $this->field('announcement.text', 'Announcement text', 'text', ''), + $this->field('announcement.url', 'Announcement link', 'text', ''), + ], + ], + [ + 'key' => 'hero', + 'label' => 'Hero', + 'fields' => [ + $this->field('home.hero.enabled', 'Show hero section', 'checkbox', true), + $this->field('home.hero.heading', 'Heading', 'text', 'Welcome'), + $this->field('home.hero.subheading', 'Subheading', 'textarea', ''), + $this->field('home.hero.cta_text', 'Button label', 'text', 'Shop now'), + $this->field('home.hero.cta_url', 'Button link', 'text', '/collections'), + ], + ], + [ + 'key' => 'colors', + 'label' => 'Colors', + 'fields' => [ + $this->field('colors.primary', 'Primary color', 'color', '#1a1a2e'), + $this->field('colors.secondary', 'Secondary color', 'color', '#e94560'), + $this->field('colors.accent', 'Accent color', 'color', '#e94560'), + ], + ], + [ + 'key' => 'products', + 'label' => 'Products', + 'fields' => [ + $this->field('products_per_page', 'Products per page', 'number', 12), + $this->field('show_vendor', 'Show product vendor', 'checkbox', true), + $this->field('show_quantity_selector', 'Show quantity selector', 'checkbox', true), + ], + ], + [ + 'key' => 'footer', + 'label' => 'Footer', + 'fields' => [ + $this->field('footer_text', 'Footer text', 'text', ''), + ], + ], + ]; + } + + /** @return array */ + private function field(string $path, string $label, string $type, mixed $default): array + { + return [ + 'key' => $this->settingKey($path), + 'path' => $path, + 'label' => $label, + 'type' => $type, + 'options' => [], + 'required' => false, + 'default' => $default, + ]; + } + + private function defaultValueFor(string $type, array $options): mixed + { + return match ($type) { + 'checkbox' => false, + 'number' => 0, + 'select' => $options[0] ?? '', + 'color' => '#000000', + default => '', + }; + } + + private function isSafeKey(int|string $key): bool + { + return preg_match('/\A[a-zA-Z0-9_-]+\z/', (string) $key) === 1; + } + + private function settingKey(string $path): string + { + return 'setting_'.trim((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $path), '_'); + } + + private function loadSelectedSectionSettings(): void + { + $section = collect($this->sections)->firstWhere('key', $this->selectedSection); + $this->sectionSettings = []; + + foreach ($section['fields'] ?? [] as $field) { + $this->sectionSettings[$field['key']] = data_get($this->settings, $field['path'], $field['default']); + } + } + + private function saveSelectedSectionToSettings(array $sections): void + { + $section = collect($sections)->firstWhere('key', $this->selectedSection); + + foreach ($section['fields'] ?? [] as $field) { + if (array_key_exists($field['key'], $this->sectionSettings)) { + data_set($this->settings, $field['path'], $this->sectionSettings[$field['key']]); + } + } + } + + private function validatedSettingValue(array $field, mixed $value): mixed + { + $rules = match ($field['type']) { + 'checkbox' => ['boolean'], + 'number' => ['numeric'], + 'color' => ['string', 'regex:/\A#[0-9a-fA-F]{6}\z/'], + 'select' => ['string', Rule::in($field['options'])], + default => ['string', 'max:10000'], + }; + + if ($field['required'] && $field['type'] !== 'checkbox') { + array_unshift($rules, 'required'); + } + + $validator = Validator::make(['value' => $value], ['value' => $rules]); + + if ($validator->fails()) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'settings.'.$field['path'] => $validator->errors()->first('value'), + ]); + } + + $validated = $validator->validated()['value']; + + if ($field['type'] === 'number' && is_string($validated) && is_numeric($validated)) { + return str_contains($validated, '.') ? (float) $validated : (int) $validated; + } + + return $validated; + } + + private function currentTheme(): Theme + { + return $this->findTheme($this->theme->getKey()); + } + + private function findTheme(int|string $themeId): Theme + { + Gate::authorize('manage-themes'); + + return Theme::query() + ->where('store_id', $this->currentStore()->getKey()) + ->with('settings') + ->findOrFail($themeId); + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..89c7524e --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,125 @@ +currentStore()->getKey(); + $theme = $this->findTheme($themeId, $storeId); + $publishedAt = now(); + + DB::transaction(function () use ($storeId, $theme, $publishedAt): void { + Theme::query() + ->where('store_id', $storeId) + ->where('id', '!=', $theme->getKey()) + ->update(['status' => 'draft', 'is_active' => false]); + + $theme->update([ + 'status' => 'published', + 'is_active' => true, + 'published_at' => $publishedAt, + ]); + }); + + session()->flash('status', "{$theme->name} is now the published theme."); + } + + public function duplicateTheme(int $themeId): void + { + Gate::authorize('manage-themes'); + + $theme = $this->findTheme($themeId, $this->currentStore()->getKey()); + $copy = DB::transaction(function () use ($theme): Theme { + $copy = $theme->replicate(['is_active', 'published_at', 'status']); + $copy->name = mb_substr($theme->name.' (Copy)', 0, 255); + $copy->status = 'draft'; + $copy->is_active = false; + $copy->published_at = null; + $copy->save(); + + if ($theme->settings !== null) { + $copy->settings()->create([ + 'settings_json' => $theme->settings->settings_json ?? [], + 'updated_at' => now(), + ]); + } + + $now = now(); + $files = DB::table('theme_files') + ->where('theme_id', $theme->getKey()) + ->get(['path', 'content', 'sha256', 'byte_size']) + ->map(fn (object $file): array => [ + 'theme_id' => $copy->getKey(), + 'path' => $file->path, + 'content' => $file->content, + 'storage_key' => 'themes/'.$copy->getKey().'/'.$file->path, + 'sha256' => $file->sha256 ?: hash('sha256', $file->content), + 'byte_size' => $file->byte_size ?? strlen($file->content), + 'created_at' => $now, + 'updated_at' => $now, + ]) + ->all(); + + if ($files !== []) { + DB::table('theme_files')->insert($files); + } + + return $copy; + }); + + session()->flash('status', "{$theme->name} was duplicated."); + $this->dispatch('theme-duplicated', themeId: $copy->getKey()); + } + + public function deleteTheme(int $themeId): void + { + Gate::authorize('manage-themes'); + + $theme = $this->findTheme($themeId, $this->currentStore()->getKey()); + $name = $theme->name; + $theme->delete(); + + session()->flash('status', "{$name} was deleted."); + } + + public function render(): mixed + { + Gate::authorize('manage-themes'); + + $themes = Theme::query() + ->where('store_id', $this->currentStore()->getKey()) + ->with('settings') + ->orderByDesc('is_active') + ->orderBy('name') + ->orderBy('id') + ->get(); + + return view('livewire.admin.themes.index', compact('themes'))->layout('layouts.admin'); + } + + private function currentStore(): Store + { + $store = app('current_store'); + + abort_unless($store instanceof Store, 404); + + return $store; + } + + private function findTheme(int $themeId, int|string $storeId): Theme + { + return Theme::query() + ->where('store_id', $storeId) + ->findOrFail($themeId); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..98dbe61e --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,179 @@ + */ + public array $editingAddress = []; + + public ?int $deleteAddressId = null; + + public bool $isDefault = false; + + public string $label = ''; + + /** @var array */ + public array $address = [ + 'first_name' => '', 'last_name' => '', 'address_line_1' => '', 'address_line_2' => '', 'city' => '', + 'state' => '', 'country' => 'DE', 'postal_code' => '', 'phone' => '', + ]; + + public function openAddressForm(): void + { + $this->resetForm(); + $this->showAddressForm = true; + } + + public function closeAddressForm(): void + { + $this->resetForm(); + $this->showAddressForm = false; + } + + public function editAddress(int $addressId): void + { + $saved = $this->customer()->addresses()->findOrFail($addressId); + $this->editingAddressId = $saved->id; + $this->label = $saved->label ?? ''; + $this->address = $this->normalizeAddress($saved->address_json ?? []); + $this->editingAddress = $this->address; + $this->isDefault = $saved->is_default; + $this->showAddressForm = true; + } + + public function saveAddress(): void + { + $this->validate([ + 'label' => ['nullable', 'string', 'max:50'], + 'address.first_name' => ['required', 'string', 'max:100'], + 'address.last_name' => ['required', 'string', 'max:100'], + 'address.address_line_1' => ['required', 'string', 'max:255'], + 'address.address_line_2' => ['nullable', 'string', 'max:255'], + 'address.city' => ['required', 'string', 'max:100'], + 'address.state' => ['nullable', 'string', 'max:100'], + 'address.country' => ['required', 'string', 'size:2'], + 'address.postal_code' => ['required', 'string', 'max:24'], + 'address.phone' => ['nullable', 'string', 'max:32'], + 'isDefault' => ['boolean'], + ]); + + $customer = $this->customer(); + + DB::transaction(function () use ($customer): void { + if ($this->isDefault) { + $customer->addresses()->update(['is_default' => false]); + } + + $saved = $this->editingAddressId + ? $customer->addresses()->findOrFail($this->editingAddressId) + : new CustomerAddress; + $normalized = $this->normalizeAddress($this->address); + $saved->fill([ + 'label' => $this->label, + 'address_json' => $normalized, + 'is_default' => $this->isDefault || ! $customer->addresses()->exists(), + ]); + $customer->addresses()->save($saved); + }); + + $this->closeAddressForm(); + session()->flash('status', 'Address saved.'); + } + + public function confirmDelete(int $addressId): void + { + $this->customer()->addresses()->findOrFail($addressId); + $this->deleteAddressId = $addressId; + } + + public function cancelDelete(): void + { + $this->deleteAddressId = null; + } + + public function deleteAddress(): void + { + $customer = $this->customer(); + $address = $customer->addresses()->findOrFail($this->deleteAddressId); + $wasDefault = $address->is_default; + $address->delete(); + + if ($wasDefault && $nextDefault = $customer->addresses()->oldest('id')->first()) { + $nextDefault->forceFill(['is_default' => true])->save(); + } + + $this->deleteAddressId = null; + session()->flash('status', 'Address removed.'); + } + + public function setDefault(int $addressId): void + { + $customer = $this->customer(); + $customer->addresses()->update(['is_default' => false]); + $customer->addresses()->findOrFail($addressId)->forceFill(['is_default' => true])->save(); + session()->flash('status', 'Default address updated.'); + } + + public function render(): mixed + { + $addresses = $this->customer()->addresses()->orderByDesc('is_default')->orderBy('id')->get(); + + $addresses->each(function (CustomerAddress $address): void { + $address->setAttribute('address_json', $this->normalizeAddress($address->address_json ?? [])); + }); + + return view('storefront.account.addresses', compact('addresses'))->layout('storefront.layouts.app'); + } + + private function customer(): mixed + { + return auth('customer')->user(); + } + + /** @param array $address + * @return array + */ + private function normalizeAddress(array $address): array + { + $country = strtoupper((string) ($address['country'] ?? $address['country_code'] ?? 'DE')); + $addressLine1 = (string) ($address['address_line_1'] ?? $address['address1'] ?? ''); + $addressLine2 = (string) ($address['address_line_2'] ?? $address['address2'] ?? ''); + $postalCode = (string) ($address['postal_code'] ?? $address['zip'] ?? ''); + $state = (string) ($address['state'] ?? $address['province'] ?? ''); + + return [ + 'first_name' => (string) ($address['first_name'] ?? ''), + 'last_name' => (string) ($address['last_name'] ?? ''), + 'address_line_1' => $addressLine1, + 'address_line_2' => $addressLine2, + 'address1' => $addressLine1, + 'address2' => $addressLine2, + 'city' => (string) ($address['city'] ?? ''), + 'state' => $state, + 'province' => $state, + 'country' => $country, + 'country_code' => $country, + 'postal_code' => $postalCode, + 'zip' => $postalCode, + 'phone' => (string) ($address['phone'] ?? ''), + ]; + } + + private function resetForm(): void + { + $this->reset(['editingAddressId', 'editingAddress', 'label', 'isDefault']); + $this->address = [ + 'first_name' => '', 'last_name' => '', 'address_line_1' => '', 'address_line_2' => '', 'city' => '', + 'state' => '', 'country' => 'DE', 'postal_code' => '', 'phone' => '', + ]; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php new file mode 100644 index 00000000..eff511ff --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php @@ -0,0 +1,24 @@ +validate(['email' => ['required', 'email', 'max:255']]); + Password::broker('customers')->sendResetLink(['email' => $this->email]); + $this->statusMessage = 'If an account with that email exists, we sent a password reset link.'; + } + + public function render(): mixed + { + return view('storefront.account.forgot-password')->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..e2b02952 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,80 @@ +query('redirect'); + $this->redirectTo = is_string($redirect) ? ($this->safeRedirectPath($redirect) ?? '') : ''; + } + + public function login(CartService $carts): mixed + { + $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + $key = 'customer-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages(['email' => 'Too many login attempts. Try again in a minute.']); + } + + RateLimiter::hit($key, 60); + $store = app('current_store'); + + if (! Auth::guard('customer')->attempt(['store_id' => $store->id, 'email' => $this->email, 'password' => $this->password], $this->remember)) { + throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']); + } + + RateLimiter::clear($key); + session()->regenerate(); + $customer = Auth::guard('customer')->user(); + $guestCartId = session('cart_id'); + + if ($guestCartId) { + $guestCart = \App\Models\Cart::withoutGlobalScopes()->where('store_id', $store->id)->whereNull('customer_id')->find($guestCartId); + + if ($guestCart) { + $carts->mergeGuestCart($guestCart, $customer); + } + } + + $redirectTo = $this->safeRedirectPath($this->redirectTo); + + return $redirectTo !== null ? $this->redirect($redirectTo) : $this->redirectRoute('storefront.account.dashboard'); + } + + private function safeRedirectPath(string $redirect): ?string + { + if ($redirect === '' || ! str_starts_with($redirect, '/') || str_starts_with($redirect, '//') || str_contains($redirect, '\\') || preg_match('/[\x00-\x1F\x7F]/', $redirect)) { + return null; + } + + $parsedRedirect = parse_url($redirect); + + if ($parsedRedirect === false || isset($parsedRedirect['scheme']) || isset($parsedRedirect['host']) || isset($parsedRedirect['user']) || isset($parsedRedirect['pass'])) { + return null; + } + + return $redirect; + } + + public function render(): mixed + { + return view('storefront.account.login')->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..71cc6764 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,59 @@ +ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages(['email' => 'Too many registration attempts. Try again in a minute.']); + } + + RateLimiter::hit($key, 60); + $store = app('current_store'); + $data = $this->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', \Illuminate\Validation\Rule::unique('customers', 'email')->where('store_id', $store->id)], + 'password' => ['required', 'confirmed', Password::defaults()], + 'marketingOptIn' => ['boolean'], + ]); + $customer = Customer::create([ + 'store_id' => $store->id, + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => $data['password'], + 'marketing_opt_in' => $data['marketingOptIn'], + ]); + + RateLimiter::clear($key); + Auth::guard('customer')->login($customer); + session()->regenerate(); + + return $this->redirectRoute('storefront.account.dashboard'); + } + + public function render(): mixed + { + return view('storefront.account.register')->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php new file mode 100644 index 00000000..c279e644 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php @@ -0,0 +1,53 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + public function resetPassword(): mixed + { + $data = $this->validate([ + 'token' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'confirmed', PasswordRule::defaults()], + ]); + $status = Password::broker('customers')->reset($data, function (Customer $customer, string $password): void { + $customer->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save(); + event(new PasswordResetEvent($customer)); + }); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages(['email' => __($status)]); + } + + session()->flash('status', 'Your password has been reset. Please sign in.'); + + return $this->redirectRoute('storefront.login'); + } + + public function render(): mixed + { + return view('storefront.account.reset-password')->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..047eac3c --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,20 @@ +user(); + + return view('storefront.account.index', [ + 'customer' => $customer, + 'orders' => Order::query()->where('customer_id', $customer->id)->latest('placed_at')->limit(5)->get(), + 'addresses' => $customer->addresses()->orderByDesc('is_default')->get(), + ])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..db8f7cd8 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,19 @@ + Order::query()->where('customer_id', auth('customer')->id())->latest('placed_at')->paginate(10), + ])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..2010ae60 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,27 @@ +orderNumber = $orderNumber; + } + + public function render(): mixed + { + $order = Order::query() + ->where('customer_id', auth('customer')->id()) + ->where('order_number', $this->orderNumber) + ->with('lines', 'payments', 'fulfillments.lines') + ->firstOrFail(); + + return view('storefront.account.orders.show', ['order' => $order])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..e5abfe76 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,140 @@ + */ + public array $quantities = []; + + public string $discountCode = ''; + + public function mount(CartService $carts): void + { + $this->syncQuantities($carts); + } + + public function updatedQuantities(int $quantity, string $lineId, CartService $carts): void + { + $this->updateLine((int) $lineId, $quantity, $carts); + } + + public function increaseQuantity(int $lineId, CartService $carts): void + { + $cart = $this->cart($carts); + $line = $cart->lines()->findOrFail($lineId); + $this->updateLine($lineId, min(9999, (int) $line->quantity + 1), $carts); + } + + public function decreaseQuantity(int $lineId, CartService $carts): void + { + $cart = $this->cart($carts); + $line = $cart->lines()->findOrFail($lineId); + $this->updateLine($lineId, max(1, (int) $line->quantity - 1), $carts); + } + + public function removeItem(int $lineId, CartService $carts): void + { + $cart = $this->cart($carts); + $line = $cart->lines()->with('variant', 'cart')->findOrFail($lineId); + $carts->remove($cart, $lineId); + unset($this->quantities[$lineId]); + $this->dispatchStorefrontAnalytics('remove_from_cart', $this->cartLineProperties($line, (int) $line->quantity)); + $this->dispatch('cart-updated'); + } + + public function applyDiscount(CartService $carts): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:64']]); + $carts->applyDiscount($this->cart($carts), $this->discountCode); + $this->dispatch('cart-updated'); + } + + public function removeDiscount(CartService $carts): void + { + $carts->removeDiscount($this->cart($carts)); + $this->discountCode = ''; + $this->dispatch('cart-updated'); + } + + public function beginCheckout(CartService $carts): mixed + { + if (! $this->currentCart($carts)?->lines()->exists()) { + $this->addError('cart', 'Your cart is empty.'); + + return null; + } + + return $this->redirectRoute('storefront.checkout'); + } + + public function render(CartService $carts): mixed + { + $cart = $this->currentCart($carts); + $this->fillQuantities($cart); + + return view('storefront.cart', ['cart' => $cart])->layout('storefront.layouts.app'); + } + + private function updateLine(int $lineId, int $quantity, CartService $carts): void + { + $cart = $this->cart($carts); + $line = $cart->lines()->with('variant', 'cart')->findOrFail($lineId); + $previousQuantity = (int) $line->quantity; + $carts->updateQuantity($cart, $lineId, $quantity); + $this->dispatchQuantityChange($line, $previousQuantity, $quantity); + $this->dispatch('cart-updated'); + $this->syncQuantities($carts); + } + + private function dispatchQuantityChange(CartLine $line, int $previousQuantity, int $quantity): void + { + if ($quantity === $previousQuantity) { + return; + } + + $eventType = $quantity > $previousQuantity ? 'add_to_cart' : 'remove_from_cart'; + $this->dispatchStorefrontAnalytics($eventType, $this->cartLineProperties($line, abs($quantity - $previousQuantity))); + } + + /** @return array */ + private function cartLineProperties(CartLine $line, int $quantity): array + { + return [ + 'product_id' => $line->variant?->product_id, + 'variant_id' => $line->variant_id, + 'quantity' => $quantity, + 'price_amount' => (int) ($line->variant?->price_amount ?? $line->unit_price_amount), + 'currency' => $line->cart?->currency, + ]; + } + + private function cart(CartService $carts): Cart + { + return $carts->getOrCreateActiveCart(app('current_store'), auth('customer')->user()); + } + + private function currentCart(CartService $carts): ?Cart + { + return $carts->findActiveCart(app('current_store'), auth('customer')->user()); + } + + private function syncQuantities(CartService $carts): void + { + $this->fillQuantities($this->currentCart($carts)); + } + + private function fillQuantities(?Cart $cart): void + { + $this->quantities = $cart?->lines->mapWithKeys(static fn ($line): array => [$line->id => $line->quantity])->all() ?? []; + $this->discountCode = $cart?->discount_code ?? ''; + } +} diff --git a/app/Livewire/Storefront/CartCount.php b/app/Livewire/Storefront/CartCount.php new file mode 100644 index 00000000..4260561d --- /dev/null +++ b/app/Livewire/Storefront/CartCount.php @@ -0,0 +1,27 @@ +bound('current_store') ? app('current_store') : null; + + abort_unless($store instanceof Store, 403); + + $cart = $carts->findActiveCart($store, auth('customer')->user()); + + return view('livewire.storefront.cart-count', [ + 'itemCount' => (int) ($cart?->lines->sum('quantity') ?? 0), + ]); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..dc8288dd --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,139 @@ + */ + public array $quantities = []; + + public string $discountCode = ''; + + #[On('open-cart-drawer')] + public function open(): void + { + $this->isOpen = true; + } + + public function close(): void + { + $this->isOpen = false; + } + + #[On('cart-updated')] + public function refreshCart(): void + { + $this->syncQuantities(app(CartService::class)); + } + + public function updatedQuantities(int $quantity, string $lineId, CartService $carts): void + { + $this->updateLine((int) $lineId, $quantity, $carts); + } + + public function increaseQuantity(int $lineId, CartService $carts): void + { + $line = $this->cart($carts)->lines()->findOrFail($lineId); + $this->updateLine($lineId, min(9999, (int) $line->quantity + 1), $carts); + } + + public function decreaseQuantity(int $lineId, CartService $carts): void + { + $line = $this->cart($carts)->lines()->findOrFail($lineId); + $this->updateLine($lineId, max(1, (int) $line->quantity - 1), $carts); + } + + public function removeItem(int $lineId, CartService $carts): void + { + $cart = $this->cart($carts); + $line = $cart->lines()->with('variant', 'cart')->findOrFail($lineId); + $carts->remove($cart, $lineId); + $this->dispatchStorefrontAnalytics('remove_from_cart', $this->cartLineProperties($line, (int) $line->quantity)); + $this->dispatch('cart-updated'); + $this->syncQuantities($carts); + } + + public function applyDiscount(CartService $carts): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:64']]); + $carts->applyDiscount($this->cart($carts), $this->discountCode); + $this->dispatch('cart-updated'); + $this->syncQuantities($carts); + } + + public function removeDiscount(CartService $carts): void + { + $carts->removeDiscount($this->cart($carts)); + $this->discountCode = ''; + $this->dispatch('cart-updated'); + $this->syncQuantities($carts); + } + + public function render(CartService $carts): mixed + { + $cart = $carts->findActiveCart(app('current_store'), auth('customer')->user()); + $this->fillQuantities($cart); + + return view('storefront.components.cart-drawer', ['cart' => $cart]); + } + + private function cart(CartService $carts): Cart + { + return $carts->getOrCreateActiveCart(app('current_store'), auth('customer')->user()); + } + + private function syncQuantities(CartService $carts): void + { + $this->fillQuantities($this->currentCart($carts)); + } + + private function currentCart(CartService $carts): ?Cart + { + return $carts->findActiveCart(app('current_store'), auth('customer')->user()); + } + + private function updateLine(int $lineId, int $quantity, CartService $carts): void + { + $cart = $this->cart($carts); + $line = $cart->lines()->with('variant', 'cart')->findOrFail($lineId); + $previousQuantity = (int) $line->quantity; + $carts->updateQuantity($cart, $lineId, $quantity); + + if ($quantity !== $previousQuantity) { + $eventType = $quantity > $previousQuantity ? 'add_to_cart' : 'remove_from_cart'; + $this->dispatchStorefrontAnalytics($eventType, $this->cartLineProperties($line, abs($quantity - $previousQuantity))); + } + + $this->dispatch('cart-updated'); + $this->syncQuantities($carts); + } + + /** @return array */ + private function cartLineProperties(CartLine $line, int $quantity): array + { + return [ + 'product_id' => $line->variant?->product_id, + 'variant_id' => $line->variant_id, + 'quantity' => $quantity, + 'price_amount' => (int) ($line->variant?->price_amount ?? $line->unit_price_amount), + 'currency' => $line->cart?->currency, + ]; + } + + private function fillQuantities(?Cart $cart): void + { + $this->quantities = $cart?->lines->mapWithKeys(static fn ($line): array => [$line->id => $line->quantity])->all() ?? []; + $this->discountCode = $cart?->discount_code ?? ''; + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..0a5480f6 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,23 @@ +checkoutId = $checkoutId; + } + + public function render(): mixed + { + $order = Order::query()->with('lines', 'payments')->where('checkout_id', $this->checkoutId)->firstOrFail(); + + return view('storefront.checkout.confirmation', ['order' => $order])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..53ffa854 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,298 @@ + */ + public array $shipping = [ + 'first_name' => '', 'last_name' => '', 'address_line_1' => '', 'address_line_2' => '', 'city' => '', + 'state' => '', 'country' => 'DE', 'postal_code' => '', 'phone' => '', + ]; + + /** @var array */ + public array $billing = []; + + public bool $billingSameAsShipping = true; + + public ?int $savedAddressId = null; + + public ?int $shippingRateId = null; + + public string $discountCode = ''; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = ''; + + public string $cardholderName = ''; + + public string $expiry = ''; + + public string $cvc = ''; + + public ?string $paymentError = null; + + public function mount(?int $checkoutId = null): void + { + if (auth('customer')->check()) { + $this->email = auth('customer')->user()->email; + } + + $checkoutId ??= session('checkout_id'); + $checkout = $checkoutId + ? CheckoutModel::withoutGlobalScopes()->where('store_id', app('current_store')->id)->whereIn('status', [ + CheckoutStatus::Started->value, + CheckoutStatus::Addressed->value, + CheckoutStatus::ShippingSelected->value, + CheckoutStatus::PaymentSelected->value, + ])->find($checkoutId) + : null; + + if (! $checkout) { + $cart = app(CartService::class)->findActiveCart(app('current_store'), auth('customer')->user()); + + if (! $cart || $cart->lines->isEmpty()) { + $this->redirectRoute('storefront.cart'); + + return; + } + + $checkout = app(CheckoutService::class)->start($cart, auth('customer')->user()); + session()->put('checkout_id', $checkout->id); + } + + $this->checkoutId = $checkout->id; + $this->email = $checkout->email ?? $this->email; + $this->shipping = [...$this->shipping, ...$this->addressForForm($checkout->shipping_address_json ?? [])]; + $this->billing = $this->addressForForm($checkout->billing_address_json ?? []); + $this->shippingRateId = $checkout->shipping_method_id; + $this->discountCode = $checkout->discount_code ?? ''; + $this->paymentMethod = $checkout->payment_method ?? $this->paymentMethod; + } + + public function getCheckoutStepProperty(): int + { + if ($this->stepOverride !== null) { + return $this->stepOverride; + } + + $checkout = $this->checkout(); + + return match ($checkout->status) { + CheckoutStatus::PaymentSelected->value => 4, + CheckoutStatus::ShippingSelected->value => 3, + CheckoutStatus::Addressed->value => 3, + default => blank($checkout->email) ? 1 : 2, + }; + } + + public function getAddressesProperty(): mixed + { + $customer = auth('customer')->user(); + + return $customer ? $customer->addresses()->orderByDesc('is_default')->get() : collect(); + } + + public function updatedSavedAddressId(?int $addressId): void + { + if (! $addressId) { + return; + } + + $address = auth('customer')->user()?->addresses()->findOrFail($addressId); + + if ($address) { + $this->shipping = [...$this->shipping, ...$this->addressForForm($address->address_json)]; + } + } + + public function continueToAddress(CheckoutService $checkouts): void + { + $this->validate(['email' => ['required', 'email', 'max:255']]); + $checkouts->setContact($this->checkout(), $this->email); + $this->stepOverride = 2; + } + + public function continueToShippingMethod(CheckoutService $checkouts): void + { + $this->validate([ + 'shipping.first_name' => ['required', 'string', 'max:100'], + 'shipping.last_name' => ['required', 'string', 'max:100'], + 'shipping.address_line_1' => ['required', 'string', 'max:255'], + 'shipping.city' => ['required', 'string', 'max:100'], + 'shipping.country' => ['required', 'string', 'size:2'], + 'shipping.postal_code' => ['required', 'string', 'max:24'], + 'billing.first_name' => [$this->billingSameAsShipping ? 'nullable' : 'required', 'string', 'max:100'], + 'billing.last_name' => [$this->billingSameAsShipping ? 'nullable' : 'required', 'string', 'max:100'], + ]); + + $billing = $this->billingSameAsShipping ? null : $this->billing; + $checkout = $checkouts->setAddress($this->checkout(), $this->email, $this->shipping, $billing); + $this->stepOverride = null; + + if (! $this->requiresShipping($checkout)) { + $this->shippingRateId = null; + $checkout = $checkouts->selectShippingMethod($checkout, null); + $checkouts->selectPaymentMethod($checkout, PaymentMethod::tryFrom($this->paymentMethod) ?? PaymentMethod::CreditCard); + } + } + + public function continueToPayment(CheckoutService $checkouts): void + { + $method = PaymentMethod::tryFrom($this->paymentMethod); + + if (! $method) { + throw ValidationException::withMessages(['paymentMethod' => 'Choose a supported payment method.']); + } + + $checkout = $this->checkout(); + + if ($checkout->status === CheckoutStatus::Addressed->value) { + $checkout = $checkouts->selectShippingMethod($checkout, $this->shippingRateId); + } + + $checkouts->selectPaymentMethod($checkout, $method); + $this->stepOverride = null; + } + + public function editStep(int $step, CheckoutService $checkouts): void + { + $checkout = $this->checkout(); + + if ($step === 1) { + $checkouts->releaseReservations($checkout); + $checkout->forceFill(['status' => CheckoutStatus::Started->value, 'shipping_method_id' => null, 'shipping_amount' => 0, 'payment_method' => null])->save(); + $this->stepOverride = 1; + } elseif ($step === 2 && $checkout->status !== CheckoutStatus::Started->value) { + $checkouts->releaseReservations($checkout); + $checkout->forceFill(['status' => CheckoutStatus::Addressed->value, 'shipping_method_id' => null, 'shipping_amount' => 0, 'payment_method' => null])->save(); + $this->stepOverride = 2; + } elseif ($step === 3 && $checkout->status === CheckoutStatus::PaymentSelected->value) { + $checkouts->releaseReservations($checkout); + $checkout->forceFill(['status' => CheckoutStatus::Addressed->value, 'payment_method' => null])->save(); + $this->stepOverride = 3; + } + + $checkouts->recalculate($checkout->refresh()); + } + + public function applyDiscount(CheckoutService $checkouts): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:64']]); + $checkouts->applyDiscount($this->checkout(), $this->discountCode); + } + + public function updatedPaymentMethod(): void + { + $this->paymentError = null; + } + + public function pay(CheckoutService $checkouts): mixed + { + if ($this->paymentMethod === PaymentMethod::CreditCard->value) { + $this->validate([ + 'cardNumber' => ['required', 'string', 'min:12', 'max:23'], + 'cardholderName' => ['required', 'string', 'max:255'], + 'expiry' => ['required', 'string', 'max:7'], + 'cvc' => ['required', 'string', 'min:3', 'max:4'], + ]); + } + + $method = PaymentMethod::tryFrom($this->paymentMethod); + + if (! $method) { + throw ValidationException::withMessages(['paymentMethod' => 'Choose a supported payment method.']); + } + + $checkout = $this->checkout(); + + if ($checkout->payment_method !== $method->value) { + $checkouts->changePaymentMethod($checkout, $method); + } + + try { + $order = $checkouts->pay($checkout, [ + 'card_number' => $this->cardNumber, + 'cardholder_name' => $this->cardholderName, + 'expiry' => $this->expiry, + 'cvc' => $this->cvc, + ]); + } catch (\App\Exceptions\PaymentDeclinedException $exception) { + $this->paymentError = $exception->getMessage(); + $this->addError('payment', $exception->getMessage()); + + return null; + } + + session()->forget(['cart_id', 'checkout_id']); + $this->dispatchStorefrontAnalytics('checkout_completed', [ + 'order_id' => $order->getKey(), + 'total_amount' => (int) $order->total_amount, + 'currency' => $order->currency, + ]); + + return $this->redirect(URL::signedRoute('storefront.confirmation', ['checkoutId' => $order->checkout_id])); + } + + public function render(CheckoutService $checkouts): mixed + { + $checkout = $checkouts->recalculate($this->checkout()); + + return view('storefront.checkout.index', [ + 'checkout' => $checkout, + 'checkoutStep' => $this->getCheckoutStepProperty(), + 'shippingRates' => $this->checkoutStep === 3 ? $checkouts->availableShippingRates($checkout) : collect(), + ])->layout('storefront.layouts.app'); + } + + private function checkout(): CheckoutModel + { + return CheckoutModel::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->findOrFail($this->checkoutId); + } + + /** @param array $address + * @return array + */ + private function addressForForm(array $address): array + { + return [ + 'first_name' => (string) ($address['first_name'] ?? ''), + 'last_name' => (string) ($address['last_name'] ?? ''), + 'address_line_1' => (string) ($address['address_line_1'] ?? $address['address1'] ?? ''), + 'address_line_2' => (string) ($address['address_line_2'] ?? $address['address2'] ?? ''), + 'city' => (string) ($address['city'] ?? ''), + 'state' => (string) ($address['state'] ?? $address['province'] ?? ''), + 'country' => strtoupper((string) ($address['country'] ?? $address['country_code'] ?? 'DE')), + 'postal_code' => (string) ($address['postal_code'] ?? $address['zip'] ?? ''), + 'phone' => (string) ($address['phone'] ?? ''), + ]; + } + + private function requiresShipping(CheckoutModel $checkout): bool + { + $checkout->loadMissing('cart.lines.variant'); + + return $checkout->cart->lines->contains(static fn ($line): bool => (bool) $line->variant?->requires_shipping); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..4a7a941d --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,16 @@ + Collection::query()->where('status', 'active')->withCount(['products' => fn ($query) => $query->where('status', 'active')->whereNotNull('published_at')])->orderBy('title')->paginate(12), + ])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..825f6a72 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,157 @@ + */ + public array $selectedTypes = []; + + /** @var list */ + public array $selectedVendors = []; + + public function mount(string $handle): void + { + $this->handle = $handle; + } + + public function updated(string $property): void + { + if (in_array($property, ['sort', 'inStock', 'minPrice', 'maxPrice', 'selectedTypes', 'selectedVendors'], true)) { + $this->resetPage(); + } + } + + public function clearFilters(): void + { + $this->reset(['inStock', 'minPrice', 'maxPrice', 'selectedTypes', 'selectedVendors']); + $this->resetPage(); + } + + public function removeFilter(string $key): void + { + [$type, $value] = array_pad(explode(':', $key, 2), 2, ''); + + match ($type) { + 'type' => $this->selectedTypes = array_values(array_diff($this->selectedTypes, [$value])), + 'vendor' => $this->selectedVendors = array_values(array_diff($this->selectedVendors, [$value])), + 'stock' => $this->inStock = false, + 'min' => $this->minPrice = '', + 'max' => $this->maxPrice = '', + default => null, + }; + + $this->resetPage(); + } + + public function quickAdd(int $variantId, CartService $carts): void + { + $variant = ProductVariant::query()->whereHas('product', fn ($query) => $query->where('status', 'active')->whereNotNull('published_at'))->findOrFail($variantId); + $carts->add($carts->getOrCreateActiveCart(app('current_store'), auth('customer')->user()), $variant, 1); + $this->dispatchStorefrontAnalytics('add_to_cart', [ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'price_amount' => (int) $variant->price_amount, + 'currency' => $variant->currency, + ]); + $this->dispatch('cart-updated'); + $this->dispatch('open-cart-drawer'); + } + + public function render(): mixed + { + $collection = Collection::query()->where('handle', $this->handle)->where('status', 'active')->firstOrFail(); + $baseQuery = $collection->products()->where('status', 'active')->whereNotNull('published_at'); + $productTypes = (clone $baseQuery)->whereNotNull('product_type')->distinct()->orderBy('product_type')->pluck('product_type')->all(); + $vendors = (clone $baseQuery)->whereNotNull('vendor')->distinct()->orderBy('vendor')->pluck('vendor')->all(); + $products = (clone $baseQuery)->with(['variants.inventoryItem', 'media']); + + if ($this->selectedTypes !== []) { + $products->whereIn('product_type', $this->selectedTypes); + } + + if ($this->selectedVendors !== []) { + $products->whereIn('vendor', $this->selectedVendors); + } + + if ($this->inStock) { + $products->whereHas('variants.inventoryItem', fn ($query) => $query->where(function ($availability): void { + $availability->where('policy', 'continue')->orWhereRaw('quantity_on_hand > quantity_reserved'); + })); + } + + $products->when($this->minPrice !== '', fn ($query) => $query->whereHas('variants', fn ($variants) => $variants->where('price_amount', '>=', $this->minorUnits($this->minPrice)))); + $products->when($this->maxPrice !== '', fn ($query) => $query->whereHas('variants', fn ($variants) => $variants->where('price_amount', '<=', $this->minorUnits($this->maxPrice)))); + + match ($this->sort) { + 'price_asc' => $products->withMin('variants', 'price_amount')->orderBy('variants_min_price_amount'), + 'price_desc' => $products->withMin('variants', 'price_amount')->orderByDesc('variants_min_price_amount'), + 'newest' => $products->latest('published_at'), + 'best_selling' => $products->orderByDesc('id'), + default => $products->orderByPivot('position')->orderBy('title'), + }; + $activeFilters = $this->activeFilters(); + + return view('storefront.collection', [ + 'collection' => $collection, + 'products' => $products->paginate(12), + 'productTypes' => $productTypes, + 'vendors' => $vendors, + 'activeFilters' => $activeFilters, + ])->layout('storefront.layouts.app'); + } + + /** @return list */ + private function activeFilters(): array + { + $filters = []; + + foreach ($this->selectedTypes as $type) { + $filters[] = ['key' => 'type:'.$type, 'label' => $type]; + } + + foreach ($this->selectedVendors as $vendor) { + $filters[] = ['key' => 'vendor:'.$vendor, 'label' => $vendor]; + } + + if ($this->inStock) { + $filters[] = ['key' => 'stock', 'label' => 'In stock']; + } + + if ($this->minPrice !== '') { + $filters[] = ['key' => 'min', 'label' => 'Min '.$this->minPrice]; + } + + if ($this->maxPrice !== '') { + $filters[] = ['key' => 'max', 'label' => 'Max '.$this->maxPrice]; + } + + return $filters; + } + + private function minorUnits(string $amount): int + { + return (int) round((float) $amount * 100); + } +} diff --git a/app/Livewire/Storefront/Concerns/DispatchesStorefrontAnalytics.php b/app/Livewire/Storefront/Concerns/DispatchesStorefrontAnalytics.php new file mode 100644 index 00000000..6a5bf495 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/DispatchesStorefrontAnalytics.php @@ -0,0 +1,12 @@ + $properties */ + protected function dispatchStorefrontAnalytics(string $eventType, array $properties = []): void + { + $this->dispatch('storefront-analytics', type: $eventType, properties: $properties); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..6f3cfcf3 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,60 @@ +ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('email', 'Please wait before trying again.'); + + return; + } + + RateLimiter::hit($key, 60); + $this->validate(['email' => ['required', 'email', 'max:255']]); + $now = now(); + + DB::table('newsletter_subscriptions')->upsert([[ + 'store_id' => app('current_store')->id, + 'email' => mb_strtolower(trim($this->email)), + 'source' => 'storefront', + 'subscribed_at' => $now, + 'unsubscribed_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]], ['store_id', 'email'], ['subscribed_at', 'unsubscribed_at', 'updated_at']); + + if ($customer = auth('customer')->user()) { + $customer->forceFill(['marketing_opt_in' => true])->save(); + } + + $this->reset('email'); + RateLimiter::clear($key); + session()->flash('newsletter-success', 'Thanks for subscribing.'); + } + + public function render(): mixed + { + $settings = app()->bound('current_store') ? app('current_store')->theme_settings : []; + + return view('storefront.home', [ + 'collections' => Collection::query()->where('status', 'active')->withCount('products')->orderBy('title')->get(), + 'featuredProducts' => Product::query()->where('status', 'active')->whereNotNull('published_at')->with(['variants.inventoryItem', 'media'])->latest('published_at')->limit(8)->get(), + 'settings' => $settings, + 'safeRichText' => app(HtmlSanitizer::class)->sanitize(data_get($settings, 'home.rich_text.html')), + ])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Navigation/Menu.php b/app/Livewire/Storefront/Navigation/Menu.php new file mode 100644 index 00000000..957d80ce --- /dev/null +++ b/app/Livewire/Storefront/Navigation/Menu.php @@ -0,0 +1,207 @@ +bound('current_store') && $this->storeId !== null) { + app()->instance('current_store', Store::query()->findOrFail($this->storeId)); + } + } + + public function mount(string $handle, string $presentation): void + { + abort_unless(in_array($handle, ['main-menu', 'footer-menu'], true), 404); + abort_unless(in_array($presentation, ['mobile', 'desktop', 'footer'], true), 404); + + $this->handle = $handle; + $this->presentation = $presentation; + $this->storeId = $this->currentStore()->id; + } + + public function render(): mixed + { + $store = $this->currentStore(); + $menu = NavigationMenu::query() + ->where('store_id', $store->id) + ->where('handle', $this->handle) + ->with(['items' => fn ($query) => $query->whereNull('parent_id')->with('children')->orderBy('position')]) + ->first(); + + if ($menu === null) { + $items = $this->themeMenu($store); + $columns = $this->themeFooterColumns($store); + } else { + $savedItems = $menu->items->flatMap(fn (NavigationItem $item): array => [$item, ...$item->children->all()]); + $resources = $this->resolveResources($savedItems, $store); + $items = $menu->items->map(fn (NavigationItem $item): array => $this->mapItem($item, $resources))->all(); + $columns = $this->footerColumns($menu->title, $items); + } + + return view('livewire.storefront.navigation.menu', [ + 'items' => $items, + 'columns' => $columns, + ]); + } + + private function currentStore(): Store + { + $store = app()->bound('current_store') ? app('current_store') : null; + + abort_unless($store instanceof Store, 404); + + return $store; + } + + /** @return array> */ + private function themeMenu(Store $store): array + { + $settings = $store->theme_settings; + $legacyKey = $this->handle === 'main-menu' ? 'main_menu' : 'footer_menu'; + $items = data_get($settings, 'navigation.'.$this->handle, data_get($settings, $legacyKey, [])); + + return is_array($items) ? $items : []; + } + + /** @return array>}> */ + private function themeFooterColumns(Store $store): array + { + $items = $this->themeMenu($store); + + return array_slice(array_values(array_filter(array_map(function (array $item): ?array { + $links = data_get($item, 'children', data_get($item, 'links', [])); + + if (! is_array($links)) { + return null; + } + + return [ + 'label' => data_get($item, 'label', data_get($item, 'title', 'Explore')), + 'children' => $links, + ]; + }, $items))), 0, 3); + } + + /** @param SupportCollection $items + * @return array> + */ + private function resolveResources(SupportCollection $items, Store $store): array + { + $idsByType = $items->filter(fn (NavigationItem $item): bool => $item->type !== 'link' && $item->resource_id !== null) + ->groupBy('type') + ->map(fn (SupportCollection $records): array => $records->pluck('resource_id')->map(fn ($id): int => (int) $id)->all()); + + $pageIds = $idsByType->get('page', []); + $collectionIds = $idsByType->get('collection', []); + $productIds = $idsByType->get('product', []); + + return [ + 'page' => $pageIds === [] + ? new EloquentCollection + : Page::query()->where('store_id', $store->id)->where('status', 'published')->whereIn('id', $pageIds)->get(['id', 'handle']), + 'collection' => $collectionIds === [] + ? new EloquentCollection + : Collection::query()->where('store_id', $store->id)->where('status', 'active')->whereIn('id', $collectionIds)->get(['id', 'handle']), + 'product' => $productIds === [] + ? new EloquentCollection + : Product::query()->where('store_id', $store->id)->where('status', 'active')->whereNotNull('published_at')->whereIn('id', $productIds)->get(['id', 'handle']), + ]; + } + + /** @param array> $resources + * @return array{label: string, url: string, children: array} + */ + private function mapItem(NavigationItem $item, array $resources): array + { + $children = $item->children->map(fn (NavigationItem $child): array => [ + 'label' => $child->label, + 'url' => $this->itemUrl($child, $resources), + ])->all(); + + return [ + 'label' => $item->label, + 'url' => $this->itemUrl($item, $resources), + 'children' => $children, + ]; + } + + /** @param array> $resources */ + private function itemUrl(NavigationItem $item, array $resources): string + { + if ($item->type === 'link') { + return $this->isAllowedNavigationUrl((string) $item->url) ? (string) $item->url : '#'; + } + + $resource = ($resources[$item->type] ?? collect())->firstWhere('id', (int) $item->resource_id); + + if (! $resource) { + return '#'; + } + + return match ($item->type) { + 'page' => route('storefront.page', ['handle' => $resource->handle]), + 'collection' => route('storefront.collection', ['handle' => $resource->handle]), + 'product' => route('storefront.product', ['handle' => $resource->handle]), + default => '#', + }; + } + + /** @param array> $items + * @return array>}> + */ + private function footerColumns(string $menuTitle, array $items): array + { + $columns = []; + $ungroupedLinks = []; + + foreach ($items as $item) { + if ($item['children'] !== []) { + $columns[] = ['label' => $item['label'], 'children' => $item['children']]; + } else { + $ungroupedLinks[] = ['label' => $item['label'], 'url' => $item['url']]; + } + } + + if ($ungroupedLinks !== []) { + array_unshift($columns, ['label' => $menuTitle, 'children' => $ungroupedLinks]); + } + + return array_slice($columns, 0, 3); + } + + private function isAllowedNavigationUrl(string $url): bool + { + if ($url === '' || preg_match('/[\x00-\x20\\\\]/', $url) === 1) { + return false; + } + + if (str_starts_with($url, '/') && ! str_starts_with($url, '//')) { + return true; + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + return in_array(strtolower((string) $scheme), ['http', 'https'], true) && filter_var($url, FILTER_VALIDATE_URL) !== false; + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..48506a60 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,23 @@ +where('handle', request()->route('handle')) + ->where('status', 'published') + ->whereNotNull('published_at') + ->firstOrFail(); + + $safeHtml = app(HtmlSanitizer::class)->sanitize($page->body_html); + + return view('storefront.page', ['page' => $page, 'safeHtml' => $safeHtml])->layout('storefront.layouts.app'); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..c07bc788 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,178 @@ + */ + public array $selectedOptions = []; + + public ?int $selectedVariantId = null; + + public int $selectedImageIndex = 0; + + public int $quantity = 1; + + public function mount(string $handle): void + { + $this->handle = $handle; + $default = $this->productModel()->variants->firstWhere('is_default', true) ?? $this->productModel()->variants->first(); + + if ($default) { + $this->selectedVariantId = $default->id; + + foreach ($default->optionValues as $value) { + $this->selectedOptions[$value->option->name] = $value->value; + } + } + } + + public function updatedSelectedOptions(): void + { + $this->selectedVariantId = $this->selectedVariant()?->id; + } + + public function selectImage(int $index): void + { + $this->selectedImageIndex = min(max(0, $index), max(0, $this->productModel()->media->count() - 1)); + } + + public function decreaseQuantity(): void + { + $this->quantity = max(1, $this->quantity - 1); + } + + public function increaseQuantity(): void + { + $this->quantity = min(9999, $this->quantity + 1); + } + + public function addToCart(CartService $carts): void + { + Validator::make(['quantity' => $this->quantity], ['quantity' => ['required', 'integer', 'min:1', 'max:9999']])->validate(); + $variant = $this->selectedVariant(); + + if (! $variant) { + $this->addError('variant', 'Choose an available product option.'); + + return; + } + + $carts->add($carts->getOrCreateActiveCart(app('current_store'), auth('customer')->user()), $variant, $this->quantity); + $this->dispatchStorefrontAnalytics('add_to_cart', [ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'quantity' => $this->quantity, + 'price_amount' => (int) $variant->price_amount, + 'currency' => $variant->currency, + ]); + $this->dispatch('cart-updated'); + $this->dispatch('open-cart-drawer'); + session()->flash('status', 'Added to cart.'); + } + + public function quickAdd(int $variantId, CartService $carts): void + { + $variant = ProductVariant::query()->whereHas('product', fn ($query) => $query->where('status', 'active')->whereNotNull('published_at'))->findOrFail($variantId); + $carts->add($carts->getOrCreateActiveCart(app('current_store'), auth('customer')->user()), $variant, 1); + $this->dispatchStorefrontAnalytics('add_to_cart', [ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'price_amount' => (int) $variant->price_amount, + 'currency' => $variant->currency, + ]); + $this->dispatch('cart-updated'); + $this->dispatch('open-cart-drawer'); + } + + public function product(): array + { + $product = $this->productModel(); + $selectedVariant = $this->selectedVariant(); + $variants = $product->variants->map(function (ProductVariant $variant): array { + $item = $variant->inventoryItem; + $data = $variant->toArray(); + $data['inventory_quantity'] = $item ? max(0, $item->quantity_on_hand - $item->quantity_reserved) : null; + $data['inventory_policy'] = $item?->policy ?? 'deny'; + $data['title'] = $variant->title; + + return $data; + }); + $optionRows = $product->options->map(fn ($option): array => [ + 'name' => $option->name, + 'values' => $option->values->pluck('value')->all(), + ]); + $media = $product->media->map(fn ($image): array => [ + 'id' => $image->id, + 'url' => $image->url, + 'thumbnail_url' => $image->thumbnail_url, + 'alt' => $image->alt_text, + ]); + $available = $variants->filter(fn (array $variant): bool => $variant['inventory_quantity'] === null || $variant['inventory_quantity'] > 0 || $variant['inventory_policy'] === 'continue'); + + return [ + ...$product->toArray(), + 'options' => $optionRows->all(), + 'variants' => $variants->all(), + 'media' => $media->all(), + 'description_html' => app(HtmlSanitizer::class)->sanitize($product->description_html), + 'price_amount' => (int) ($selectedVariant?->price_amount ?? $product->price_amount), + 'compare_at_amount' => $selectedVariant?->compare_at_amount ?? $product->compare_at_amount, + 'currency' => $selectedVariant?->currency ?? $product->currency, + 'sold_out' => $available->isEmpty(), + 'inventory_quantity' => $selectedVariant?->inventoryItem ? max(0, $selectedVariant->inventoryItem->quantity_on_hand - $selectedVariant->inventoryItem->quantity_reserved) : null, + 'inventory_policy' => $selectedVariant?->inventoryItem?->policy ?? 'deny', + ]; + } + + public function render(): mixed + { + return view('storefront.product', ['product' => $this->product(), 'selectedVariant' => $this->selectedVariant()])->layout('storefront.layouts.app'); + } + + private function productModel(): Product + { + return Product::query() + ->where('handle', $this->handle) + ->where('status', 'active') + ->whereNotNull('published_at') + ->with(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'media', 'collections']) + ->firstOrFail(); + } + + private function selectedVariant(): ?ProductVariant + { + $product = $this->productModel(); + + if ($this->selectedVariantId !== null) { + $selected = $product->variants->firstWhere('id', $this->selectedVariantId); + + if ($selected) { + return $selected; + } + } + + foreach ($product->variants as $variant) { + $values = $variant->optionValues->mapWithKeys(fn ($value): array => [$value->option->name => $value->value])->all(); + + if ($values === $this->selectedOptions) { + return $variant; + } + } + + return $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..88e7f68b --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,213 @@ + */ + public array $selectedTypes = []; + + /** @var list */ + public array $selectedVendors = []; + + public function mount(): void + { + $this->query = trim((string) request()->query('q', '')); + $this->recordSearch(); + } + + public function updated(string $property): void + { + if (in_array($property, ['query', 'sort', 'inStock', 'minPrice', 'maxPrice', 'selectedTypes', 'selectedVendors'], true)) { + $this->resetPage(); + } + + if ($property === 'query') { + $this->recordSearch(); + } + } + + public function clearFilters(): void + { + $this->reset(['inStock', 'minPrice', 'maxPrice', 'selectedTypes', 'selectedVendors']); + $this->resetPage(); + } + + public function removeFilter(string $key): void + { + [$type, $value] = array_pad(explode(':', $key, 2), 2, ''); + + match ($type) { + 'type' => $this->selectedTypes = array_values(array_diff($this->selectedTypes, [$value])), + 'vendor' => $this->selectedVendors = array_values(array_diff($this->selectedVendors, [$value])), + 'stock' => $this->inStock = false, + 'min' => $this->minPrice = '', + 'max' => $this->maxPrice = '', + default => null, + }; + + $this->resetPage(); + } + + public function quickAdd(int $variantId, CartService $carts): void + { + $variant = ProductVariant::query()->whereHas('product', fn ($builder) => $builder->where('status', 'active')->whereNotNull('published_at'))->findOrFail($variantId); + $carts->add($carts->getOrCreateActiveCart(app('current_store'), auth('customer')->user()), $variant, 1); + $this->dispatchStorefrontAnalytics('add_to_cart', [ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'price_amount' => (int) $variant->price_amount, + 'currency' => $variant->currency, + ]); + $this->dispatch('cart-updated'); + $this->dispatch('open-cart-drawer'); + } + + public function render(SearchService $search): mixed + { + $active = Product::query()->where('status', 'active')->whereNotNull('published_at'); + $productTypes = (clone $active)->whereNotNull('product_type')->distinct()->orderBy('product_type')->pluck('product_type')->all(); + $vendors = (clone $active)->whereNotNull('vendor')->distinct()->orderBy('vendor')->pluck('vendor')->all(); + $products = (clone $active)->with(['variants.inventoryItem', 'media']); + + $rankedProductIds = []; + + if ($this->query !== '') { + $escaped = addcslashes($this->query, '%_\\'); + $expression = $search->fullTextExpression((int) app('current_store')->id, $this->query); + $rankedProductIds = $search->rankedProductIds((int) app('current_store')->id, $expression, 5000); + $products->where(function ($builder) use ($rankedProductIds, $expression, $escaped): void { + if ($rankedProductIds !== []) { + $builder->whereIn('id', $rankedProductIds) + ->orWhere('title', 'like', '%'.$escaped.'%') + ->orWhere('description_html', 'like', '%'.$escaped.'%') + ->orWhere('vendor', 'like', '%'.$escaped.'%') + ->orWhere('tags', 'like', '%'.$escaped.'%'); + } elseif ($expression === '') { + $builder->whereRaw('1 = 0'); + } else { + $builder->where('title', 'like', '%'.$escaped.'%') + ->orWhere('description_html', 'like', '%'.$escaped.'%') + ->orWhere('vendor', 'like', '%'.$escaped.'%') + ->orWhere('tags', 'like', '%'.$escaped.'%'); + } + }); + } + + if ($this->selectedTypes !== []) { + $products->whereIn('product_type', $this->selectedTypes); + } + + if ($this->selectedVendors !== []) { + $products->whereIn('vendor', $this->selectedVendors); + } + + if ($this->inStock) { + $products->whereHas('variants.inventoryItem', fn ($builder) => $builder->where(function ($availability): void { + $availability->where('policy', 'continue')->orWhereRaw('quantity_on_hand > quantity_reserved'); + })); + } + + $products->when($this->minPrice !== '', fn ($builder) => $builder->whereHas('variants', fn ($variants) => $variants->where('price_amount', '>=', $this->minorUnits($this->minPrice)))); + $products->when($this->maxPrice !== '', fn ($builder) => $builder->whereHas('variants', fn ($variants) => $variants->where('price_amount', '<=', $this->minorUnits($this->maxPrice)))); + + match ($this->sort) { + 'price_asc' => $products->withMin('variants', 'price_amount')->orderBy('variants_min_price_amount'), + 'price_desc' => $products->withMin('variants', 'price_amount')->orderByDesc('variants_min_price_amount'), + 'newest' => $products->latest('published_at'), + default => $this->orderByRelevance($products, $rankedProductIds), + }; + + return view('storefront.search', [ + 'query' => $this->query, + 'products' => $products->paginate(12), + 'productTypes' => $productTypes, + 'vendors' => $vendors, + 'activeFilters' => $this->activeFilters(), + ])->layout('storefront.layouts.app'); + } + + private function recordSearch(): void + { + if (trim($this->query) !== '') { + $query = mb_substr(trim($this->query), 0, 255); + SearchQuery::create(['query' => $query, 'session_id' => session()->getId()]); + $this->dispatchStorefrontAnalytics('search', ['query' => $query]); + } + } + + /** @return list */ + private function activeFilters(): array + { + $filters = []; + + foreach ($this->selectedTypes as $type) { + $filters[] = ['key' => 'type:'.$type, 'label' => $type]; + } + + foreach ($this->selectedVendors as $vendor) { + $filters[] = ['key' => 'vendor:'.$vendor, 'label' => $vendor]; + } + + if ($this->inStock) { + $filters[] = ['key' => 'stock', 'label' => 'In stock']; + } + + if ($this->minPrice !== '') { + $filters[] = ['key' => 'min', 'label' => 'Min '.$this->minPrice]; + } + + if ($this->maxPrice !== '') { + $filters[] = ['key' => 'max', 'label' => 'Max '.$this->maxPrice]; + } + + return $filters; + } + + private function minorUnits(string $amount): int + { + return (int) round((float) $amount * 100); + } + + /** @param list $rankedProductIds */ + private function orderByRelevance(\Illuminate\Database\Eloquent\Builder $query, array $rankedProductIds): void + { + $rankedProductIds = array_values(array_unique(array_map('intval', $rankedProductIds))); + + if ($rankedProductIds === []) { + $query->orderBy('title'); + + return; + } + + $rankCases = collect($rankedProductIds) + ->map(static fn (int $productId, int $position): string => "WHEN {$productId} THEN {$position}") + ->implode(' '); + + $query->orderByRaw('CASE products.id '.$rankCases.' ELSE '.count($rankedProductIds).' END') + ->orderBy('title'); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..f2a7f2ce --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,59 @@ +isOpen = true; + } + + public function close(): void + { + $this->isOpen = false; + $this->query = ''; + } + + public function search(): mixed + { + $query = trim($this->query); + + return $this->redirect(route('storefront.search', ['q' => $query])); + } + + public function render(): mixed + { + $query = trim($this->query); + $products = collect(); + $collections = collect(); + + if ($query !== '') { + $needle = addcslashes($query, '%_\\'); + $products = Product::query() + ->where('status', 'active') + ->whereNotNull('published_at') + ->where(fn ($builder) => $builder->where('title', 'like', '%'.$needle.'%')->orWhere('description_html', 'like', '%'.$needle.'%')) + ->with(['variants.inventoryItem', 'media']) + ->limit(5) + ->get(); + $collections = Collection::query() + ->where('status', 'active') + ->where('title', 'like', '%'.$needle.'%') + ->limit(3) + ->get(); + } + + return view('storefront.components.search-modal', compact('products', 'collections')); + } +} diff --git a/app/Logging/CustomizeAuditFormatter.php b/app/Logging/CustomizeAuditFormatter.php new file mode 100644 index 00000000..5684eb00 --- /dev/null +++ b/app/Logging/CustomizeAuditFormatter.php @@ -0,0 +1,16 @@ +getHandlers() as $handler) { + $handler->setFormatter(new JsonFormatter); + } + } +} diff --git a/app/Mail/CustomerOrderNotification.php b/app/Mail/CustomerOrderNotification.php new file mode 100644 index 00000000..542c86e3 --- /dev/null +++ b/app/Mail/CustomerOrderNotification.php @@ -0,0 +1,61 @@ + $details */ + public function __construct( + public Order $order, + public string $notificationType, + public array $details = [], + ) { + $this->order->loadMissing('lines'); + } + + public function envelope(): Envelope + { + return new Envelope(subject: $this->subjectForNotification()); + } + + public function content(): Content + { + return new Content( + view: 'mail.orders.customer-notification', + with: [ + 'order' => $this->order, + 'notificationType' => $this->notificationType, + 'details' => $this->details, + ], + ); + } + + private function subjectForNotification(): string + { + return match ($this->notificationType) { + self::ORDER_CONFIRMATION => "Order {$this->order->order_number} confirmed", + self::REFUND => "Refund processed for order {$this->order->order_number}", + self::SHIPPED => "Order {$this->order->order_number} has shipped", + self::CANCELLED => "Order {$this->order->order_number} cancelled", + default => "Update for order {$this->order->order_number}", + }; + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..b96b15da --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,33 @@ + 'date:Y-m-d']; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..41930816 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,52 @@ +getAttribute('properties_json') === null && $event->getAttribute('payload') !== null) { + $event->setAttribute('properties_json', $event->getAttribute('payload')); + } + + if ($event->getAttribute('payload') === null && $event->getAttribute('properties_json') !== null) { + $event->setAttribute('payload', $event->getAttribute('properties_json')); + } + + if ($event->getAttribute('occurred_at') === null) { + $event->setAttribute('occurred_at', $event->getAttribute('created_at') ?? now()); + } + }); + } + + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'payload' => 'array', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/AnalyticsExport.php b/app/Models/AnalyticsExport.php new file mode 100644 index 00000000..a3b3293a --- /dev/null +++ b/app/Models/AnalyticsExport.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + protected $fillable = [ + 'store_id', 'requested_by_user_id', 'from_date', 'to_date', 'channel', 'device', 'status', + 'storage_key', 'error_message', 'completed_at', + ]; + + protected function casts(): array + { + return [ + 'from_date' => 'date:Y-m-d', + 'to_date' => 'date:Y-m-d', + 'completed_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function requestedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by_user_id'); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..c8f42bcf --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,47 @@ + (int) $this->lines->sum('line_subtotal_amount')); + } + + protected function discountAmount(): Attribute + { + return Attribute::get(fn (): int => (int) $this->lines->sum('line_discount_amount')); + } + + protected function totalAmount(): Attribute + { + return Attribute::get(fn (): int => max(0, $this->subtotal_amount - $this->discount_amount)); + } + + protected function itemCount(): Attribute + { + return Attribute::get(fn (): int => (int) $this->lines->sum('quantity')); + } + + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..e04eb42a --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,44 @@ +belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + protected function title(): Attribute + { + return Attribute::get(fn (): ?string => $this->variant?->product?->title); + } + + protected function product(): Attribute + { + return Attribute::get(fn (): ?Product => $this->variant?->product); + } + + protected function variantTitle(): Attribute + { + return Attribute::get(fn (): ?string => $this->variant?->title); + } + + protected function priceAmount(): Attribute + { + return Attribute::get(fn (): int => (int) $this->unit_price_amount); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..98f18e51 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,50 @@ + 'array', 'billing_address_json' => 'array', 'totals_json' => 'array', 'tax_provider_snapshot_json' => 'array', 'expires_at' => 'datetime', 'completed_at' => 'datetime']; + } + + protected function shippingAddress(): Attribute + { + return Attribute::get(fn (): array => $this->shipping_address_json ?? []); + } + + protected function billingAddress(): Attribute + { + return Attribute::get(fn (): array => $this->billing_address_json ?? []); + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function shippingMethod(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..d2889f18 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,19 @@ +belongsToMany(Product::class, 'collection_products')->withPivot('position')->orderByPivot('position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..40313817 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,23 @@ +bound('current_store') ? app('current_store') : null; + + if ($store instanceof Store && blank($model->getAttribute('store_id'))) { + $model->setAttribute('store_id', $store->getKey()); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..79ca2da5 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,33 @@ + 'hashed', 'marketing_opt_in' => 'boolean']; + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..cbb694c5 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,23 @@ + '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..5ddd151a --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,25 @@ + 'datetime', 'ends_at' => 'datetime', 'is_active' => 'boolean', 'rules_json' => 'array']; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..73e3fa23 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,29 @@ + 'datetime', 'delivered_at' => 'datetime', 'created_at' => 'datetime']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..aded13a1 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,18 @@ +belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..d05fa43b --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,22 @@ +belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..fa4ea67f --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,27 @@ +belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('position'); + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..12c5f9d9 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,19 @@ +hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..1e906f85 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,61 @@ + 'array', 'shipping_address_json' => 'array', 'placed_at' => 'datetime']; + } + + protected function shippingAddress(): Attribute + { + return Attribute::get(fn (): array => $this->shipping_address_json ?? []); + } + + protected function billingAddress(): Attribute + { + return Attribute::get(fn (): array => $this->billing_address_json ?? []); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/OrderExport.php b/app/Models/OrderExport.php new file mode 100644 index 00000000..1dcb655c --- /dev/null +++ b/app/Models/OrderExport.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + protected $fillable = [ + 'store_id', 'requested_by_user_id', 'format', 'filters_json', 'status', 'row_count', + 'storage_key', 'error_message', 'completed_at', + ]; + + protected function casts(): array + { + return [ + 'filters_json' => 'array', + 'row_count' => 'integer', + 'completed_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function requestedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by_user_id'); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..dca32414 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,44 @@ + 'array', 'discount_allocations_json' => 'array']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + public function refundLines(): HasMany + { + return $this->hasMany(RefundLine::class); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..ecc17e4f --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,19 @@ +hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..b66a89ed --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,18 @@ + 'datetime']; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..78cdf1a6 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,23 @@ + 'datetime']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php new file mode 100644 index 00000000..3eed395f --- /dev/null +++ b/app/Models/PersonalAccessToken.php @@ -0,0 +1,29 @@ + 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime']; + } + + public function tokenable(): MorphTo + { + return $this->morphTo(); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..a2cc3d45 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,105 @@ + 'array', 'published_at' => 'datetime']; + } + + protected function priceAmount(): Attribute + { + return Attribute::get(function (): int { + $variants = $this->relationLoaded('variants') ? $this->variants : $this->variants()->get(); + + return (int) ($variants->min('price_amount') ?? 0); + }); + } + + protected function compareAtAmount(): Attribute + { + return Attribute::get(function (): ?int { + $variants = $this->relationLoaded('variants') ? $this->variants : $this->variants()->get(); + $amount = $variants->max('compare_at_amount'); + + return $amount === null ? null : (int) $amount; + }); + } + + protected function currency(): Attribute + { + return Attribute::get(function (): string { + $variant = $this->relationLoaded('variants') ? $this->variants->first() : $this->variants()->first(); + + return $variant?->currency ?? (app()->bound('current_store') ? app('current_store')->default_currency : 'USD'); + }); + } + + protected function soldOut(): Attribute + { + return Attribute::get(function (): bool { + $variants = $this->relationLoaded('variants') ? $this->variants : $this->variants()->with('inventoryItem')->get(); + + return $variants->isNotEmpty() && $variants->every(fn (ProductVariant $variant): bool => $variant->inventoryItem && $variant->inventoryItem->policy !== 'continue' && $variant->inventoryItem->quantity_on_hand <= $variant->inventoryItem->quantity_reserved); + }); + } + + protected function description(): Attribute + { + return Attribute::get(fn (): ?string => $this->description_html); + } + + protected static function booted(): void + { + static::saved(function (Product $product): void { + DB::table('products_fts')->where('product_id', (string) $product->id)->delete(); + DB::table('products_fts')->insert([ + 'store_id' => (string) $product->store_id, + 'product_id' => (string) $product->id, + 'title' => $product->title, + 'description' => strip_tags((string) $product->description_html), + 'vendor' => (string) $product->vendor, + 'product_type' => (string) $product->product_type, + 'tags' => implode(' ', $product->tags ?? []), + ]); + }); + + static::deleted(function (Product $product): void { + DB::table('products_fts')->where('product_id', (string) $product->id)->delete(); + }); + } + + 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'); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..03341b81 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,67 @@ +delete($media->storage_key); + $disk->deleteDirectory("media/{$media->product_id}/{$media->id}"); + }); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + protected function url(): Attribute + { + return Attribute::get(function (): string { + if (filter_var($this->storage_key, FILTER_VALIDATE_URL)) { + return $this->storage_key; + } + + return Storage::disk('public')->url($this->storage_key); + }); + } + + protected function alt(): Attribute + { + return Attribute::get(fn (): ?string => $this->alt_text); + } + + protected function thumbnailUrl(): Attribute + { + return Attribute::get(function (): string { + if ($this->type !== 'image' || $this->status !== 'ready') { + return $this->url; + } + + $directory = "media/{$this->product_id}/{$this->id}"; + $extension = strtolower(pathinfo($this->storage_key, PATHINFO_EXTENSION)); + $webp = "{$directory}/thumbnail.webp"; + $originalFormat = "{$directory}/thumbnail.{$extension}"; + + if (Storage::disk('public')->exists($webp)) { + return Storage::disk('public')->url($webp); + } + + return Storage::disk('public')->exists($originalFormat) + ? Storage::disk('public')->url($originalFormat) + : $this->url; + }); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..3a185eab --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,24 @@ +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..1ed97dc3 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,18 @@ +belongsTo(ProductOption::class, 'product_option_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..67a5ef75 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,48 @@ + 'boolean', 'is_default' => 'boolean']; + } + + protected function title(): Attribute + { + return Attribute::get(function (): string { + $values = $this->relationLoaded('optionValues') + ? $this->optionValues + : $this->optionValues()->with('option')->get(); + + return $values->isEmpty() ? 'Default Title' : $values->sortBy(static fn (ProductOptionValue $value): int => ((int) ($value->option?->position ?? 0) * 10000) + $value->position)->pluck('value')->implode(' / '); + }); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..b1782955 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,34 @@ + 'datetime']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } + + public function lines(): HasMany + { + return $this->hasMany(RefundLine::class); + } +} diff --git a/app/Models/RefundLine.php b/app/Models/RefundLine.php new file mode 100644 index 00000000..20ef84ee --- /dev/null +++ b/app/Models/RefundLine.php @@ -0,0 +1,23 @@ +belongsTo(Refund::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..c3efb030 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,20 @@ +bound('current_store') ? app('current_store') : null; + + if ($store instanceof Store) { + $builder->where($model->qualifyColumn('store_id'), $store->getKey()); + } + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..d340b645 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,13 @@ + '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..565555bf --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,25 @@ + 'array', 'regions' => 'array', 'is_active' => 'boolean']; + } + + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..9ff0f0b8 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,63 @@ +belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users')->withPivot('role'); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function activeTheme(): HasOne + { + return $this->hasOne(Theme::class)->where('is_active', true); + } + + protected function currency(): Attribute + { + return Attribute::get(fn (): string => $this->default_currency); + } + + protected function email(): Attribute + { + return Attribute::get(fn (): ?string => $this->organization?->billing_email); + } + + protected function themeSettings(): Attribute + { + return Attribute::get(fn (): array => $this->activeTheme?->settings?->settings_json ?? $this->settings?->settings_json ?? []); + } + + protected function logoUrl(): Attribute + { + return Attribute::get(fn (): ?string => data_get($this->theme_settings, 'logo_url')); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..c2361484 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,26 @@ + 'boolean', 'created_at' => 'datetime']; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreInvitation.php b/app/Models/StoreInvitation.php new file mode 100644 index 00000000..2feaaecd --- /dev/null +++ b/app/Models/StoreInvitation.php @@ -0,0 +1,36 @@ + 'datetime', + 'expires_at' => 'datetime', + 'accepted_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..40c96b62 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,27 @@ + '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..297e7fe4 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,14 @@ + 'boolean', + 'config_json' => 'array', + 'rates_json' => 'array', + ]; + } + + /** @return array */ + public function taxRatesForCalculator(): array + { + $taxRates = $this->config_json['tax_rates'] ?? null; + + if (is_array($taxRates) && array_is_list($taxRates)) { + $rates = []; + + foreach ($taxRates as $taxRate) { + if (! is_array($taxRate) || ! isset($taxRate['country_code'], $taxRate['rate'])) { + continue; + } + + $countryCode = strtoupper((string) $taxRate['country_code']); + $provinceCode = strtoupper((string) ($taxRate['province_code'] ?? '')); + + if ($provinceCode !== '') { + $rates[$countryCode][$provinceCode] = (int) $taxRate['rate']; + } else { + $rates[$countryCode] = (int) $taxRate['rate']; + } + } + + if ($rates !== [] || ($this->rates_json ?? []) === []) { + return $rates; + } + } + + return is_array($taxRates) ? $taxRates : ($this->rates_json ?? []); + } + + /** @return array{default_tax_rate: int, tax_rates: list>|array, fallback: string} */ + public function taxConfiguration(): array + { + $configuration = $this->config_json ?? []; + $rates = $configuration['tax_rates'] ?? null; + + if (! is_array($rates) || $rates === []) { + $rates = collect($this->rates_json ?? [])->map(static fn (mixed $rate, string $countryCode): array => [ + 'country_code' => strtoupper($countryCode), + 'rate' => (int) $rate, + ])->values()->all(); + } + + return [ + 'default_tax_rate' => (int) ($configuration['default_tax_rate'] ?? $this->default_rate ?? 0), + 'tax_rates' => $rates, + 'fallback' => $configuration['fallback'] ?? 'allow', + ]; + } + + public function defaultTaxRateForCalculator(): int + { + return (int) ($this->config_json['default_tax_rate'] ?? $this->default_rate ?? 0); + } + + 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..7c2ab657 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,24 @@ + 'boolean', 'published_at' => 'datetime']; + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSetting::class); + } +} diff --git a/app/Models/ThemeSetting.php b/app/Models/ThemeSetting.php new file mode 100644 index 00000000..5b8c728c --- /dev/null +++ b/app/Models/ThemeSetting.php @@ -0,0 +1,27 @@ + 'array']; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..72517c84 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,8 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -23,6 +25,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'status', ]; /** @@ -47,6 +50,7 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'last_login_at' => 'datetime', ]; } @@ -61,4 +65,21 @@ public function initials(): string ->map(fn ($word) => Str::substr($word, 0, 1)) ->implode(''); } + + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users')->withPivot('role'); + } + + public function tokens(): MorphMany + { + return $this->morphMany(PersonalAccessToken::class, 'tokenable'); + } + + public function roleForStore(Store $store): ?string + { + $role = $this->stores()->whereKey($store->getKey())->first()?->pivot?->role; + + return is_string($role) ? $role : null; + } } diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..8e23ef3f --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,29 @@ + 'datetime']; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..7c9498d3 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,30 @@ + 'encrypted']; + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + public function latestDelivery(): HasOne + { + return $this->hasOne(WebhookDelivery::class, 'subscription_id')->latestOfMany(); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..46be9152 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,33 @@ +hasRole($user, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Customer $customer): bool + { + return (int) $customer->store_id === (int) (app()->bound('current_store') ? app('current_store')->id : 0) + && $this->hasRole($user, ['owner', 'admin', 'staff', 'support']); + } + + public function update(User $user, Customer $customer): bool + { + return $this->view($user, $customer) && $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + private function hasRole(User $user, array $roles): bool + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), $roles, true); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..b3dc1e9b --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,43 @@ +hasRole($user, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Order $order): bool + { + return $order->store_id === (int) (app()->bound('current_store') ? app('current_store')->id : 0) + && $this->hasRole($user, ['owner', 'admin', 'staff', 'support']); + } + + public function update(User $user, Order $order): bool + { + return $this->view($user, $order) && $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + public function processRefund(User $user, Order $order): bool + { + return $this->view($user, $order) && $this->hasRole($user, ['owner', 'admin']); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->update($user, $order); + } + + private function hasRole(User $user, array $roles): bool + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), $roles, true); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..41f85a81 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,50 @@ +hasRole($user, ['owner', 'admin', 'staff']); + } + + public function view(User $user, Page $page): bool + { + return $this->belongsToCurrentStore($page) && $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Page $page): bool + { + return $this->belongsToCurrentStore($page) && $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + public function delete(User $user, Page $page): bool + { + return $this->belongsToCurrentStore($page) && $this->hasRole($user, ['owner', 'admin']); + } + + private function belongsToCurrentStore(Page $page): bool + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && (int) $page->store_id === (int) $store->getKey(); + } + + /** @param list $roles */ + private function hasRole(User $user, array $roles): bool + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), $roles, true); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..06d23ba1 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,48 @@ +hasRole($user, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Product $product): bool + { + return $this->ownsProduct($user, $product) && $this->hasRole($user, ['owner', 'admin', 'staff', 'support']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Product $product): bool + { + return $this->ownsProduct($user, $product) && $this->hasRole($user, ['owner', 'admin', 'staff']); + } + + public function delete(User $user, Product $product): bool + { + return $this->ownsProduct($user, $product) && $this->hasRole($user, ['owner', 'admin']); + } + + private function ownsProduct(User $user, Product $product): bool + { + return $product->store_id === (int) (app()->bound('current_store') ? app('current_store')->id : 0) + && $user->roleForStore(app('current_store')) !== null; + } + + private function hasRole(User $user, array $roles): bool + { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), $roles, true); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..ef2f394d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,28 @@ namespace App\Providers; +use App\Auth\StoreScopedCustomerUserProvider; +use App\Auth\StoreScopedPasswordBrokerManager; +use App\Enums\StoreUserRole; +use App\Http\Middleware\ResolveStore; +use App\Models\Customer; +use App\Models\Store; +use App\Models\User; +use App\Policies\CustomerPolicy; +use App\Policies\OrderPolicy; +use App\Policies\ProductPolicy; use Carbon\CarbonImmutable; +use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +32,11 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->extend('auth.password', function ($manager, $app): StoreScopedPasswordBrokerManager { + return $manager instanceof StoreScopedPasswordBrokerManager + ? $manager + : new StoreScopedPasswordBrokerManager($app); + }); } /** @@ -23,7 +44,16 @@ public function register(): void */ public function boot(): void { + Livewire::addPersistentMiddleware([ResolveStore::class]); + Auth::provider('store-customers', fn ($app, array $config): StoreScopedCustomerUserProvider => new StoreScopedCustomerUserProvider($app['hash'], $config['model'])); + ResetPasswordNotification::createUrlUsing(function (mixed $notifiable, string $token): string { + $route = $notifiable instanceof Customer ? 'storefront.password.reset' : 'admin.password.reset'; + + return route($route, ['token' => $token, 'email' => $notifiable->getEmailForPasswordReset()]); + }); $this->configureDefaults(); + $this->configureRateLimits(); + $this->configureAuthorization(); } /** @@ -46,5 +76,93 @@ protected function configureDefaults(): void ->uncompromised() : null ); + + if (DB::connection()->getDriverName() === 'sqlite') { + DB::statement('PRAGMA cache_size = -20000'); + } + } + + private function configureRateLimits(): void + { + 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', function (Request $request): Limit { + $tokenId = $request->attributes->get('api_token')?->getKey(); + + if ($tokenId !== null) { + return Limit::perMinute(60)->by('token:'.$tokenId); + } + + $plainTextToken = $request->bearerToken(); + + if ($plainTextToken !== null) { + return Limit::perMinute(60)->by('token:'.hash('sha256', $plainTextToken)); + } + + return Limit::perMinute(60)->by('ip:'.($request->user()?->id ?? $request->ip())); + }); + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip())); + RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip())); + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip())); + } + + private function configureAuthorization(): void + { + Gate::policy(\App\Models\Product::class, ProductPolicy::class); + Gate::policy(\App\Models\Order::class, OrderPolicy::class); + Gate::policy(\App\Models\Page::class, \App\Policies\PagePolicy::class); + Gate::policy(Customer::class, CustomerPolicy::class); + + Gate::define('view-collections', function (User $user): bool { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), [ + StoreUserRole::Owner->value, + StoreUserRole::Admin->value, + StoreUserRole::Staff->value, + StoreUserRole::Support->value, + ], true); + }); + + Gate::define('view-discounts', function (User $user): bool { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), [ + StoreUserRole::Owner->value, + StoreUserRole::Admin->value, + StoreUserRole::Staff->value, + StoreUserRole::Support->value, + ], true); + }); + + foreach ([ + 'manage-developers' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'manage-store-settings' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'manage-staff' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'delete-store' => [StoreUserRole::Owner->value], + 'manage-products' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value], + 'archive-products' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'manage-orders' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value], + 'view-orders' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value, StoreUserRole::Support->value], + 'process-refunds' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'create-fulfillments' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value], + 'manage-discounts' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value], + 'manage-themes' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'manage-apps' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'manage-navigation' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'view-analytics' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value], + 'manage-shipping' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value], + 'view-customers' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value, StoreUserRole::Support->value], + 'update-customers' => [StoreUserRole::Owner->value, StoreUserRole::Admin->value, StoreUserRole::Staff->value], + ] as $ability => $roles) { + Gate::define($ability, function (User $user) use ($roles): bool { + $store = app()->bound('current_store') ? app('current_store') : null; + + return $store instanceof Store && in_array($user->roleForStore($store), $roles, true); + }); + } + + Gate::define('view-admin', fn (User $user): bool => $user->stores()->exists()); + Gate::define('customer-account', fn (Customer $customer): bool => app()->bound('current_store') && (int) $customer->store_id === (int) app('current_store')->id); } } diff --git a/app/Providers/AuditServiceProvider.php b/app/Providers/AuditServiceProvider.php new file mode 100644 index 00000000..56aa37b5 --- /dev/null +++ b/app/Providers/AuditServiceProvider.php @@ -0,0 +1,255 @@ +user; + + if ($user instanceof User && $event->guard === 'web') { + app(AuditLogger::class)->log('auth.login', (int) $user->getKey(), $this->currentStoreId()); + } elseif ($user instanceof Customer && $event->guard === 'customer') { + app(AuditLogger::class)->log('customer.login', null, (int) $user->store_id, 'customer', (int) $user->getKey()); + } + }); + + Event::listen(Failed::class, function (Failed $event): void { + $email = $event->credentials['email'] ?? $event->credentials['username'] ?? null; + + if ($event->guard === 'web') { + app(AuditLogger::class)->log('auth.failed_login', null, $this->currentStoreId(), extra: ['email' => is_string($email) ? $email : null]); + } elseif ($event->guard === 'customer') { + app(AuditLogger::class)->log('customer.failed_login', null, $this->currentStoreId(), 'customer', extra: ['email' => is_string($email) ? $email : null]); + } + }); + + Event::listen(Logout::class, function (Logout $event): void { + if ($event->guard === 'web' && $event->user instanceof User) { + app(AuditLogger::class)->log('auth.logout', (int) $event->user->getKey(), $this->currentStoreId()); + } + }); + + $this->registerModelAuditing(); + } + + /** @return array, string> */ + private function auditableModels(): array + { + return [ + Product::class => 'product', + Collection::class => 'collection', + Discount::class => 'discount', + Page::class => 'page', + Theme::class => 'theme', + Order::class => 'order', + Fulfillment::class => 'fulfillment', + Refund::class => 'refund', + NavigationMenu::class => 'navigation_menu', + ShippingZone::class => 'shipping_zone', + TaxSetting::class => 'tax_setting', + StoreUser::class => 'staff', + ]; + } + + private function registerModelAuditing(): void + { + foreach ($this->auditableModels() as $modelClass => $resourceType) { + $modelClass::created(function (Model $model) use ($resourceType): void { + $event = $resourceType === 'product' && $model->status === 'archived' ? 'product.deleted' : "{$resourceType}.created"; + app(AuditLogger::class)->log( + $event, + Auth::id(), + $this->modelStoreId($model), + $resourceType, + $this->resourceId($model), + ['attributes' => $this->keyAttributes($model)], + ); + }); + + $modelClass::updating(function (Model $model): void { + $originals = []; + + foreach ($model->getDirty() as $key => $value) { + if (! $this->isSensitiveKey((string) $key)) { + $originals[$key] = $this->safeAuditValue($model->getOriginal($key)); + } + } + + $model->setRelation('__audit_original_values', $originals); + }); + + $modelClass::updated(function (Model $model) use ($resourceType): void { + $originals = $model->getRelation('__audit_original_values') ?? []; + $changes = []; + + foreach ($model->getChanges() as $key => $value) { + if ($key !== 'updated_at' && ! $this->isSensitiveKey((string) $key)) { + $changes[$key] = [$originals[$key] ?? null, $this->safeAuditValue($value)]; + } + } + + $model->unsetRelation('__audit_original_values'); + + if ($changes === []) { + return; + } + + $event = $resourceType === 'product' && $model->status === 'archived' ? 'product.deleted' : "{$resourceType}.updated"; + app(AuditLogger::class)->log($event, Auth::id(), $this->modelStoreId($model), $resourceType, $this->resourceId($model), ['changes' => $changes]); + }); + + $modelClass::deleted(function (Model $model) use ($resourceType): void { + app(AuditLogger::class)->log("{$resourceType}.deleted", Auth::id(), $this->modelStoreId($model), $resourceType, $this->resourceId($model)); + }); + + if (method_exists($modelClass, 'restored')) { + $modelClass::restored(function (Model $model) use ($resourceType): void { + app(AuditLogger::class)->log("{$resourceType}.restored", Auth::id(), $this->modelStoreId($model), $resourceType, $this->resourceId($model)); + }); + } + } + + Customer::created(function (Customer $customer): void { + app(AuditLogger::class)->log('customer.registered', null, (int) $customer->store_id, 'customer', (int) $customer->getKey()); + }); + + PersonalAccessToken::created(function (PersonalAccessToken $token): void { + app(AuditLogger::class)->log('api_token.created', Auth::id(), (int) $token->store_id, 'api_token', (int) $token->getKey(), [ + 'token_name' => $token->name, + 'abilities' => $token->abilities, + ]); + }); + + PersonalAccessToken::deleted(function (PersonalAccessToken $token): void { + app(AuditLogger::class)->log('api_token.revoked', Auth::id(), (int) $token->store_id, 'api_token', (int) $token->getKey(), ['token_name' => $token->name]); + }); + + StoreSettings::updating(function (StoreSettings $settings): void { + $settings->setRelation('__audit_original_settings', $this->safeAuditValue($settings->getOriginal('settings_json'))); + }); + + StoreSettings::updated(function (StoreSettings $settings): void { + app(AuditLogger::class)->log('store.settings_changed', Auth::id(), (int) $settings->store_id, 'store', (int) $settings->store_id, [ + 'changes' => ['settings_json' => [$settings->getRelation('__audit_original_settings'), $this->safeAuditValue($settings->settings_json)]], + ]); + $settings->unsetRelation('__audit_original_settings'); + }); + } + + private function currentStoreId(): ?int + { + $store = app()->bound('current_store') ? app('current_store') : null; + + if ($store instanceof Store) { + return (int) $store->getKey(); + } + + $storeId = request()->hasSession() ? request()->session()->get('current_store_id') : null; + + return is_numeric($storeId) ? (int) $storeId : null; + } + + private function modelStoreId(Model $model): ?int + { + if ($model->getAttribute('store_id') !== null) { + return (int) $model->getAttribute('store_id'); + } + + if (method_exists($model, 'order')) { + $storeId = $model->order()->value('store_id'); + + if (is_numeric($storeId)) { + return (int) $storeId; + } + } + + return $this->currentStoreId(); + } + + private function resourceId(Model $model): ?int + { + $id = $model->getKey() ?? $model->getAttribute('user_id'); + + return is_numeric($id) ? (int) $id : null; + } + + /** @return array */ + private function keyAttributes(Model $model): array + { + $keys = ['title', 'handle', 'name', 'code', 'status', 'order_number', 'total_amount', 'role']; + $attributes = []; + + foreach ($keys as $key) { + if ($model->getAttribute($key) !== null && ! $this->isSensitiveKey($key)) { + $attributes[$key] = $this->safeAuditValue($model->getAttribute($key)); + } + } + + return $attributes; + } + + private function isSensitiveKey(string $key): bool + { + return preg_match('/password|secret|token/i', $key) === 1; + } + + private function safeAuditValue(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + return $value->format(DATE_ATOM); + } + + if (is_array($value)) { + $safe = []; + + foreach ($value as $key => $item) { + $safe[$key] = $this->isSensitiveKey((string) $key) ? '[redacted]' : $this->safeAuditValue($item); + } + + return $safe; + } + + return is_scalar($value) || $value === null ? $value : (string) $value; + } +} diff --git a/app/Providers/WebhookEventServiceProvider.php b/app/Providers/WebhookEventServiceProvider.php new file mode 100644 index 00000000..7a43b8e0 --- /dev/null +++ b/app/Providers/WebhookEventServiceProvider.php @@ -0,0 +1,55 @@ +>, meta: array{current_page: int, per_page: int, total: int, last_page: int}} + */ + public function listCollections(Store $store, array $filters, int $perPage): array + { + $query = Collection::query() + ->where('store_id', $store->getKey()) + ->withCount('products'); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['query'])) { + $query->where('title', 'like', '%'.addcslashes($filters['query'], '%_\\').'%'); + } + + $paginator = $query + ->orderBy('title') + ->paginate($perPage, ['*'], 'page', $filters['page'] ?? 1); + + return $this->paginated($paginator, fn (Collection $collection): array => $this->collectionResource($collection)); + } + + /** @param array $attributes */ + public function createCollection(Store $store, array $attributes): Collection + { + return DB::transaction(function () use ($store, $attributes): Collection { + $collection = Collection::query()->create([ + 'store_id' => $store->getKey(), + 'title' => $attributes['title'], + 'handle' => filled($attributes['handle'] ?? null) + ? $attributes['handle'] + : $this->handles->generate($attributes['title'], 'collections', (int) $store->getKey()), + 'description_html' => $attributes['description_html'] ?? null, + 'type' => $attributes['type'], + 'status' => $attributes['status'] ?? 'active', + ]); + + if (array_key_exists('product_ids', $attributes)) { + $this->syncProducts($collection, $attributes['product_ids']); + } + + return $collection->loadCount('products'); + }); + } + + /** @param array $attributes */ + public function updateCollection(Collection $collection, array $attributes): Collection + { + return DB::transaction(function () use ($collection, $attributes): Collection { + $changes = collect($attributes)->only(['title', 'description_html', 'type', 'status'])->all(); + + if (array_key_exists('handle', $attributes)) { + $title = $attributes['title'] ?? $collection->title; + $changes['handle'] = filled($attributes['handle']) + ? $attributes['handle'] + : $this->handles->generate($title, 'collections', (int) $collection->store_id, (int) $collection->getKey()); + } + + if ($changes !== []) { + $collection->update($changes); + } + + if (array_key_exists('product_ids', $attributes)) { + $this->syncProducts($collection, $attributes['product_ids']); + } elseif (array_key_exists('add_product_ids', $attributes) || array_key_exists('remove_product_ids', $attributes)) { + $productIds = $collection->products()->pluck('products.id')->map(static fn (int|string $id): int => (int) $id)->all(); + $productIds = array_values(array_unique([...$productIds, ...($attributes['add_product_ids'] ?? [])])); + $removed = array_map('intval', $attributes['remove_product_ids'] ?? []); + $this->syncProducts($collection, array_values(array_diff($productIds, $removed))); + } + + return $collection->refresh()->loadCount('products'); + }); + } + + public function deleteCollection(Collection $collection): void + { + $collection->delete(); + } + + /** + * @param array{type?: string, status?: string, page?: int} $filters + * @return array{data: list>, meta: array{current_page: int, per_page: int, total: int, last_page: int}} + */ + public function listDiscounts(Store $store, array $filters, int $perPage): array + { + $query = Discount::query()->where('store_id', $store->getKey()); + + if (isset($filters['type'])) { + $filters['type'] === 'code' + ? $query->whereNotNull('code') + : $query->whereNull('code'); + } + + $now = now(); + + if (($filters['status'] ?? null) === 'active') { + $query + ->where(fn ($builder) => $builder->whereNull('starts_at')->orWhere('starts_at', '<=', $now)) + ->where(fn ($builder) => $builder->whereNull('ends_at')->orWhere('ends_at', '>=', $now)); + } elseif (($filters['status'] ?? null) === 'expired') { + $query->whereNotNull('ends_at')->where('ends_at', '<', $now); + } elseif (($filters['status'] ?? null) === 'scheduled') { + $query->whereNotNull('starts_at')->where('starts_at', '>', $now); + } + + $paginator = $query + ->orderByDesc('created_at') + ->orderBy('id') + ->paginate($perPage, ['*'], 'page', $filters['page'] ?? 1); + + return $this->paginated($paginator, fn (Discount $discount): array => $this->discountResource($discount)); + } + + /** @param array $attributes */ + public function createDiscount(Store $store, array $attributes): Discount + { + $mode = $attributes['type']; + $rules = $this->storedRules($mode, $attributes['rules_json'] ?? [], []); + $valueType = $this->toStorageValueType($attributes['value_type']); + + return Discount::query()->create([ + 'store_id' => $store->getKey(), + 'code' => $mode === 'code' ? $attributes['code'] : null, + 'title' => $mode === 'code' ? $attributes['code'] : 'Automatic discount', + 'type' => $valueType, + 'value' => $valueType === 'free_shipping' ? 0 : (int) $attributes['value_amount'], + 'minimum_subtotal_amount' => $rules['minimum_purchase_amount'], + 'usage_limit' => $attributes['usage_limit'] ?? null, + 'starts_at' => $attributes['starts_at'] ?? null, + 'ends_at' => $attributes['ends_at'] ?? null, + 'is_active' => true, + 'rules_json' => $rules, + ]); + } + + /** @param array $attributes */ + public function updateDiscount(Discount $discount, array $attributes): Discount + { + $changes = []; + + if (array_key_exists('value_type', $attributes)) { + $changes['type'] = $this->toStorageValueType($attributes['value_type']); + } + + if (array_key_exists('value_amount', $attributes)) { + $valueType = $changes['type'] ?? $discount->type; + $changes['value'] = $valueType === 'free_shipping' ? 0 : (int) $attributes['value_amount']; + } elseif (($changes['type'] ?? $discount->type) === 'free_shipping') { + $changes['value'] = 0; + } + + foreach (['usage_limit', 'starts_at', 'ends_at'] as $field) { + if (array_key_exists($field, $attributes)) { + $changes[$field] = $attributes[$field]; + } + } + + if (array_key_exists('rules_json', $attributes)) { + $rules = $this->storedRules( + $discount->code === null ? 'automatic' : 'code', + $attributes['rules_json'], + $this->publicRules($discount), + ); + $changes['rules_json'] = $rules; + $changes['minimum_subtotal_amount'] = $rules['minimum_purchase_amount']; + } + + if ($changes !== []) { + $discount->update($changes); + } + + return $discount->refresh(); + } + + public function deleteDiscount(Discount $discount): void + { + $discount->delete(); + } + + /** @return array */ + public function collectionResource(Collection $collection): array + { + return [ + 'id' => (int) $collection->getKey(), + 'store_id' => (int) $collection->store_id, + 'title' => $collection->title, + 'handle' => $collection->handle, + 'description_html' => $collection->description_html, + 'type' => $collection->type, + 'status' => $collection->status, + 'products_count' => (int) ($collection->products_count ?? $collection->products()->count()), + 'created_at' => $collection->created_at?->toISOString(), + 'updated_at' => $collection->updated_at?->toISOString(), + ]; + } + + /** @return array */ + public function discountResource(Discount $discount): array + { + $rules = $this->publicRules($discount); + + return [ + 'id' => (int) $discount->getKey(), + 'store_id' => (int) $discount->store_id, + 'type' => $discount->code === null ? 'automatic' : 'code', + 'code' => $discount->code, + 'value_type' => $this->fromStorageValueType($discount->type), + 'value_amount' => (int) $discount->value, + 'starts_at' => $discount->starts_at?->toISOString(), + 'ends_at' => $discount->ends_at?->toISOString(), + 'usage_limit' => $discount->usage_limit === null ? null : (int) $discount->usage_limit, + 'usage_count' => (int) $discount->usage_count, + 'rules_json' => $rules, + 'created_at' => $discount->created_at?->toISOString(), + ]; + } + + /** @param list $productIds */ + private function syncProducts(Collection $collection, array $productIds): void + { + $positions = []; + + foreach (array_values(array_unique(array_map('intval', $productIds))) as $position => $productId) { + $positions[$productId] = ['position' => $position]; + } + + $collection->products()->sync($positions); + } + + /** + * @param array $newRules + * @param array $existingRules + * @return array + */ + private function storedRules(string $mode, array $newRules, array $existingRules): array + { + $rules = array_merge([ + 'minimum_purchase_amount' => 0, + 'applicable_product_ids' => [], + 'applicable_collection_ids' => [], + 'customer_eligibility' => 'all', + 'once_per_customer' => false, + ], $existingRules, $newRules); + $productIds = array_values(array_unique(array_map('intval', $rules['applicable_product_ids'] ?? []))); + $collectionIds = array_values(array_unique(array_map('intval', $rules['applicable_collection_ids'] ?? []))); + + return [ + 'activation_method' => $mode, + 'minimum_purchase_amount' => (int) ($rules['minimum_purchase_amount'] ?? 0), + 'applicable_product_ids' => $productIds, + 'applicable_collection_ids' => $collectionIds, + 'customer_eligibility' => $rules['customer_eligibility'] ?? 'all', + 'once_per_customer' => (bool) ($rules['once_per_customer'] ?? false), + 'one_per_customer' => (bool) ($rules['once_per_customer'] ?? false), + 'product_ids' => $productIds, + 'collection_ids' => $collectionIds, + ]; + } + + /** @return array{minimum_purchase_amount: int, applicable_product_ids: list, applicable_collection_ids: list, customer_eligibility: string, once_per_customer: bool} */ + private function publicRules(Discount $discount): array + { + $storedRules = $discount->rules_json ?? []; + + return [ + 'minimum_purchase_amount' => (int) ($storedRules['minimum_purchase_amount'] ?? $discount->minimum_subtotal_amount ?? 0), + 'applicable_product_ids' => array_values(array_map('intval', $storedRules['applicable_product_ids'] ?? $storedRules['product_ids'] ?? [])), + 'applicable_collection_ids' => array_values(array_map('intval', $storedRules['applicable_collection_ids'] ?? $storedRules['collection_ids'] ?? [])), + 'customer_eligibility' => (string) ($storedRules['customer_eligibility'] ?? 'all'), + 'once_per_customer' => (bool) ($storedRules['once_per_customer'] ?? $storedRules['one_per_customer'] ?? false), + ]; + } + + private function toStorageValueType(string $valueType): string + { + return match ($valueType) { + 'percent' => 'percentage', + 'fixed' => 'fixed_amount', + 'free_shipping' => 'free_shipping', + }; + } + + private function fromStorageValueType(string $valueType): string + { + return match ($valueType) { + 'percentage' => 'percent', + 'fixed_amount' => 'fixed', + 'free_shipping' => 'free_shipping', + default => $valueType, + }; + } + + /** + * @template T of Collection|Discount + * + * @param LengthAwarePaginator $paginator + * @param callable(T): array $resource + * @return array{data: list>, meta: array{current_page: int, per_page: int, total: int, last_page: int}} + */ + private function paginated(LengthAwarePaginator $paginator, callable $resource): array + { + return [ + 'data' => $paginator->getCollection()->map($resource)->values()->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]; + } +} diff --git a/app/Services/AdminProductApiService.php b/app/Services/AdminProductApiService.php new file mode 100644 index 00000000..daa4a930 --- /dev/null +++ b/app/Services/AdminProductApiService.php @@ -0,0 +1,528 @@ + $filters + * @return array{data: list>, meta: array{current_page: int, per_page: int, total: int, last_page: int}} + */ + public function list(Store $store, array $filters): array + { + $query = Product::query() + ->where('store_id', $store->getKey()) + ->with(['media', 'variants.inventoryItem']) + ->withCount('variants') + ->addSelect(['total_inventory' => DB::table('inventory_items') + ->join('product_variants', 'product_variants.id', '=', 'inventory_items.variant_id') + ->whereColumn('product_variants.product_id', 'products.id') + ->where('product_variants.status', 'active') + ->selectRaw('COALESCE(SUM(inventory_items.quantity_on_hand), 0)')]); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['query'])) { + $search = addcslashes($filters['query'], '%_\\'); + $query->where(function (Builder $builder) use ($search): void { + $builder->where('title', 'like', "%{$search}%") + ->orWhere('vendor', 'like', "%{$search}%") + ->orWhereHas('variants', fn (Builder $variants) => $variants->where('sku', 'like', "%{$search}%")); + }); + } + + if (isset($filters['collection_id'])) { + $query->whereHas('collections', fn (Builder $collections) => $collections->whereKey($filters['collection_id'])); + } + + $query->orderBy(match ($filters['sort'] ?? 'updated_at_desc') { + 'title_asc', 'title_desc' => 'title', + 'created_at_asc', 'created_at_desc' => 'created_at', + default => 'updated_at', + }, str_ends_with($filters['sort'] ?? 'updated_at_desc', '_asc') ? 'asc' : 'desc'); + + $page = $filters['page'] ?? 1; + $perPage = $filters['per_page'] ?? 25; + $products = $query->paginate($perPage, $query->getQuery()->columns ?? ['*'], 'page', $page); + + return [ + 'data' => $products->getCollection()->map(fn (Product $product): array => $this->listResource($product))->all(), + 'meta' => $this->paginationMeta($products), + ]; + } + + /** @param array $data */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $submittedVariants = $data['variants']; + $options = $this->normalizeOptions($data['options'] ?? [], $submittedVariants); + $requestedStatus = ProductStatus::from($data['status'] ?? ProductStatus::Draft->value); + $creationData = [ + ...$data, + 'status' => ProductStatus::Draft->value, + 'options' => $options, + 'variants' => array_map(fn (array $variant): array => $this->variantAttributes($variant, $store), $submittedVariants), + ]; + $product = $this->products->create($store, $creationData); + $product->load(['options.values', 'variants.inventoryItem']); + $variantsByInitialPosition = $product->variants->keyBy('position'); + + foreach ($submittedVariants as $position => $submittedVariant) { + $variant = $variantsByInitialPosition->get($position); + + if (! $variant) { + throw ValidationException::withMessages(["variants.{$position}" => 'The variant could not be created.']); + } + + $variant->optionValues()->sync($this->optionValueIds($product->options, $submittedVariant['option_values'] ?? [])); + + if (isset($submittedVariant['status'])) { + $variant->update(['status' => $submittedVariant['status']]); + } + + if (isset($submittedVariant['position'])) { + $variant->update(['position' => $submittedVariant['position'] - 1]); + } + } + + if (array_key_exists('collections', $data)) { + $product->collections()->sync($data['collections']); + } + + if ($requestedStatus !== ProductStatus::Draft) { + $this->products->transitionStatus($product, $requestedStatus); + } + + return $this->loadProduct($product); + }); + } + + /** @param array $data */ + public function update(Product $product, Store $store, array $data): Product + { + return DB::transaction(function () use ($product, $store, $data): Product { + $requestedStatus = isset($data['status']) ? ProductStatus::from($data['status']) : null; + $productData = collect($data)->only(['title', 'handle', 'description_html', 'vendor', 'product_type', 'tags'])->all(); + $this->products->update($product, $productData); + + if (array_key_exists('options', $data)) { + $product->load(['options.values', 'variants.optionValues.option', 'variants.inventoryItem']); + $options = $this->normalizeOptions($data['options'], $data['variants'] ?? [], $product); + $this->variants->rebuild($product, $options); + $product->load(['options.values', 'variants.optionValues.option', 'variants.inventoryItem']); + + foreach ($data['variants'] ?? [] as $position => $submittedVariant) { + $variant = $this->matchVariant($product, $submittedVariant, $position); + $this->applyVariantUpdate($variant, $submittedVariant, $product, $store); + } + } elseif (isset($data['variants'])) { + $product->load(['options.values', 'variants.optionValues.option', 'variants.inventoryItem']); + + foreach ($data['variants'] as $position => $submittedVariant) { + $variant = isset($submittedVariant['id']) + ? $product->variants->firstWhere('id', $submittedVariant['id']) + : $this->createVariant($product, $store, $submittedVariant, $position); + + if (! $variant) { + throw ValidationException::withMessages(["variants.{$position}.id" => 'The variant does not belong to this product.']); + } + + $this->applyVariantUpdate($variant, $submittedVariant, $product, $store); + } + } + + if (isset($data['delete_variant_ids'])) { + $this->deleteVariants($product, $data['delete_variant_ids']); + } + + $this->normalizeDefaultVariant($product); + + if (array_key_exists('collections', $data)) { + $product->collections()->sync($data['collections']); + } + + $product->refresh(); + + if ($requestedStatus !== null && $product->status !== $requestedStatus->value) { + $this->products->transitionStatus($product, $requestedStatus); + } + + return $this->loadProduct($product); + }); + } + + /** @return array */ + public function resource(Product $product): array + { + $product = $this->loadProduct($product); + + return [ + 'id' => $product->getKey(), + 'store_id' => $product->store_id, + 'title' => $product->title, + 'handle' => $product->handle, + 'description_html' => $product->description_html, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'status' => $product->status, + 'tags' => $product->tags ?? [], + 'published_at' => $product->published_at?->toISOString(), + 'created_at' => $product->created_at?->toISOString(), + 'updated_at' => $product->updated_at?->toISOString(), + 'options' => $product->options->map(fn (ProductOption $option): array => [ + 'id' => $option->getKey(), + 'name' => $option->name, + 'position' => $option->position + 1, + 'values' => $option->values->map(fn (ProductOptionValue $value): array => [ + 'id' => $value->getKey(), + 'value' => $value->value, + 'position' => $value->position + 1, + ])->all(), + ])->all(), + 'variants' => $product->variants->map(fn (ProductVariant $variant): array => [ + 'id' => $variant->getKey(), + 'sku' => $variant->sku, + 'barcode' => $variant->barcode, + 'price_amount' => (int) $variant->price_amount, + 'compare_at_amount' => $variant->compare_at_amount === null ? null : (int) $variant->compare_at_amount, + 'currency' => $variant->currency, + 'weight_g' => $variant->weight_g === null ? null : (int) $variant->weight_g, + 'requires_shipping' => (bool) $variant->requires_shipping, + 'is_default' => (bool) $variant->is_default, + 'position' => $variant->position + 1, + 'status' => $variant->status, + 'option_values' => $variant->optionValues->map(fn (ProductOptionValue $value): array => [ + 'option_name' => $value->option?->name, + 'value' => $value->value, + ])->all(), + 'inventory' => [ + 'quantity_on_hand' => (int) ($variant->inventoryItem?->quantity_on_hand ?? 0), + 'quantity_reserved' => (int) ($variant->inventoryItem?->quantity_reserved ?? 0), + 'policy' => $variant->inventoryItem?->policy ?? 'deny', + ], + ])->all(), + 'media' => $product->media->map(fn (ProductMedia $media): array => [ + 'id' => $media->getKey(), + 'type' => $media->type, + 'storage_key' => $media->storage_key, + 'url' => $media->url, + 'alt_text' => $media->alt_text, + 'width' => $media->width, + 'height' => $media->height, + 'mime_type' => $media->mime_type, + 'byte_size' => $media->byte_size, + 'position' => $media->position + 1, + 'status' => $media->status, + ])->all(), + 'collections' => $product->collections->map(static fn (Collection $collection): array => [ + 'id' => $collection->getKey(), + 'title' => $collection->title, + 'handle' => $collection->handle, + ])->all(), + ]; + } + + /** @return array */ + private function listResource(Product $product): array + { + $image = $product->media->firstWhere('type', 'image'); + + return [ + 'id' => $product->getKey(), + 'store_id' => $product->store_id, + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'tags' => $product->tags ?? [], + 'variants_count' => (int) $product->variants_count, + 'total_inventory' => (int) $product->total_inventory, + 'published_at' => $product->published_at?->toISOString(), + 'created_at' => $product->created_at?->toISOString(), + 'updated_at' => $product->updated_at?->toISOString(), + 'featured_image' => $image === null ? null : ['url' => $image->url, 'alt_text' => $image->alt_text], + ]; + } + + /** @param list> $optionData + * @param list> $submittedVariants + * @return list}> + */ + private function normalizeOptions(array $optionData, array $submittedVariants, ?Product $product = null): array + { + usort($optionData, static fn (array $left, array $right): int => ($left['position'] ?? 0) <=> ($right['position'] ?? 0)); + $options = []; + + foreach ($optionData as $position => $option) { + $name = trim((string) ($option['name'] ?? '')); + $existing = $product?->options->first(fn (ProductOption $candidate): bool => mb_strtolower($candidate->name) === mb_strtolower($name)); + $values = array_map('strval', $option['values'] ?? $existing?->values->pluck('value')->all() ?? []); + + foreach ($submittedVariants as $variant) { + foreach ($variant['option_values'] ?? [] as $optionValue) { + if (mb_strtolower((string) ($optionValue['option_name'] ?? '')) === mb_strtolower($name)) { + $values[] = (string) $optionValue['value']; + } + } + } + + $options[] = ['name' => $name, 'values' => array_values(array_unique($values))]; + } + + return $options; + } + + /** @param EloquentCollection $options + * @param list $submittedValues + * @return list + */ + private function optionValueIds(EloquentCollection $options, array $submittedValues): array + { + if ($options->isEmpty()) { + if ($submittedValues !== []) { + throw ValidationException::withMessages(['variants.option_values' => 'Option values require product options.']); + } + + return []; + } + + $submitted = collect($submittedValues)->keyBy(fn (array $value): string => mb_strtolower($value['option_name'])); + $ids = []; + + foreach ($options as $option) { + $choice = $submitted->get(mb_strtolower($option->name)); + + if (! $choice) { + throw ValidationException::withMessages(['variants.option_values' => "Every variant must specify a value for {$option->name}."]); + } + + $value = $option->values->first(fn (ProductOptionValue $candidate): bool => mb_strtolower($candidate->value) === mb_strtolower($choice['value'])); + + if (! $value) { + throw ValidationException::withMessages(['variants.option_values' => "The value {$choice['value']} does not exist for {$option->name}."]); + } + + $ids[] = (int) $value->getKey(); + } + + if ($submitted->count() !== $options->count()) { + throw ValidationException::withMessages(['variants.option_values' => 'Each option may appear only once per variant.']); + } + + return $ids; + } + + /** @param array $submittedVariant */ + private function matchVariant(Product $product, array $submittedVariant, int $position): ProductVariant + { + if (isset($submittedVariant['option_values'])) { + $key = $this->submittedCombinationKey($submittedVariant['option_values']); + $variant = $product->variants->first(fn (ProductVariant $candidate): bool => $candidate->status === 'active' && $this->variantCombinationKey($candidate) === $key); + + if ($variant) { + return $variant; + } + } + + if (isset($submittedVariant['id'])) { + $variant = $product->variants->firstWhere('id', $submittedVariant['id']); + + if ($variant) { + return $variant; + } + + throw ValidationException::withMessages(["variants.{$position}.id" => 'The variant does not belong to this product.']); + } + + return $product->variants->firstWhere('position', $position) + ?? throw ValidationException::withMessages(["variants.{$position}" => 'The variant does not match an option combination.']); + } + + /** @param array $submittedVariant */ + private function applyVariantUpdate(ProductVariant $variant, array $submittedVariant, Product $product, Store $store): void + { + $attributes = []; + + $priceAmount = (int) ($submittedVariant['price_amount'] ?? $variant->price_amount); + $compareAtAmount = array_key_exists('compare_at_amount', $submittedVariant) + ? $submittedVariant['compare_at_amount'] + : $variant->compare_at_amount; + + if ($compareAtAmount !== null && (int) $compareAtAmount <= $priceAmount) { + throw ValidationException::withMessages(['variants.compare_at_amount' => 'The compare-at price must be greater than the variant price.']); + } + + foreach (['sku', 'barcode', 'price_amount', 'compare_at_amount', 'currency', 'weight_g', 'requires_shipping', 'is_default', 'status'] as $field) { + if (array_key_exists($field, $submittedVariant)) { + $attributes[$field] = $submittedVariant[$field]; + } + } + + if (isset($submittedVariant['position'])) { + $attributes['position'] = $submittedVariant['position'] - 1; + } + + if ($attributes !== []) { + $variant->update($attributes); + } + + if (array_key_exists('option_values', $submittedVariant)) { + $product->loadMissing('options.values'); + $variant->optionValues()->sync($this->optionValueIds($product->options, $submittedVariant['option_values'])); + } + + if (! array_key_exists('option_values', $submittedVariant) && $product->options()->exists() && ! $variant->optionValues()->exists()) { + throw ValidationException::withMessages(['variants.option_values' => 'A variant must include a value for every product option.']); + } + + if (($attributes['is_default'] ?? false) === true) { + $product->variants()->whereKeyNot($variant->getKey())->update(['is_default' => false]); + } + + if (isset($submittedVariant['inventory'])) { + $inventory = $variant->inventoryItem; + $quantity = (int) ($submittedVariant['inventory']['quantity_on_hand'] ?? $inventory?->quantity_on_hand ?? 0); + $reserved = (int) ($inventory?->quantity_reserved ?? 0); + + if ($quantity < $reserved) { + throw ValidationException::withMessages(['variants.inventory.quantity_on_hand' => 'Available quantity cannot be lower than quantity reserved for active checkouts.']); + } + + $variant->inventoryItem()->updateOrCreate([], [ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $quantity, + 'policy' => $submittedVariant['inventory']['policy'] ?? $inventory?->policy ?? 'deny', + ]); + } + } + + /** @param array $submittedVariant */ + private function createVariant(Product $product, Store $store, array $submittedVariant, int $position): ProductVariant + { + $variant = $product->variants()->create([ + ...$this->variantAttributes($submittedVariant, $store), + 'position' => ($submittedVariant['position'] ?? $position + 1) - 1, + 'is_default' => $submittedVariant['is_default'] ?? false, + ]); + $variant->inventoryItem()->create([ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $submittedVariant['inventory']['quantity_on_hand'] ?? 0, + 'policy' => $submittedVariant['inventory']['policy'] ?? 'deny', + ]); + + return $variant->load(['inventoryItem', 'optionValues.option']); + } + + /** @param array $submittedVariant + * @return array + */ + private function variantAttributes(array $submittedVariant, Store $store): array + { + return [ + 'sku' => $submittedVariant['sku'] ?? null, + 'barcode' => $submittedVariant['barcode'] ?? null, + 'price_amount' => $submittedVariant['price_amount'], + 'compare_at_amount' => $submittedVariant['compare_at_amount'] ?? null, + 'currency' => strtoupper($submittedVariant['currency'] ?? $store->default_currency), + 'weight_g' => $submittedVariant['weight_g'] ?? null, + 'requires_shipping' => $submittedVariant['requires_shipping'] ?? true, + 'is_default' => $submittedVariant['is_default'] ?? false, + 'quantity_on_hand' => $submittedVariant['inventory']['quantity_on_hand'] ?? 0, + 'inventory_policy' => $submittedVariant['inventory']['policy'] ?? 'deny', + 'status' => $submittedVariant['status'] ?? 'active', + ]; + } + + /** @param list $variantIds */ + private function deleteVariants(Product $product, array $variantIds): void + { + $variants = $product->variants()->whereIn('id', $variantIds)->get(); + + if ($variants->count() !== count(array_unique($variantIds))) { + throw ValidationException::withMessages(['delete_variant_ids' => 'One or more variants do not belong to this product.']); + } + + foreach ($variants as $variant) { + $hasOrderHistory = OrderLine::query()->where('variant_id', $variant->getKey())->exists(); + + if ($hasOrderHistory) { + $variant->update(['status' => 'archived', 'is_default' => false]); + } else { + $variant->delete(); + } + } + + } + + private function normalizeDefaultVariant(Product $product): void + { + $activeVariants = $product->variants()->where('status', 'active')->orderBy('position')->get(); + $defaults = $activeVariants->where('is_default', true); + + if ($defaults->count() > 1) { + throw ValidationException::withMessages(['variants' => 'Only one active variant may be the default.']); + } + + if ($defaults->isEmpty() && $activeVariants->isNotEmpty()) { + $activeVariants->first()->update(['is_default' => true]); + } + } + + private function submittedCombinationKey(array $values): string + { + return collect($values)->map(fn (array $value): string => mb_strtolower(trim($value['option_name'])).'='.mb_strtolower(trim($value['value'])))->sort()->implode('|'); + } + + private function variantCombinationKey(ProductVariant $variant): string + { + return $variant->optionValues + ->map(fn (ProductOptionValue $value): string => mb_strtolower(trim((string) $value->option?->name)).'='.mb_strtolower(trim($value->value))) + ->sort() + ->implode('|'); + } + + private function loadProduct(Product $product): Product + { + return $product->refresh()->load([ + 'options.values', + 'variants.inventoryItem', + 'variants.optionValues.option', + 'media', + 'collections', + ]); + } + + /** @return array{current_page: int, per_page: int, total: int, last_page: int} */ + private function paginationMeta(LengthAwarePaginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ]; + } +} diff --git a/app/Services/AdminStoreConfigurationService.php b/app/Services/AdminStoreConfigurationService.php new file mode 100644 index 00000000..1562cf60 --- /dev/null +++ b/app/Services/AdminStoreConfigurationService.php @@ -0,0 +1,589 @@ +>, meta: array{current_page: int, per_page: int, total: int, last_page: int}} + */ + public function listPages(Store $store, array $filters): array + { + $perPage = $filters['per_page'] ?? 25; + $pages = Page::query() + ->where('store_id', $store->getKey()) + ->when(isset($filters['status']), fn ($query) => $query->where('status', $filters['status'])) + ->orderBy('title') + ->orderBy('id') + ->paginate($perPage, ['*'], 'page', $filters['page'] ?? 1); + + return [ + 'data' => $pages->getCollection()->map(fn (Page $page): array => $this->pageResource($page))->all(), + 'meta' => [ + 'current_page' => $pages->currentPage(), + 'per_page' => $pages->perPage(), + 'total' => $pages->total(), + 'last_page' => $pages->lastPage(), + ], + ]; + } + + /** @param array{title: string, handle?: ?string, body_html?: ?string, status?: string} $attributes */ + public function createPage(Store $store, array $attributes): Page + { + $status = $attributes['status'] ?? 'draft'; + $handle = ($attributes['handle'] ?? null) ?: $this->handles->generate($attributes['title'], 'pages', (int) $store->getKey()); + + return Page::query()->create([ + 'store_id' => $store->getKey(), + 'title' => $attributes['title'], + 'handle' => $handle, + 'body_html' => $this->sanitizer->sanitize($attributes['body_html'] ?? null), + 'status' => $status, + 'published_at' => $status === 'published' ? now() : null, + ]); + } + + /** @param array{title?: string, handle?: ?string, body_html?: ?string, status?: string} $attributes */ + public function updatePage(Page $page, array $attributes): Page + { + $status = $attributes['status'] ?? $page->status; + $updated = []; + + foreach (['title', 'body_html'] as $field) { + if (array_key_exists($field, $attributes)) { + $updated[$field] = $field === 'body_html' + ? $this->sanitizer->sanitize($attributes[$field]) + : $attributes[$field]; + } + } + + if (array_key_exists('handle', $attributes)) { + $updated['handle'] = $attributes['handle'] ?: $this->handles->generate( + $attributes['title'] ?? $page->title, + 'pages', + (int) $page->store_id, + (int) $page->getKey(), + ); + } + + if (array_key_exists('status', $attributes)) { + $updated['status'] = $status; + $updated['published_at'] = $status === 'published' ? ($page->published_at ?? now()) : null; + } + + $page->update($updated); + + return $page->refresh(); + } + + /** @return array */ + public function pageResource(Page $page): array + { + return [ + 'id' => $page->getKey(), + 'store_id' => $page->store_id, + 'title' => $page->title, + 'handle' => $page->handle, + 'status' => $page->status, + 'published_at' => $page->published_at?->toISOString(), + 'created_at' => $page->created_at?->toISOString(), + 'updated_at' => $page->updated_at?->toISOString(), + ]; + } + + /** @return list> */ + public function listShippingZones(Store $store): array + { + return ShippingZone::query() + ->where('store_id', $store->getKey()) + ->with(['rates' => fn ($query) => $query->orderBy('id')]) + ->orderBy('name') + ->orderBy('id') + ->get() + ->map(fn (ShippingZone $zone): array => $this->shippingZoneResource($zone)) + ->all(); + } + + /** @param list $countries */ + public function countryOverlap(Store $store, array $countries, ?int $excludeZoneId = null): bool + { + $existingCountries = ShippingZone::query() + ->where('store_id', $store->getKey()) + ->when($excludeZoneId, fn ($query) => $query->where('id', '!=', $excludeZoneId)) + ->get(['countries']) + ->flatMap(static fn (ShippingZone $zone): array => $zone->countries ?? []) + ->map(static fn (string $code): string => strtoupper($code)); + + return $existingCountries->intersect($countries)->isNotEmpty(); + } + + /** @param array{name: string, countries_json: list, regions_json?: list} $attributes */ + public function createShippingZone(Store $store, array $attributes): ShippingZone + { + return ShippingZone::query()->create([ + 'store_id' => $store->getKey(), + 'name' => $attributes['name'], + 'countries' => $attributes['countries_json'], + 'regions' => $attributes['regions_json'] ?? [], + ]); + } + + /** @param array{name: string, countries_json: list, regions_json?: list} $attributes */ + public function updateShippingZone(ShippingZone $zone, array $attributes): ShippingZone + { + $zone->update([ + 'name' => $attributes['name'], + 'countries' => $attributes['countries_json'], + 'regions' => $attributes['regions_json'] ?? [], + ]); + + return $zone->refresh(); + } + + /** @param array{name: string, type: string, config_json: array, is_active?: bool} $attributes */ + public function createShippingRate(ShippingZone $zone, array $attributes): ShippingRate + { + $config = $attributes['config_json']; + $priceAmount = $config['price_amount'] + ?? data_get($config, 'tiers.0.price_amount') + ?? 0; + + return $zone->rates()->create([ + 'name' => $attributes['name'], + 'type' => $attributes['type'], + 'config_json' => $config, + 'price_amount' => $priceAmount, + 'is_active' => $attributes['is_active'] ?? true, + ]); + } + + /** @return array */ + public function shippingZoneResource(ShippingZone $zone): array + { + return [ + 'id' => $zone->getKey(), + 'store_id' => $zone->store_id, + 'name' => $zone->name, + 'countries_json' => $zone->countries ?? [], + 'regions_json' => $zone->regions ?? [], + 'rates' => $zone->rates->map(fn (ShippingRate $rate): array => [ + 'id' => $rate->getKey(), + 'name' => $rate->name, + 'type' => $rate->type, + 'config_json' => $rate->config_json ?? [], + 'is_active' => $rate->is_active, + ])->all(), + ]; + } + + /** @return array{id: int, store_id: int, name: string, version: string, status: string, published_at: ?string, created_at: ?string} */ + public function installTheme(Store $store, UploadedFile $archive, ?string $name): array + { + if ($archive->getSize() > self::MAX_THEME_ARCHIVE_BYTES) { + throw ValidationException::withMessages(['file' => 'The theme archive may not exceed 50 MB.']); + } + + $zip = new ZipArchive; + $opened = $zip->open($archive->getRealPath()); + + if ($opened !== true) { + throw ValidationException::withMessages(['file' => 'The uploaded theme archive is invalid.']); + } + + try { + [$manifest, $files] = $this->readThemeArchive($zip); + } finally { + $zip->close(); + } + + $theme = DB::transaction(function () use ($store, $manifest, $files, $name): Theme { + $theme = Theme::query()->create([ + 'store_id' => $store->getKey(), + 'name' => $name ?: $manifest['name'], + 'version' => $manifest['version'], + 'status' => 'draft', + 'is_active' => false, + ]); + $now = now(); + $rows = []; + + foreach ($files as $path => $content) { + $rows[] = [ + 'theme_id' => $theme->getKey(), + 'path' => $path, + 'content' => $content, + 'storage_key' => 'themes/'.$theme->getKey().'/'.$path, + 'sha256' => hash('sha256', $content), + 'byte_size' => strlen($content), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + DB::table('theme_files')->insert($rows); + + return $theme; + }); + + return [ + 'id' => (int) $theme->getKey(), + 'store_id' => (int) $theme->store_id, + 'name' => $theme->name, + 'version' => $theme->version, + 'status' => $theme->status, + 'published_at' => null, + 'created_at' => $theme->created_at?->toISOString(), + ]; + } + + /** @return array{id: int, status: string, published_at: string} */ + public function publishTheme(Theme $theme): array + { + $manifest = $this->themeManifest($theme); + + if (isset($manifest['settings_schema'])) { + $this->validateThemeSettingsSchema($manifest['settings_schema']); + } + $publishedAt = now(); + + DB::transaction(function () use ($theme, $publishedAt): void { + Theme::query() + ->where('store_id', $theme->store_id) + ->where('id', '!=', $theme->getKey()) + ->where('status', 'published') + ->update(['status' => 'draft', 'is_active' => false]); + + $theme->update(['status' => 'published', 'is_active' => true, 'published_at' => $publishedAt]); + }); + + return [ + 'id' => (int) $theme->getKey(), + 'status' => 'published', + 'published_at' => $publishedAt->toISOString(), + ]; + } + + /** @param array $settings + * @return array{id: int, settings_json: array} + */ + public function updateThemeSettings(Theme $theme, array $settings): array + { + $manifest = $this->themeManifest($theme); + $schema = $manifest['settings_schema'] ?? null; + + if ($schema !== null) { + $this->assertSchemaValue($settings, $schema, 'settings_json'); + } + + $theme->settings()->updateOrCreate( + ['theme_id' => $theme->getKey()], + ['settings_json' => $settings, 'updated_at' => now()], + ); + + return ['id' => (int) $theme->getKey(), 'settings_json' => $settings]; + } + + /** @return array{0: array{name: string, version: string, templates: list, settings_schema?: array}, 1: array} */ + private function readThemeArchive(ZipArchive $zip): array + { + if ($zip->numFiles < 2 || $zip->numFiles > self::MAX_THEME_FILES) { + throw ValidationException::withMessages(['file' => 'The theme archive must contain between 2 and 500 files.']); + } + + $files = []; + $totalBytes = 0; + + for ($index = 0; $index < $zip->numFiles; $index++) { + $entry = $zip->statIndex($index); + $path = $entry['name'] ?? ''; + + if (! is_string($path) || str_ends_with($path, '/')) { + continue; + } + + if ($this->isUnsafeThemePath($path)) { + throw ValidationException::withMessages(['file' => 'The theme archive contains an unsafe file path.']); + } + + if (strlen($path) > 255) { + throw ValidationException::withMessages(['file' => 'Theme file paths may not exceed 255 characters.']); + } + + if (isset($entry['size']) && $entry['size'] > self::MAX_THEME_ARCHIVE_BYTES) { + throw ValidationException::withMessages(['file' => 'A theme file exceeds the maximum archive size.']); + } + + $totalBytes += (int) ($entry['size'] ?? 0); + + if ($totalBytes > self::MAX_THEME_ARCHIVE_BYTES) { + throw ValidationException::withMessages(['file' => 'The expanded theme archive may not exceed 50 MB.']); + } + + $content = $zip->getFromIndex($index); + + if (! is_string($content)) { + throw ValidationException::withMessages(['file' => 'A theme file could not be read.']); + } + + $files[$path] = $content; + } + + if (! isset($files['theme.json'])) { + throw ValidationException::withMessages(['file' => 'The theme archive is missing its root theme.json manifest.']); + } + + try { + $manifest = json_decode($files['theme.json'], true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw ValidationException::withMessages(['file' => 'The theme manifest must contain valid JSON.']); + } + + if (! is_array($manifest) + || ! is_string($manifest['name'] ?? null) + || trim($manifest['name']) === '' + || mb_strlen($manifest['name']) > 255 + || ! is_string($manifest['version'] ?? null) + || mb_strlen($manifest['version']) > 255 + || ! preg_match('/\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/', $manifest['version']) + || ! is_array($manifest['templates'] ?? null) + || ! array_is_list($manifest['templates']) + || $manifest['templates'] === []) { + throw ValidationException::withMessages(['file' => 'The theme manifest requires a name, semantic version, and non-empty templates list.']); + } + + foreach ($manifest['templates'] as $templatePath) { + if (! is_string($templatePath) + || ! str_starts_with($templatePath, 'templates/') + || ! isset($files[$templatePath]) + || trim($files[$templatePath]) === '') { + throw ValidationException::withMessages(['file' => 'The theme archive is missing a required template declared in theme.json.']); + } + } + + if (array_key_exists('settings_schema', $manifest)) { + $this->validateThemeSettingsSchema($manifest['settings_schema']); + } + + return [$manifest, $files]; + } + + /** @return array */ + private function themeManifest(Theme $theme): array + { + $content = DB::table('theme_files')->where('theme_id', $theme->getKey())->where('path', 'theme.json')->value('content'); + + if (! is_string($content)) { + throw ValidationException::withMessages(['theme' => 'The theme manifest is missing.']); + } + + try { + $manifest = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw ValidationException::withMessages(['theme' => 'The theme manifest is invalid.']); + } + + if (! is_array($manifest)) { + throw ValidationException::withMessages(['theme' => 'The theme manifest is invalid.']); + } + + return $manifest; + } + + private function validateThemeSettingsSchema(mixed $schema): void + { + if (! is_array($schema) || ($schema['type'] ?? null) !== 'object') { + throw ValidationException::withMessages(['file' => 'The theme settings_schema must be a JSON Schema object.']); + } + + $this->validateSchemaDefinition($schema); + } + + /** @param array $schema */ + private function validateSchemaDefinition(array $schema): void + { + $types = ['object', 'array', 'string', 'integer', 'number', 'boolean', 'null']; + + if (! in_array($schema['type'] ?? null, $types, true)) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema contains an unsupported type.']); + } + + if (isset($schema['enum']) && (! is_array($schema['enum']) || $schema['enum'] === [])) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema enum must be a non-empty array.']); + } + + if (isset($schema['pattern'])) { + $pattern = $schema['pattern']; + + if (! is_string($pattern) || @preg_match('~'.str_replace('~', '\\~', $pattern).'~u', '') === false) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema contains an invalid pattern.']); + } + } + + if (isset($schema['required']) && (! is_array($schema['required']) || ! array_is_list($schema['required']) || collect($schema['required'])->contains(fn (mixed $key): bool => ! is_string($key)))) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema required list is invalid.']); + } + + if (isset($schema['properties'])) { + if (! is_array($schema['properties']) || array_is_list($schema['properties']) && $schema['properties'] !== []) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema properties must be an object.']); + } + + foreach ($schema['properties'] as $propertySchema) { + if (! is_array($propertySchema)) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema property definition is invalid.']); + } + + $this->validateSchemaDefinition($propertySchema); + } + } + + if (isset($schema['items'])) { + if (! is_array($schema['items'])) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema array item definition is invalid.']); + } + + $this->validateSchemaDefinition($schema['items']); + } + + $additionalProperties = $schema['additionalProperties'] ?? true; + + if (! is_bool($additionalProperties) && ! is_array($additionalProperties)) { + throw ValidationException::withMessages(['file' => 'The theme settings_schema additionalProperties setting is invalid.']); + } + + if (is_array($additionalProperties)) { + $this->validateSchemaDefinition($additionalProperties); + } + } + + /** @param array $schema */ + private function assertSchemaValue(mixed $value, array $schema, string $path): void + { + $type = $schema['type'] ?? null; + $validType = match ($type) { + 'object' => is_array($value) && ($value === [] || ! array_is_list($value)), + 'array' => is_array($value) && array_is_list($value), + 'string' => is_string($value), + 'integer' => is_int($value), + 'number' => is_int($value) || is_float($value), + 'boolean' => is_bool($value), + 'null' => $value === null, + default => false, + }; + + if (! $validType) { + throw ValidationException::withMessages([$path => "The {$path} value does not match its theme settings schema."]); + } + + if (array_key_exists('enum', $schema) && ! in_array($value, $schema['enum'], true)) { + throw ValidationException::withMessages([$path => "The {$path} value is not an allowed theme setting."]); + } + + if (is_string($value)) { + if (isset($schema['minLength']) && mb_strlen($value) < $schema['minLength']) { + throw ValidationException::withMessages([$path => "The {$path} value is shorter than the theme schema allows."]); + } + + if (isset($schema['maxLength']) && mb_strlen($value) > $schema['maxLength']) { + throw ValidationException::withMessages([$path => "The {$path} value is longer than the theme schema allows."]); + } + + $pattern = $schema['pattern'] ?? null; + + if (isset($pattern) && (! is_string($pattern) || @preg_match('~'.str_replace('~', '\\~', $pattern).'~u', '') === false || @preg_match('~'.str_replace('~', '\\~', $pattern).'~u', $value) !== 1)) { + throw ValidationException::withMessages([$path => "The {$path} value does not match the theme schema pattern."]); + } + } + + if ((is_int($value) || is_float($value)) && isset($schema['minimum']) && $value < $schema['minimum']) { + throw ValidationException::withMessages([$path => "The {$path} value is below the theme schema minimum."]); + } + + if ((is_int($value) || is_float($value)) && isset($schema['maximum']) && $value > $schema['maximum']) { + throw ValidationException::withMessages([$path => "The {$path} value is above the theme schema maximum."]); + } + + if (is_array($value) && array_is_list($value)) { + if (isset($schema['minItems']) && count($value) < $schema['minItems']) { + throw ValidationException::withMessages([$path => "The {$path} array has too few items."]); + } + + if (isset($schema['maxItems']) && count($value) > $schema['maxItems']) { + throw ValidationException::withMessages([$path => "The {$path} array has too many items."]); + } + + if (isset($schema['items'])) { + foreach ($value as $index => $item) { + $this->assertSchemaValue($item, $schema['items'], "{$path}.{$index}"); + } + } + } + + if ($type !== 'object') { + return; + } + + $properties = $schema['properties'] ?? []; + $required = $schema['required'] ?? []; + + if (! is_array($properties) || ! is_array($required)) { + throw ValidationException::withMessages(['theme' => 'The theme settings schema is invalid.']); + } + + foreach ($required as $key) { + if (! is_string($key) || ! array_key_exists($key, $value)) { + throw ValidationException::withMessages(["{$path}.{$key}" => 'This theme setting is required.']); + } + } + + foreach ($value as $key => $setting) { + if (! array_key_exists($key, $properties)) { + if (($schema['additionalProperties'] ?? true) === false) { + throw ValidationException::withMessages(["{$path}.{$key}" => 'This theme setting is not defined by the theme schema.']); + } + + if (is_array($schema['additionalProperties'] ?? null)) { + $this->assertSchemaValue($setting, $schema['additionalProperties'], "{$path}.{$key}"); + } + + continue; + } + + if (! is_array($properties[$key])) { + throw ValidationException::withMessages(['theme' => 'The theme settings schema is invalid.']); + } + + $this->assertSchemaValue($setting, $properties[$key], "{$path}.{$key}"); + } + } + + private function isUnsafeThemePath(string $path): bool + { + return str_starts_with($path, '/') + || str_contains($path, "\0") + || str_contains($path, '\\') + || preg_match('/\A[A-Za-z]:/', $path) === 1 + || in_array('..', explode('/', $path), true) + || str_starts_with($path, './'); + } +} diff --git a/app/Services/AnalyticsReportingService.php b/app/Services/AnalyticsReportingService.php new file mode 100644 index 00000000..1b974e95 --- /dev/null +++ b/app/Services/AnalyticsReportingService.php @@ -0,0 +1,193 @@ +, + * top_products: list, + * top_referrers: list + * } + */ + public function report( + Store $store, + CarbonImmutable $from, + CarbonImmutable $to, + string $channel = 'all', + string $device = 'all', + ): array { + if ($to->lt($from) || $from->diffInDays($to) > 364) { + throw new InvalidArgumentException('Analytics date range must be ordered and no longer than 365 days.'); + } + + if (! in_array($channel, ['all', 'storefront', 'api'], true)) { + throw new InvalidArgumentException('Unsupported analytics channel.'); + } + + if (! in_array($device, ['all', 'desktop', 'mobile', 'tablet'], true)) { + throw new InvalidArgumentException('Unsupported analytics device.'); + } + + $fromDate = $from->startOfDay(); + $toDate = $to->endOfDay(); + $orderQuery = DB::table('orders') + ->where('orders.store_id', $store->getKey()) + ->whereBetween('orders.placed_at', [$fromDate->toDateTimeString(), $toDate->toDateTimeString()]) + ->whereIn('orders.financial_status', ['paid', 'partially_refunded']); + $eventOrderIds = $this->checkoutOrderIds($store, $fromDate, $toDate, $channel, $device); + + if ($eventOrderIds !== null) { + $orderQuery->whereIn('orders.id', $eventOrderIds); + } + + $dailyOrders = (clone $orderQuery) + ->selectRaw('DATE(orders.placed_at) AS report_date') + ->selectRaw('COUNT(*) AS orders_count') + ->selectRaw( + 'COALESCE(SUM(MAX(0, orders.total_amount - COALESCE((SELECT SUM(refunds.amount) FROM refunds WHERE refunds.order_id = orders.id AND refunds.status = ?), 0))), 0) AS revenue_amount', + ['processed'], + ) + ->groupByRaw('DATE(orders.placed_at)') + ->orderBy('report_date') + ->get() + ->keyBy('report_date'); + + $totalSales = (int) $dailyOrders->sum('revenue_amount'); + $ordersCount = (int) $dailyOrders->sum('orders_count'); + $daily = []; + + for ($date = $fromDate; $date->lte($toDate); $date = $date->addDay()) { + $dateString = $date->toDateString(); + $row = $dailyOrders->get($dateString); + $revenueAmount = (int) ($row->revenue_amount ?? 0); + + $daily[] = [ + 'date' => $dateString, + 'revenue_amount' => $revenueAmount, + 'orders_count' => (int) ($row->orders_count ?? 0), + 'revenue_percent' => $totalSales > 0 ? round(($revenueAmount / $totalSales) * 100, 2) : 0.0, + ]; + } + + $eventMetrics = $this->eventsQuery($store, $fromDate, $toDate, $channel, $device) + ->selectRaw('COUNT(DISTINCT CASE WHEN type = ? THEN session_id END) AS visits_count', ['page_view']) + ->selectRaw('SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS add_to_cart_count', ['add_to_cart']) + ->selectRaw('SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS checkout_started_count', ['checkout_started']) + ->first(); + $visitsCount = (int) ($eventMetrics->visits_count ?? 0); + $topProductsQuery = DB::table('order_lines') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $store->getKey()) + ->whereBetween('orders.placed_at', [$fromDate->toDateTimeString(), $toDate->toDateTimeString()]) + ->whereIn('orders.financial_status', ['paid', 'partially_refunded']); + + if ($eventOrderIds !== null) { + $topProductsQuery->whereIn('orders.id', $eventOrderIds); + } + + $topProducts = $topProductsQuery + ->select(['order_lines.product_id', 'order_lines.title_snapshot as title']) + ->selectRaw('SUM(order_lines.quantity) AS units_sold') + ->selectRaw('SUM(order_lines.total_amount) AS revenue_amount') + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->orderByDesc('revenue_amount') + ->orderBy('title') + ->limit(20) + ->get() + ->map(static fn (object $row): array => [ + 'product_id' => $row->product_id === null ? null : (int) $row->product_id, + 'title' => (string) $row->title, + 'units_sold' => (int) $row->units_sold, + 'revenue_amount' => (int) $row->revenue_amount, + 'revenue_percent' => $totalSales > 0 ? round(((int) $row->revenue_amount / $totalSales) * 100, 2) : 0.0, + ]) + ->all(); + + $referrerSessions = $this->eventsQuery($store, $fromDate, $toDate, $channel, $device) + ->where('type', 'page_view') + ->whereNotNull('session_id') + ->select('session_id') + ->selectRaw("COALESCE(NULLIF(json_extract(properties_json, '$.referrer'), ''), 'Direct') AS source") + ->groupBy('session_id', 'source'); + $completedOrders = $this->eventsQuery($store, $fromDate, $toDate, $channel, $device) + ->where('type', 'checkout_completed') + ->whereNotNull('session_id') + ->whereNotNull(DB::raw("json_extract(properties_json, '$.order_id')")) + ->select('session_id') + ->selectRaw("CAST(json_extract(properties_json, '$.order_id') AS TEXT) AS order_id") + ->groupBy('session_id', 'order_id'); + $topReferrers = DB::query() + ->fromSub($referrerSessions, 'referrer_sessions') + ->leftJoinSub($completedOrders, 'completed_orders', 'completed_orders.session_id', '=', 'referrer_sessions.session_id') + ->select('referrer_sessions.source') + ->selectRaw('COUNT(DISTINCT referrer_sessions.session_id) AS sessions') + ->selectRaw('COUNT(DISTINCT completed_orders.order_id) AS orders') + ->groupBy('referrer_sessions.source') + ->orderByDesc('sessions') + ->orderBy('referrer_sessions.source') + ->limit(10) + ->get() + ->map(static fn (object $row): array => [ + 'source' => (string) $row->source, + 'sessions' => (int) $row->sessions, + 'orders' => (int) $row->orders, + 'conversion_rate' => (int) $row->sessions > 0 ? round(((int) $row->orders / (int) $row->sessions) * 100, 2) : 0.0, + ]) + ->all(); + + return [ + 'from' => $fromDate->toDateString(), + 'to' => $toDate->toDateString(), + 'currency' => $store->default_currency, + 'total_sales' => $totalSales, + 'orders_count' => $ordersCount, + 'average_order_value' => $ordersCount > 0 ? intdiv($totalSales, $ordersCount) : 0, + 'conversion_rate' => $visitsCount > 0 ? round(($ordersCount / $visitsCount) * 100, 2) : 0.0, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => (int) ($eventMetrics->add_to_cart_count ?? 0), + 'checkout_started_count' => (int) ($eventMetrics->checkout_started_count ?? 0), + 'daily' => $daily, + 'top_products' => $topProducts, + 'top_referrers' => $topReferrers, + ]; + } + + private function checkoutOrderIds(Store $store, CarbonImmutable $from, CarbonImmutable $to, string $channel, string $device): ?Builder + { + if ($channel === 'all' && $device === 'all') { + return null; + } + + return $this->eventsQuery($store, $from, $to, $channel, $device) + ->where('type', 'checkout_completed') + ->whereNotNull(DB::raw("json_extract(properties_json, '$.order_id')")) + ->selectRaw("CAST(json_extract(properties_json, '$.order_id') AS INTEGER)"); + } + + private function eventsQuery(Store $store, CarbonImmutable $from, CarbonImmutable $to, string $channel, string $device): Builder + { + return DB::table('analytics_events') + ->where('store_id', $store->getKey()) + ->whereBetween(DB::raw('COALESCE(occurred_at, created_at)'), [$from->toDateTimeString(), $to->toDateTimeString()]) + ->when($channel !== 'all', fn (Builder $query): Builder => $query->whereRaw("json_extract(properties_json, '$.channel') = ?", [$channel])) + ->when($device !== 'all', fn (Builder $query): Builder => $query->whereRaw("json_extract(properties_json, '$.device') = ?", [$device])); + } +} diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..4a1a3ab2 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,78 @@ +aggregateForDate(now('UTC')->subDay()); + } + + public function aggregateForDate(DateTimeInterface|string|null $date = null): int + { + $aggregationDate = $date === null + ? now('UTC')->subDay()->startOfDay() + : CarbonImmutable::parse($date, 'UTC')->utc()->startOfDay(); + $dateString = $aggregationDate->toDateString(); + + $eventMetrics = DB::table('analytics_events') + ->select('store_id') + ->whereRaw('date(COALESCE(occurred_at, created_at)) = ?', [$dateString]) + ->selectRaw('COUNT(DISTINCT CASE WHEN type = ? THEN session_id END) AS visits_count', ['page_view']) + ->selectRaw('SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS add_to_cart_count', ['add_to_cart']) + ->selectRaw('SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS checkout_started_count', ['checkout_started']) + ->groupBy('store_id') + ->get() + ->keyBy('store_id'); + + $salesMetrics = DB::table('orders') + ->select('store_id') + ->whereDate('placed_at', $dateString) + ->whereIn('financial_status', ['paid', 'partially_refunded']) + ->selectRaw('COUNT(*) AS orders_count') + ->selectRaw('SUM(MAX(0, total_amount - (SELECT COALESCE(SUM(refunds.amount), 0) FROM refunds WHERE refunds.order_id = orders.id AND refunds.status = ?))) AS revenue_amount', ['processed']) + ->groupBy('store_id') + ->get() + ->keyBy('store_id'); + + $dailyMetrics = []; + $storeIds = DB::table('stores')->orderBy('id')->pluck('id'); + + foreach ($storeIds as $storeId) { + $storeId = (int) $storeId; + $eventData = $eventMetrics->get($storeId); + $salesData = $salesMetrics->get($storeId); + $ordersCount = (int) ($salesData->orders_count ?? 0); + $revenueAmount = (int) ($salesData->revenue_amount ?? 0); + + $dailyMetrics[] = [ + 'store_id' => $storeId, + 'date' => $dateString, + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0, + 'visits_count' => (int) ($eventData->visits_count ?? 0), + 'add_to_cart_count' => (int) ($eventData->add_to_cart_count ?? 0), + 'checkout_started_count' => (int) ($eventData->checkout_started_count ?? 0), + 'checkout_completed_count' => $ordersCount, + ]; + } + + if ($dailyMetrics === []) { + return 0; + } + + DB::table('analytics_daily')->upsert( + $dailyMetrics, + ['store_id', 'date'], + ['orders_count', 'revenue_amount', 'aov_amount', 'visits_count', 'add_to_cart_count', 'checkout_started_count', 'checkout_completed_count'], + ); + + return count($dailyMetrics); + } +} diff --git a/app/Services/ApiTokenService.php b/app/Services/ApiTokenService.php new file mode 100644 index 00000000..7bd4b04c --- /dev/null +++ b/app/Services/ApiTokenService.php @@ -0,0 +1,72 @@ + */ + private const ALLOWED_ABILITIES = [ + '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', + 'manage-platform', + ]; + + /** @param list $abilities + * @return array{token: PersonalAccessToken, plain_text_token: string} + */ + public function create(User $user, Store $store, string $name, array $abilities, ?\DateTimeInterface $expiresAt = null): array + { + abort_unless($user->stores()->whereKey($store->getKey())->exists(), 403); + + foreach ($abilities as $ability) { + if (! is_string($ability) || ! in_array($ability, self::ALLOWED_ABILITIES, true)) { + throw new InvalidArgumentException('One or more API token abilities are not supported.'); + } + } + + $plainTextToken = 'shop_'.Str::random(64); + $token = $user->tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => $name, + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => $expiresAt ?? now()->addYear(), + ]); + + return ['token' => $token, 'plain_text_token' => $plainTextToken]; + } + + public function revoke(PersonalAccessToken $token, Store $store): void + { + abort_unless((int) $token->store_id === (int) $store->getKey(), 404); + + $token->delete(); + } + + /** @return list */ + public function availableAbilities(): array + { + return array_values(array_diff(self::ALLOWED_ABILITIES, ['manage-platform'])); + } +} diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 00000000..2f382f79 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,53 @@ + $extra */ + public function log( + string $event, + ?int $userId = null, + ?int $storeId = null, + ?string $resourceType = null, + ?int $resourceId = null, + array $extra = [], + ): void { + $request = app()->bound('request') ? app('request') : null; + $context = array_merge($this->redactSensitiveValues($extra), [ + 'timestamp' => now()->toIso8601String(), + 'event' => $event, + 'user_id' => $userId, + 'store_id' => $storeId, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'ip' => $request instanceof HttpRequest ? ($request->ip() ?? '') : '', + 'user_agent' => $request instanceof HttpRequest ? ($request->userAgent() ?? '') : '', + ]); + + Log::channel('audit')->info($event, $context); + } + + /** @param array $values + * @return array + */ + private function redactSensitiveValues(array $values): array + { + $redacted = []; + + foreach ($values as $key => $value) { + if (preg_match('/password|secret|token/i', (string) $key) === 1) { + $redacted[$key] = '[redacted]'; + } elseif (is_array($value)) { + $redacted[$key] = $this->redactSensitiveValues($value); + } else { + $redacted[$key] = $value; + } + } + + return $redacted; + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..9f807d42 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,355 @@ +firstOrCreate( + ['store_id' => $store->id, 'customer_id' => $customer->id, 'status' => 'active'], + ['currency' => $store->default_currency, 'cart_version' => 1], + )->load('lines.variant.product', 'lines.variant.inventoryItem'); + } + + $sessionCartId = Session::get('cart_id'); + $cart = $sessionCartId + ? Cart::withoutGlobalScopes()->where('store_id', $store->id)->where('status', 'active')->find($sessionCartId) + : null; + + if (! $cart) { + $cart = Cart::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => 'active', + ]); + Session::put('cart_id', $cart->id); + } + + return $cart->load('lines.variant.product', 'lines.variant.inventoryItem'); + } + + public function findActiveCart(Store $store, ?Customer $customer = null): ?Cart + { + $query = Cart::withoutGlobalScopes()->where('store_id', $store->id)->where('status', 'active'); + + if ($customer) { + $query->where('customer_id', $customer->id); + } else { + $query->whereKey(Session::get('cart_id', 0))->whereNull('customer_id'); + } + + return $query->with('lines.variant.product', 'lines.variant.inventoryItem')->first(); + } + + public function add(Cart $cart, ProductVariant $variant, int $quantity, ?int $expectedVersion = null): Cart + { + if ($quantity < 1 || $quantity > 9999) { + throw ValidationException::withMessages(['quantity' => 'Choose a quantity between 1 and 9999.']); + } + + return DB::transaction(function () use ($cart, $variant, $quantity, $expectedVersion): Cart { + $lockedCart = Cart::withoutGlobalScopes()->whereKey($cart->id)->lockForUpdate()->firstOrFail(); + $this->assertVersion($lockedCart, $expectedVersion); + + $variant->loadMissing(['product', 'inventoryItem']); + + if (! $variant->product || (int) $variant->product->store_id !== (int) $lockedCart->store_id) { + abort(404); + } + + if ($variant->product->status !== 'active' || $variant->status !== 'active') { + throw ValidationException::withMessages(['variant' => 'This product is not available for purchase.']); + } + + $line = $lockedCart->lines()->where('variant_id', $variant->id)->first(); + $newQuantity = (int) ($line?->quantity ?? 0) + $quantity; + + if ($newQuantity > 9999) { + throw ValidationException::withMessages(['quantity' => 'A cart line cannot exceed 9999 items.']); + } + + if ($variant->inventoryItem && ! $this->inventory->checkAvailability($variant->inventoryItem, $newQuantity)) { + throw new InsufficientInventoryException('There is not enough stock available for this quantity.'); + } + + $subtotal = $variant->price_amount * $newQuantity; + $line?->delete(); + $lockedCart->lines()->create([ + 'variant_id' => $variant->id, + 'quantity' => $newQuantity, + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]); + $lockedCart->increment('cart_version'); + $this->refreshDiscountAllocations($lockedCart); + + return $lockedCart->refresh()->load('lines.variant.product', 'lines.variant.inventoryItem'); + }); + } + + public function updateQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): Cart + { + return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): Cart { + $lockedCart = Cart::withoutGlobalScopes()->whereKey($cart->id)->lockForUpdate()->firstOrFail(); + $this->assertVersion($lockedCart, $expectedVersion); + $line = $lockedCart->lines()->with('variant.inventoryItem')->findOrFail($lineId); + + if ($quantity <= 0) { + $line->delete(); + } else { + if ($quantity > 9999) { + throw ValidationException::withMessages(['quantity' => 'Choose a quantity between 1 and 9999.']); + } + + if ($line->variant->inventoryItem && ! $this->inventory->checkAvailability($line->variant->inventoryItem, $quantity)) { + throw new InsufficientInventoryException('There is not enough stock available for this quantity.'); + } + + $line->quantity = $quantity; + $line->line_subtotal_amount = $line->unit_price_amount * $quantity; + $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount; + $line->save(); + } + + $lockedCart->increment('cart_version'); + $this->refreshDiscountAllocations($lockedCart); + + return $lockedCart->refresh()->load('lines.variant.product', 'lines.variant.inventoryItem'); + }); + } + + public function remove(Cart $cart, int $lineId, ?int $expectedVersion = null): Cart + { + return $this->updateQuantity($cart, $lineId, 0, $expectedVersion); + } + + public function mergeGuestCart(Cart $guestCart, Customer $customer): Cart + { + return DB::transaction(function () use ($guestCart, $customer): Cart { + $customerCart = Cart::withoutGlobalScopes()->firstOrCreate( + ['store_id' => $guestCart->store_id, 'customer_id' => $customer->id, 'status' => 'active'], + ['currency' => $guestCart->currency, 'cart_version' => 1], + ); + + foreach ($guestCart->lines()->with('variant')->get() as $guestLine) { + $customerLine = $customerCart->lines()->where('variant_id', $guestLine->variant_id)->first(); + + if ($customerLine) { + $customerLine->quantity = max($customerLine->quantity, $guestLine->quantity); + $customerLine->unit_price_amount = $guestLine->variant->price_amount; + $customerLine->line_subtotal_amount = $customerLine->unit_price_amount * $customerLine->quantity; + $customerLine->line_total_amount = $customerLine->line_subtotal_amount; + $customerLine->save(); + $guestLine->delete(); + } else { + $guestLine->cart_id = $customerCart->id; + $guestLine->unit_price_amount = $guestLine->variant->price_amount; + $guestLine->line_subtotal_amount = $guestLine->unit_price_amount * $guestLine->quantity; + $guestLine->line_total_amount = $guestLine->line_subtotal_amount; + $guestLine->save(); + } + } + + $guestCart->status = 'abandoned'; + $guestCart->save(); + $customerCart->increment('cart_version'); + if (! $customerCart->discount_code && $guestCart->discount_code) { + $customerCart->forceFill(['discount_code' => $guestCart->discount_code])->save(); + } + $this->refreshDiscountAllocations($customerCart); + Session::forget('cart_id'); + + return $customerCart->refresh()->load('lines.variant.product'); + }); + } + + public function applyDiscount(Cart $cart, string $code): Cart + { + $discount = Discount::query() + ->where('store_id', $cart->store_id) + ->whereRaw('upper(code) = ?', [strtoupper(trim($code))]) + ->first(); + + if (! $discount) { + throw ValidationException::withMessages(['discount_code' => 'This discount code is not valid.']); + } + $cart->load('lines.variant.product.collections'); + $lines = $this->discountLines($cart); + $result = $this->discountCalculator->calculateStacked( + [$discount, ...$this->automaticDiscounts($cart->store_id)], + $lines, + array_sum(array_column($lines, 'line_subtotal_amount')), + $cart->customer_id, + $this->customerEmail($cart->customer_id), + $cart->customer_id !== null, + ); + + DB::transaction(function () use ($cart, $discount, $result): void { + $cart->forceFill(['discount_code' => $discount->code])->save(); + $cart->increment('cart_version'); + $this->storeCartDiscountAllocations($cart, $result['allocations']); + }); + + return $cart->refresh()->load('lines.variant.product'); + } + + public function removeDiscount(Cart $cart): Cart + { + DB::transaction(function () use ($cart): void { + $cart->forceFill(['discount_code' => null])->save(); + $cart->increment('cart_version'); + $this->refreshDiscountAllocations($cart); + }); + + return $cart->refresh()->load('lines.variant.product'); + } + + /** @return array{items: int, subtotal: int} */ + public function summary(Cart $cart): array + { + return [ + 'items' => (int) $cart->lines()->sum('quantity'), + 'subtotal' => (int) $cart->lines()->sum('line_subtotal_amount'), + ]; + } + + private function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && $expectedVersion !== (int) $cart->cart_version) { + throw new CartVersionConflictException((int) $cart->cart_version); + } + } + + private function refreshDiscountAllocations(Cart $cart): void + { + $cart->load('lines.variant.product.collections'); + + if ($cart->lines->isEmpty()) { + $this->clearDiscountAllocations($cart); + + return; + } + + $codeDiscount = $cart->discount_code + ? Discount::withoutGlobalScopes() + ->where('store_id', $cart->store_id) + ->whereRaw('upper(code) = ?', [strtoupper(trim($cart->discount_code))]) + ->first() + : null; + + if ($cart->discount_code && ! $codeDiscount) { + $cart->forceFill(['discount_code' => null])->save(); + } + + $lines = $this->discountLines($cart); + $automaticDiscounts = $this->automaticDiscounts($cart->store_id); + $discounts = [...($codeDiscount ? [$codeDiscount] : []), ...$automaticDiscounts]; + + try { + $result = $this->discountCalculator->calculateStacked( + $discounts, + $lines, + array_sum(array_column($lines, 'line_subtotal_amount')), + $cart->customer_id, + $this->customerEmail($cart->customer_id), + $cart->customer_id !== null, + ); + } catch (ValidationException) { + $cart->forceFill(['discount_code' => null])->save(); + $result = $this->discountCalculator->calculateStacked( + $automaticDiscounts, + $lines, + array_sum(array_column($lines, 'line_subtotal_amount')), + $cart->customer_id, + $this->customerEmail($cart->customer_id), + $cart->customer_id !== null, + ); + } + + $this->storeCartDiscountAllocations($cart, $result['allocations']); + } + + /** @return list> */ + private function discountLines(Cart $cart): array + { + return $cart->lines->map(static fn ($line): array => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'product_id' => $line->variant?->product_id, + 'collection_ids' => $line->variant?->product?->collections?->pluck('id')->all() ?? [], + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_subtotal_amount' => $line->line_subtotal_amount, + ])->all(); + } + + /** @return list */ + private function automaticDiscounts(int $storeId): array + { + return Discount::withoutGlobalScopes() + ->where('store_id', $storeId) + ->whereNull('code') + ->where('is_active', true) + ->orderBy('id') + ->get() + ->filter(static fn (Discount $discount): bool => ($discount->rules_json['activation_method'] ?? null) === 'automatic') + ->values() + ->all(); + } + + private function customerEmail(?int $customerId): ?string + { + return $customerId === null + ? null + : Customer::withoutGlobalScopes()->whereKey($customerId)->value('email'); + } + + /** @param array $allocations */ + private function storeCartDiscountAllocations(Cart $cart, array $allocations): void + { + foreach ($cart->lines as $line) { + $amount = (int) ($allocations[$line->variant_id] ?? 0); + $line->forceFill([ + 'line_discount_amount' => $amount, + 'line_total_amount' => max(0, $line->line_subtotal_amount - $amount), + ])->save(); + } + } + + private function clearDiscountAllocations(Cart $cart): void + { + foreach ($cart->lines as $line) { + if ($line->line_discount_amount !== 0) { + $line->forceFill([ + 'line_discount_amount' => 0, + 'line_total_amount' => $line->line_subtotal_amount, + ])->save(); + } + } + + if ($cart->discount_code !== null) { + $cart->forceFill(['discount_code' => null])->save(); + } + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..36dcfb6a --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,478 @@ +loadMissing('lines.variant.product'); + + if ($cart->status !== 'active' || $cart->lines->isEmpty()) { + throw ValidationException::withMessages(['cart' => 'Add an available product to your cart before checkout.']); + } + + return Checkout::withoutGlobalScopes()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $customer?->id ?? $cart->customer_id, + 'status' => CheckoutStatus::Started->value, + 'discount_code' => $cart->discount_code, + 'expires_at' => now()->addHours(24), + ]); + } + + public function setContact(Checkout $checkout, string $email): Checkout + { + if ($checkout->status !== CheckoutStatus::Started->value) { + throw new InvalidCheckoutTransitionException('Contact information cannot be changed at this checkout stage.'); + } + + Validator::make(['email' => $email], ['email' => ['required', 'email', 'max:255']])->validate(); + + $checkout->forceFill(['email' => $email])->save(); + + return $this->recalculate($checkout->refresh()); + } + + /** @param array $shippingAddress + * @param array|null $billingAddress + */ + public function setAddress(Checkout $checkout, string $email, array $shippingAddress, ?array $billingAddress = null): Checkout + { + if (! in_array($checkout->status, [CheckoutStatus::Started->value, CheckoutStatus::Addressed->value], true)) { + throw new InvalidCheckoutTransitionException('Address details cannot be changed at this checkout stage.'); + } + + $shippingAddress = $this->normalizeAddress($shippingAddress); + $billingAddress = $billingAddress === null ? null : $this->normalizeAddress($billingAddress); + $requiresShipping = $this->requiresShipping($checkout); + $requiredAddressField = $requiresShipping ? 'required' : 'nullable'; + $validCountryCode = static function (string $attribute, mixed $value, \Closure $fail): void { + $countries = class_exists(\ResourceBundle::class) + ? \ResourceBundle::create('en', 'ICUDATA-region')?->get('Countries') + : null; + + $countryName = $countries instanceof \ResourceBundle + ? $countries->get(strtoupper((string) $value)) + : null; + + if (! is_string($countryName) || in_array($countryName, ['Unknown Region', 'Pseudo-Accents', 'Pseudo-Bidi'], true)) { + $fail('The country code must be a valid ISO 3166-1 alpha-2 code.'); + } + }; + + $validator = Validator::make([ + 'email' => $email, + 'shipping_address' => $shippingAddress, + 'billing_address' => $billingAddress, + ], [ + 'email' => ['required', 'email', 'max:255'], + 'shipping_address' => [$requiredAddressField, 'array'], + 'shipping_address.first_name' => [$requiredAddressField, 'string', 'max:255'], + 'shipping_address.last_name' => [$requiredAddressField, 'string', 'max:255'], + 'shipping_address.address1' => [$requiredAddressField, 'string', 'max:500'], + 'shipping_address.address2' => ['nullable', 'string', 'max:500'], + 'shipping_address.city' => [$requiredAddressField, 'string', 'max:255'], + 'shipping_address.province' => ['nullable', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country' => [$requiredAddressField, 'string', 'max:255'], + 'shipping_address.country_code' => [$requiredAddressField, 'string', 'size:2', $validCountryCode], + 'shipping_address.zip' => [$requiredAddressField, 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + 'billing_address' => ['nullable', 'array'], + 'billing_address.first_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.last_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.address1' => ['required_with:billing_address', 'string', 'max:500'], + 'billing_address.address2' => ['nullable', 'string', 'max:500'], + 'billing_address.city' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.province' => ['nullable', 'string', 'max:255'], + 'billing_address.province_code' => ['nullable', 'string', 'max:10'], + 'billing_address.country' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.country_code' => ['required_with:billing_address', 'string', 'size:2', $validCountryCode], + 'billing_address.zip' => ['required_with:billing_address', 'string', 'max:20'], + 'billing_address.phone' => ['nullable', 'string', 'max:50'], + ]); + + $validator->validate(); + + if ($requiresShipping) { + $shippingAddress['country_code'] = strtoupper((string) $shippingAddress['country_code']); + $shippingAddress['zip'] = (string) $shippingAddress['zip']; + } else { + $shippingAddress = []; + } + + if ($billingAddress !== null) { + $billingAddress['country_code'] = strtoupper((string) $billingAddress['country_code']); + $billingAddress['zip'] = (string) $billingAddress['zip']; + } + + $billingAddress ??= $shippingAddress; + + $checkout->forceFill([ + 'email' => $email, + 'shipping_address_json' => $shippingAddress, + 'billing_address_json' => $billingAddress, + 'status' => CheckoutStatus::Addressed->value, + ])->save(); + + return $this->recalculate($checkout->refresh()); + } + + /** @return \Illuminate\Support\Collection */ + public function availableShippingRates(Checkout $checkout): \Illuminate\Support\Collection + { + return $this->shippingCalculator->ratesForCheckout($checkout); + } + + public function selectShippingMethod(Checkout $checkout, ?int $shippingRateId = null): Checkout + { + if ($checkout->status !== CheckoutStatus::Addressed->value) { + throw new InvalidCheckoutTransitionException('Provide a shipping address before selecting delivery.'); + } + + if (! $this->requiresShipping($checkout)) { + $checkout->forceFill(['shipping_method_id' => null, 'shipping_amount' => 0, 'status' => CheckoutStatus::ShippingSelected->value])->save(); + + return $this->recalculate($checkout->refresh()); + } + + $availableRates = $this->availableShippingRates($checkout); + $rate = $availableRates->firstWhere('id', $shippingRateId); + + if (! $rate) { + throw ValidationException::withMessages(['shipping_method_id' => 'Choose a valid shipping rate for your address.']); + } + + $checkout->forceFill([ + 'shipping_method_id' => $rate->id, + 'shipping_amount' => $rate->price_amount, + 'status' => CheckoutStatus::ShippingSelected->value, + ])->save(); + + return $this->recalculate($checkout->refresh()); + } + + public function applyDiscount(Checkout $checkout, string $code): Checkout + { + if (! in_array($checkout->status, [CheckoutStatus::Started->value, CheckoutStatus::Addressed->value, CheckoutStatus::ShippingSelected->value], true)) { + throw new InvalidCheckoutTransitionException('Discount codes cannot be changed at this checkout stage.'); + } + + $discount = Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('upper(code) = ?', [strtoupper(trim($code))]) + ->first(); + + if (! $discount) { + throw ValidationException::withMessages(['discount_code' => 'This discount code is not valid.']); + } + + return DB::transaction(function () use ($checkout, $discount): Checkout { + $checkout->forceFill(['discount_code' => $discount->code])->save(); + + return $this->recalculate($checkout->refresh()); + }); + } + + public function removeDiscount(Checkout $checkout): Checkout + { + if (! in_array($checkout->status, [CheckoutStatus::Started->value, CheckoutStatus::Addressed->value, CheckoutStatus::ShippingSelected->value], true)) { + throw new InvalidCheckoutTransitionException('Discount codes cannot be changed at this checkout stage.'); + } + + if (! $checkout->discount_code) { + abort(404, 'No discount is applied to this checkout.'); + } + + $checkout->forceFill(['discount_code' => null])->save(); + + return $this->recalculate($checkout->refresh()); + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod $method): Checkout + { + if ($checkout->status !== CheckoutStatus::ShippingSelected->value) { + throw new InvalidCheckoutTransitionException('Select your address and shipping method before payment.'); + } + + return DB::transaction(function () use ($checkout, $method): Checkout { + $checkout = Checkout::withoutGlobalScopes()->with('cart.lines.variant.inventoryItem')->lockForUpdate()->findOrFail($checkout->id); + + foreach ($checkout->cart->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventory->reserve($line->variant->inventoryItem, $line->quantity); + } + } + + $checkout->forceFill([ + 'payment_method' => $method->value, + 'status' => CheckoutStatus::PaymentSelected->value, + 'expires_at' => now()->addHours(24), + ])->save(); + + return $checkout->refresh(); + }); + } + + public function changePaymentMethod(Checkout $checkout, PaymentMethod $method): Checkout + { + if ($checkout->status !== CheckoutStatus::PaymentSelected->value) { + throw new InvalidCheckoutTransitionException('Select your address and shipping method before changing payment.'); + } + + $checkout->forceFill(['payment_method' => $method->value])->save(); + + return $checkout->refresh(); + } + + public function releaseReservations(Checkout $checkout): void + { + if ($checkout->status !== CheckoutStatus::PaymentSelected->value) { + return; + } + + DB::transaction(function () use ($checkout): void { + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + + foreach ($checkout->cart->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventory->release($line->variant->inventoryItem, $line->quantity); + } + } + }); + } + + public function expire(Checkout $checkout): void + { + if (in_array($checkout->status, [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value], true)) { + return; + } + + DB::transaction(function () use ($checkout): void { + $lockedCheckout = Checkout::withoutGlobalScopes() + ->with('cart.lines.variant.inventoryItem') + ->lockForUpdate() + ->findOrFail($checkout->id); + + if ($lockedCheckout->status === CheckoutStatus::PaymentSelected->value) { + foreach ($lockedCheckout->cart->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventory->release($line->variant->inventoryItem, $line->quantity); + } + } + } + + $lockedCheckout->forceFill(['status' => CheckoutStatus::Expired->value])->save(); + }); + } + + /** @param array $paymentDetails */ + public function pay(Checkout $checkout, array $paymentDetails = []): \App\Models\Order + { + if (Order::withoutGlobalScopes()->where('checkout_id', $checkout->id)->exists()) { + return $this->payments->pay($checkout, $paymentDetails); + } + + $checkout = $this->recalculate($checkout->refresh()); + + return $this->payments->pay($checkout, $paymentDetails); + } + + public function recalculate(Checkout $checkout): Checkout + { + $checkout->loadMissing('cart.lines.variant.product'); + $lines = $checkout->cart->lines->map(static fn ($line): array => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'product_id' => $line->variant?->product_id, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'collection_ids' => $line->variant?->product?->collections?->pluck('id')->all() ?? [], + ])->all(); + $subtotal = array_sum(array_map(static fn (array $line): int => $line['quantity'] * $line['unit_price_amount'], $lines)); + $codeDiscount = $checkout->discount_code + ? Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('upper(code) = ?', [strtoupper(trim($checkout->discount_code))]) + ->first() + : null; + $automaticDiscounts = $this->automaticDiscounts($checkout->store_id); + $discounts = [...($codeDiscount ? [$codeDiscount] : []), ...$automaticDiscounts]; + $taxSettings = TaxSetting::query()->where('store_id', $checkout->store_id)->first(); + $address = $checkout->shipping_address_json ?? []; + $selectedShippingAmount = (int) $checkout->shipping_amount; + + if ($checkout->shipping_method_id !== null) { + $selectedRate = $this->availableShippingRates($checkout)->firstWhere('id', (int) $checkout->shipping_method_id); + + if ($selectedRate !== null) { + $selectedShippingAmount = (int) $selectedRate->price_amount; + } + } + + $rates = $taxSettings?->taxRatesForCalculator() ?? []; + $pricesIncludeTax = (bool) ($taxSettings?->prices_include_tax ?? false); + $result = $this->pricing->calculate( + $lines, + $selectedShippingAmount, + $discounts, + 0, + $pricesIncludeTax, + true, + (string) ($checkout->cart->currency ?? 'USD'), + $checkout->customer_id !== null, + $checkout->customer_id, + $checkout->email, + ); + + $discountedLineAmounts = array_map(function (array $line) use ($result): int { + $variantId = (int) ($line['variant_id'] ?? $line['id'] ?? 0); + $lineSubtotal = (int) $line['quantity'] * (int) $line['unit_price_amount']; + + return max(0, $lineSubtotal - (int) ($result->discountAllocations[$variantId] ?? 0)); + }, $lines); + $taxRequest = new TaxCalculationRequest( + (int) $checkout->store_id, + $discountedLineAmounts, + $result->shipping, + $address, + $rates, + $taxSettings?->defaultTaxRateForCalculator() ?? 0, + $pricesIncludeTax, + true, + (string) ($checkout->cart->currency ?? 'USD'), + $taxSettings?->config_json ?? [], + ); + $taxCalculation = $this->calculateTax($taxSettings, $taxRequest, $checkout); + $result = new PricingResult( + $result->subtotal, + $result->discount, + $result->shipping, + $taxCalculation->lines, + $taxCalculation->total, + max(0, $result->subtotal - $result->discount + $result->shipping + ($pricesIncludeTax ? 0 : $taxCalculation->total)), + $result->currency, + $result->discountAllocations, + $result->appliedDiscounts, + ); + + $checkout->forceFill([ + 'subtotal_amount' => $result->subtotal, + 'discount_amount' => $result->discount, + 'shipping_amount' => $result->shipping, + 'tax_amount' => $result->taxTotal, + 'total_amount' => $result->total, + 'totals_json' => $result->toArray(), + 'tax_provider_snapshot_json' => $taxCalculation->snapshot, + ])->save(); + + return $checkout->refresh(); + } + + private function calculateTax(?TaxSetting $settings, TaxCalculationRequest $request, Checkout $checkout): TaxCalculationResult + { + if ($settings?->mode !== 'provider') { + return app(ManualTaxProvider::class)->calculate($request); + } + + if ($settings->provider === 'none') { + return new TaxCalculationResult([], 0, [ + 'provider' => 'none', + 'status' => 'skipped', + 'tax_lines' => [], + 'total_tax_amount' => 0, + ]); + } + + try { + return app(StripeTaxProvider::class)->calculate($request); + } catch (TaxProviderUnavailableException $exception) { + $snapshot = [ + 'provider' => 'stripe_tax', + 'status' => 'unavailable', + 'message' => $exception->getMessage(), + 'fallback' => $settings->config_json['fallback'] ?? 'allow', + ]; + + if (($settings->config_json['fallback'] ?? 'allow') === 'block') { + $checkout->forceFill(['tax_provider_snapshot_json' => $snapshot])->save(); + + throw ValidationException::withMessages(['tax' => 'Tax could not be calculated. Please try again later.']); + } + + logger()->warning('Stripe Tax is unavailable; checkout continued without tax.', [ + 'store_id' => $checkout->store_id, + 'checkout_id' => $checkout->id, + 'provider_error' => $exception->getMessage(), + ]); + + return new TaxCalculationResult([], 0, [...$snapshot, 'status' => 'fallback']); + } + } + + /** @return list */ + private function automaticDiscounts(int $storeId): array + { + return Discount::withoutGlobalScopes() + ->where('store_id', $storeId) + ->whereNull('code') + ->where('is_active', true) + ->orderBy('id') + ->get() + ->filter(static fn (Discount $discount): bool => ($discount->rules_json['activation_method'] ?? null) === 'automatic') + ->values() + ->all(); + } + + private function requiresShipping(Checkout $checkout): bool + { + $checkout->loadMissing('cart.lines.variant'); + + return $checkout->cart->lines->contains(static fn ($line): bool => (bool) $line->variant?->requires_shipping); + } + + /** @param array $address + * @return array + */ + private function normalizeAddress(array $address): array + { + $address['address1'] = $address['address1'] ?? $address['address_line_1'] ?? null; + $address['address2'] = $address['address2'] ?? $address['address_line_2'] ?? null; + $address['country_code'] = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + $address['country'] = $address['country'] ?? $address['country_code']; + $address['zip'] = $address['zip'] ?? $address['postal_code'] ?? null; + $address['postal_code'] = $address['postal_code'] ?? $address['zip']; + $address['province'] = $address['province'] ?? $address['state'] ?? null; + $address['province_code'] = $address['province_code'] ?? $address['state_code'] ?? null; + + return $address; + } +} diff --git a/app/Services/DiscountCalculator.php b/app/Services/DiscountCalculator.php new file mode 100644 index 00000000..d700b47b --- /dev/null +++ b/app/Services/DiscountCalculator.php @@ -0,0 +1,214 @@ +>> $lines + * @return array{amount: int, allocations: array} + */ + public function calculate( + Discount $discount, + array $lines, + int $subtotal, + bool $hasCustomer = false, + ?int $customerId = null, + ?string $customerEmail = null, + ): array { + $rules = $discount->rules_json ?? []; + $minimum = (int) ($discount->minimum_subtotal_amount ?? $rules['min_purchase_amount'] ?? $rules['minimum_purchase_amount'] ?? $rules['minimum_order_amount'] ?? 0); + + if (! $discount->is_active || ($discount->starts_at && $discount->starts_at->isFuture()) || ($discount->ends_at && $discount->ends_at->isPast())) { + throw ValidationException::withMessages(['discount_code' => 'This discount code is expired or inactive.']); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw ValidationException::withMessages(['discount_code' => 'This discount code has reached its usage limit.']); + } + + if ($minimum > $subtotal) { + throw ValidationException::withMessages(['discount_code' => 'Your order does not meet the minimum purchase amount.']); + } + + if (($rules['customer_eligibility'] ?? null) === 'registered' && ! $hasCustomer) { + throw ValidationException::withMessages(['discount_code' => 'This discount is available to registered customers.']); + } + + if (($rules['one_per_customer'] ?? false) && $this->hasCustomerRedeemed($discount, $customerId, $customerEmail)) { + throw ValidationException::withMessages(['discount_code' => 'This discount can only be used once per customer.']); + } + + $eligible = $this->eligibleLines($lines, $rules); + $eligibleSubtotal = array_sum(array_map(static fn (array $line): int => (int) $line['line_subtotal_amount'], $eligible)); + + if ($eligibleSubtotal < 1) { + throw ValidationException::withMessages(['discount_code' => 'This discount does not apply to the items in your cart.']); + } + + $amount = match ($discount->type) { + DiscountType::Percentage->value => intdiv(($eligibleSubtotal * $discount->value) + 50, 100), + DiscountType::FixedAmount->value => min($discount->value, $eligibleSubtotal), + DiscountType::FreeShipping->value => 0, + default => throw ValidationException::withMessages(['discount_code' => 'This discount type is not supported.']), + }; + $amount = min($amount, $eligibleSubtotal); + + return ['amount' => $amount, 'allocations' => $this->allocate($eligible, $amount, $eligibleSubtotal)]; + } + + /** + * @param list $discounts + * @param list> $lines + * @return array{amount: int, allocations: array, applied_discounts: list}>} + */ + public function calculateStacked( + array $discounts, + array $lines, + int $subtotal, + ?int $customerId = null, + ?string $customerEmail = null, + bool $hasCustomer = false, + ): array { + $remainingLines = $lines; + $totalAmount = 0; + $allocations = []; + $appliedDiscounts = []; + + foreach ($discounts as $discount) { + try { + $result = $this->calculate($discount, $remainingLines, $subtotal, $hasCustomer, $customerId, $customerEmail); + } catch (ValidationException $exception) { + if ($discount->code !== null) { + throw $exception; + } + + continue; + } + + foreach ($remainingLines as $index => $line) { + $variantId = (int) ($line['variant_id'] ?? $line['id'] ?? 0); + $lineDiscount = (int) ($result['allocations'][$variantId] ?? 0); + + if ($lineDiscount > 0) { + $allocations[$variantId] = (int) ($allocations[$variantId] ?? 0) + $lineDiscount; + $remainingLines[$index]['line_subtotal_amount'] = max(0, (int) $line['line_subtotal_amount'] - $lineDiscount); + } + } + + $totalAmount += $result['amount']; + $appliedDiscounts[] = [ + 'discount_id' => $discount->exists ? (int) $discount->getKey() : null, + 'code' => $discount->code, + 'title' => $discount->title ?? '', + 'type' => $discount->type, + 'activation_method' => ($discount->rules_json['activation_method'] ?? null) === 'automatic' || $discount->code === null ? 'automatic' : 'code', + 'amount' => $result['amount'], + 'allocations' => $result['allocations'], + ]; + } + + return [ + 'amount' => $totalAmount, + 'allocations' => $allocations, + 'applied_discounts' => $appliedDiscounts, + ]; + } + + public function hasCustomerRedeemed(Discount $discount, ?int $customerId, ?string $customerEmail): bool + { + if (! $discount->exists || ($customerId === null && blank($customerEmail))) { + return false; + } + + $query = Order::withoutGlobalScopes() + ->where('store_id', $discount->store_id) + ->whereHas('checkout', function (Builder $checkoutQuery) use ($discount): void { + $checkoutQuery->withoutGlobalScopes()->where(function (Builder $query) use ($discount): void { + if ($discount->exists) { + $query->whereJsonContains('totals_json->applied_discount_ids', (int) $discount->getKey()); + } + + if ($discount->code !== null) { + $query->orWhere('discount_code', $discount->code); + } + }); + }) + ->where(function (Builder $query) use ($customerId, $customerEmail): void { + if ($customerId !== null) { + $query->where('customer_id', $customerId); + } + + if (filled($customerEmail)) { + $normalizedEmail = mb_strtolower(trim((string) $customerEmail)); + + if ($customerId !== null) { + $query->orWhereRaw('LOWER(email) = ?', [$normalizedEmail]); + } else { + $query->whereRaw('LOWER(email) = ?', [$normalizedEmail]); + } + } + }); + + return $query->exists(); + } + + /** @param list> $lines */ + private function eligibleLines(array $lines, array $rules): array + { + $productIds = array_map('intval', $rules['product_ids'] ?? []); + $collectionIds = array_map('intval', $rules['collection_ids'] ?? []); + + if ($productIds === [] && $collectionIds === []) { + return $lines; + } + + return array_values(array_filter($lines, static function (array $line) use ($productIds, $collectionIds): bool { + return in_array((int) ($line['product_id'] ?? 0), $productIds, true) + || array_intersect($collectionIds, array_map('intval', $line['collection_ids'] ?? [])) !== []; + })); + } + + /** @param list> $lines + * @return array + */ + private function allocate(array $lines, int $amount, int $eligibleSubtotal): array + { + if ($amount === 0 || $eligibleSubtotal === 0) { + return []; + } + + $allocations = []; + $remainders = []; + $allocated = 0; + + foreach ($lines as $line) { + $lineSubtotal = (int) $line['line_subtotal_amount']; + $numerator = $amount * $lineSubtotal; + $share = intdiv($numerator, $eligibleSubtotal); + $key = (int) ($line['variant_id'] ?? $line['id'] ?? 0); + $allocations[$key] = $share; + $remainders[$key] = $numerator % $eligibleSubtotal; + $allocated += $share; + } + + arsort($remainders); + + foreach (array_keys($remainders) as $key) { + if ($allocated >= $amount) { + break; + } + + $allocations[$key]++; + $allocated++; + } + + return $allocations; + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..16165f41 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,139 @@ + $lineQuantities + * @param array{tracking_company?: ?string, tracking_number?: ?string, tracking_url?: ?string} $trackingData + */ + public function create(Order $order, array $lineQuantities, array $trackingData = []): Fulfillment + { + return DB::transaction(function () use ($order, $lineQuantities, $trackingData): Fulfillment { + $order = Order::withoutGlobalScopes()->with('lines.fulfillmentLines')->lockForUpdate()->findOrFail($order->id); + + if (! in_array($order->financial_status, ['paid', 'partially_refunded'], true)) { + throw ValidationException::withMessages(['order' => 'Fulfillment is available after payment is confirmed.']); + } + + if ($lineQuantities === []) { + throw ValidationException::withMessages(['lines' => 'Select at least one item to fulfill.']); + } + + $selectedLines = []; + + foreach ($lineQuantities as $lineId => $quantity) { + $line = $order->lines->firstWhere('id', (int) $lineId); + + if (! $line || $quantity < 1) { + throw ValidationException::withMessages(['lines' => 'Each fulfillment line must belong to this order and have a positive quantity.']); + } + + $fulfilledQuantity = (int) $line->fulfillmentLines->sum('quantity'); + + if ($quantity > $line->quantity - $fulfilledQuantity) { + throw ValidationException::withMessages(['lines' => 'A fulfillment cannot exceed the remaining quantity for an order line.']); + } + + $selectedLines[] = [$line, $quantity]; + } + + $fulfillment = $order->fulfillments()->create([ + 'status' => 'pending', + 'tracking_company' => $trackingData['tracking_company'] ?? null, + 'tracking_number' => $trackingData['tracking_number'] ?? null, + 'tracking_url' => $trackingData['tracking_url'] ?? null, + 'created_at' => now(), + ]); + + foreach ($selectedLines as [$line, $quantity]) { + $fulfillment->lines()->create(['order_line_id' => $line->id, 'quantity' => $quantity]); + } + + $this->keepOrderInProgress($order); + + FulfillmentCreated::dispatch($fulfillment->load('order', 'lines.orderLine')); + + return $fulfillment->load('lines.orderLine'); + }); + } + + public function markShipped(Fulfillment $fulfillment, bool $notifyCustomer = true): Fulfillment + { + return DB::transaction(function () use ($fulfillment, $notifyCustomer): Fulfillment { + $order = Order::withoutGlobalScopes()->lockForUpdate()->findOrFail($fulfillment->order_id); + $fulfillment = Fulfillment::withoutGlobalScopes() + ->where('order_id', $order->id) + ->lockForUpdate() + ->findOrFail($fulfillment->id); + + if ($fulfillment->status !== 'pending') { + throw ValidationException::withMessages(['status' => 'Only pending fulfillments can be marked as shipped.']); + } + + $fulfillment->forceFill(['status' => 'shipped', 'shipped_at' => now()])->save(); + $this->keepOrderInProgress($order); + + $fulfillment = $fulfillment->refresh()->load('order'); + FulfillmentShipped::dispatch($fulfillment, $notifyCustomer); + + return $fulfillment; + }); + } + + public function markDelivered(Fulfillment $fulfillment): Fulfillment + { + return DB::transaction(function () use ($fulfillment): Fulfillment { + $order = Order::withoutGlobalScopes()->lockForUpdate()->findOrFail($fulfillment->order_id); + $fulfillment = Fulfillment::withoutGlobalScopes() + ->where('order_id', $order->id) + ->lockForUpdate() + ->findOrFail($fulfillment->id); + + if ($fulfillment->status !== 'shipped') { + throw ValidationException::withMessages(['status' => 'Only shipped fulfillments can be marked as delivered.']); + } + + $fulfillment->forceFill(['status' => 'delivered', 'delivered_at' => now()])->save(); + + $order->load(['lines', 'fulfillments.lines']); + $allFulfillmentsDelivered = $order->fulfillments->isNotEmpty() + && $order->fulfillments->every(fn (Fulfillment $candidate): bool => $candidate->status === 'delivered'); + $deliveredQuantities = $order->fulfillments + ->where('status', 'delivered') + ->flatMap(fn (Fulfillment $candidate) => $candidate->lines) + ->groupBy('order_line_id') + ->map(fn ($lines): int => (int) $lines->sum('quantity')); + $allOrderLineQuantitiesDelivered = $order->lines->isNotEmpty() + && $order->lines->every(fn (OrderLine $line): bool => ($deliveredQuantities[$line->id] ?? 0) >= $line->quantity); + $fullyFulfilled = $allFulfillmentsDelivered && $allOrderLineQuantitiesDelivered; + + $order->forceFill([ + 'fulfillment_status' => $fullyFulfilled ? 'fulfilled' : 'partial', + 'status' => $fullyFulfilled ? 'fulfilled' : ($order->status === 'fulfilled' ? 'paid' : $order->status), + ])->save(); + + $fulfillment = $fulfillment->refresh()->load('order'); + FulfillmentDelivered::dispatch($fulfillment); + + return $fulfillment; + }); + } + + private function keepOrderInProgress(Order $order): void + { + $order->forceFill([ + 'fulfillment_status' => 'partial', + 'status' => $order->status === 'fulfilled' ? 'paid' : $order->status, + ])->save(); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..10a936da --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,86 @@ +policy === InventoryPolicy::Continue->value + || ($item->quantity_on_hand - $item->quantity_reserved) >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + $this->assertPositive($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = InventoryItem::query()->whereKey($item->getKey())->lockForUpdate()->firstOrFail(); + + if (! $this->checkAvailability($locked, $quantity)) { + throw new InsufficientInventoryException('There is not enough inventory to reserve this quantity.'); + } + + $locked->increment('quantity_reserved', $quantity); + $item->refresh(); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + $this->assertPositive($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = InventoryItem::query()->whereKey($item->getKey())->lockForUpdate()->firstOrFail(); + $locked->quantity_reserved = max(0, $locked->quantity_reserved - $quantity); + $locked->save(); + $item->refresh(); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + $this->assertPositive($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = InventoryItem::query()->whereKey($item->getKey())->lockForUpdate()->firstOrFail(); + + if ($locked->quantity_reserved < $quantity) { + throw new InsufficientInventoryException('The requested quantity has not been reserved.'); + } + + $locked->quantity_on_hand -= $quantity; + $locked->quantity_reserved -= $quantity; + $locked->save(); + $item->refresh(); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + $this->assertPositive($quantity); + + DB::transaction(function () use ($item, $quantity): void { + $locked = InventoryItem::query()->whereKey($item->getKey())->lockForUpdate()->firstOrFail(); + $locked->increment('quantity_on_hand', $quantity); + $item->refresh(); + }); + } + + private function assertPositive(int $quantity): void + { + if ($quantity < 1) { + throw new InvalidArgumentException('Inventory quantities must be positive integers.'); + } + } +} diff --git a/app/Services/MockPaymentProvider.php b/app/Services/MockPaymentProvider.php new file mode 100644 index 00000000..cfb6c165 --- /dev/null +++ b/app/Services/MockPaymentProvider.php @@ -0,0 +1,29 @@ + 'mock_'.Str::random(20), 'status' => 'pending', 'message' => 'Bank transfer payment is awaiting confirmation.']; + } + + $normalizedCard = preg_replace('/\D+/', '', (string) $cardNumber); + + if ($method === PaymentMethod::CreditCard && $normalizedCard === '4000000000000002') { + return ['id' => 'mock_'.Str::random(20), 'status' => 'failed', 'message' => 'Your card was declined.']; + } + + if ($method === PaymentMethod::CreditCard && $normalizedCard === '4000000000009995') { + return ['id' => 'mock_'.Str::random(20), 'status' => 'failed', 'message' => 'Your card has insufficient funds.']; + } + + return ['id' => 'mock_'.Str::random(20), 'status' => 'captured', 'message' => 'Payment completed successfully.']; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..238460b3 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,238 @@ +where('checkout_id', $checkout->id)->first(); + + if ($existing) { + return $existing->load('lines', 'payments', 'fulfillments'); + } + + $checkout->loadMissing('cart.lines.variant.product', 'cart.lines.variant.inventoryItem'); + $cart = $checkout->cart; + $lines = $cart->lines; + $store = $checkout->store_id; + $totals = $checkout->totals_json ?? []; + $appliedDiscountIds = array_values(array_unique(array_map('intval', $totals['applied_discount_ids'] ?? []))); + + if ($appliedDiscountIds === [] && $checkout->discount_code) { + $legacyDiscountId = Discount::withoutGlobalScopes() + ->where('store_id', $store) + ->where('code', $checkout->discount_code) + ->value('id'); + + if ($legacyDiscountId !== null) { + $appliedDiscountIds[] = (int) $legacyDiscountId; + } + } + + $appliedDiscounts = Discount::withoutGlobalScopes() + ->where('store_id', $store) + ->whereIn('id', $appliedDiscountIds) + ->lockForUpdate() + ->get() + ->keyBy('id'); + + foreach ($appliedDiscounts as $discount) { + if (($discount->rules_json['one_per_customer'] ?? false) + && $this->discountCalculator->hasCustomerRedeemed($discount, $checkout->customer_id, $checkout->email)) { + throw ValidationException::withMessages(['discount_code' => 'This discount can only be used once per customer.']); + } + } + + $settings = StoreSettings::query()->where('store_id', $store)->first()?->settings_json ?? []; + $startNumber = (int) ($settings['order_number_start'] ?? 1001); + $prefix = (string) ($settings['order_number_prefix'] ?? '#'); + $orderNumber = $prefix.str_pad((string) ($startNumber + Order::withoutGlobalScopes()->where('store_id', $store)->count()), 4, '0', STR_PAD_LEFT); + $isPaid = $providerResult['status'] === 'captured'; + + $order = Order::withoutGlobalScopes()->create([ + 'checkout_id' => $checkout->id, + 'store_id' => $store, + 'customer_id' => $checkout->customer_id, + 'order_number' => $orderNumber, + 'payment_method' => $checkout->payment_method, + 'status' => $isPaid ? 'paid' : 'pending', + 'financial_status' => $isPaid ? 'paid' : 'pending', + 'fulfillment_status' => 'unfulfilled', + 'currency' => $cart->currency, + 'subtotal_amount' => $checkout->subtotal_amount, + 'discount_amount' => $checkout->discount_amount, + 'shipping_amount' => $checkout->shipping_amount, + 'tax_amount' => $checkout->tax_amount, + 'total_amount' => $checkout->total_amount, + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + $discountAllocations = $totals['discountAllocations'] ?? []; + + foreach ($lines as $line) { + $variant = $line->variant; + $key = (string) $line->variant_id; + $lineDiscount = (int) ($discountAllocations[$key] ?? $discountAllocations[(int) $line->variant_id] ?? 0); + $lineTotal = max(0, $line->line_subtotal_amount - $lineDiscount); + $lineDiscountDetails = []; + + foreach ($totals['appliedDiscounts'] ?? [] as $appliedDiscount) { + $amount = (int) ($appliedDiscount['allocations'][$key] ?? $appliedDiscount['allocations'][(int) $line->variant_id] ?? 0); + + if ($amount > 0 && $appliedDiscount['discount_id'] !== null) { + $lineDiscountDetails[] = ['discount_id' => (int) $appliedDiscount['discount_id'], 'amount' => $amount]; + } + } + + if ($lineDiscountDetails === [] && $lineDiscount > 0) { + $lineDiscountDetails[] = ['amount' => $lineDiscount]; + } + + $order->lines()->create([ + 'product_id' => $variant?->product_id, + 'variant_id' => $variant?->id, + 'title_snapshot' => $variant?->product?->title ?? 'Removed product', + 'variant_title_snapshot' => $variant?->title, + 'sku_snapshot' => $variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $lineTotal, + 'discount_allocations_json' => $lineDiscountDetails, + ]); + + if ($isPaid && $variant?->inventoryItem) { + $this->inventory->commit($variant->inventoryItem, $line->quantity); + } + } + + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $checkout->payment_method, + 'provider_payment_id' => $providerResult['id'], + 'status' => $providerResult['status'] === 'captured' ? 'captured' : 'pending', + 'amount' => $checkout->total_amount, + 'currency' => $cart->currency, + 'raw_json_encrypted' => Crypt::encryptString(json_encode($providerResult, JSON_THROW_ON_ERROR)), + 'created_at' => now(), + ]); + + $checkout->forceFill(['status' => 'completed', 'completed_at' => now()])->save(); + $cart->forceFill(['status' => 'converted'])->save(); + + foreach ($appliedDiscounts as $discount) { + $discount->increment('usage_count'); + } + + if ($isPaid && $lines->isNotEmpty() && $lines->every(static fn ($line): bool => ! $line->variant?->requires_shipping)) { + $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'created_at' => now()]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line->id, 'quantity' => $line->quantity]); + } + + $order->forceFill(['status' => 'fulfilled', 'fulfillment_status' => 'fulfilled'])->save(); + } + + $order = $order->refresh()->load('lines', 'payments', 'fulfillments'); + OrderCreated::dispatch($order); + CheckoutCompleted::dispatch($order); + + if ($isPaid) { + OrderPaid::dispatch($order); + } + + if ($order->fulfillment_status === 'fulfilled') { + OrderFulfilled::dispatch($order); + } + + return $order; + }); + } + + public function confirmBankTransfer(Order $order): Order + { + return DB::transaction(function () use ($order): Order { + $order = Order::withoutGlobalScopes()->with('payments', 'lines.variant.inventoryItem')->lockForUpdate()->findOrFail($order->id); + + if ($order->payment_method !== 'bank_transfer' || $order->financial_status !== 'pending') { + abort(422, 'This order is not awaiting a bank transfer.'); + } + + $payment = $order->payments()->where('status', 'pending')->firstOrFail(); + + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventory->commit($line->variant->inventoryItem, $line->quantity); + } + } + + $payment->update(['status' => 'captured']); + $order->forceFill(['status' => 'paid', 'financial_status' => 'paid'])->save(); + + if ($order->lines->isNotEmpty() && $order->lines->every(static fn ($line): bool => ! $line->variant?->requires_shipping)) { + $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'created_at' => now()]); + + foreach ($order->lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line->id, 'quantity' => $line->quantity]); + } + + $order->forceFill(['status' => 'fulfilled', 'fulfillment_status' => 'fulfilled'])->save(); + } + + $order = $order->refresh(); + OrderPaid::dispatch($order); + + if ($order->fulfillment_status === 'fulfilled') { + OrderFulfilled::dispatch($order); + } + + return $order; + }); + } + + public function cancelPending(Order $order): Order + { + return DB::transaction(function () use ($order): Order { + $order = Order::withoutGlobalScopes()->with('lines.variant.inventoryItem')->lockForUpdate()->findOrFail($order->id); + + if ($order->financial_status !== 'pending') { + abort(422, 'Only unpaid orders can be cancelled.'); + } + + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventory->release($line->variant->inventoryItem, $line->quantity); + } + } + + $order->payments()->where('status', 'pending')->update(['status' => 'failed']); + $order->forceFill(['status' => 'cancelled', 'financial_status' => 'voided'])->save(); + OrderCancelled::dispatch($order->refresh()); + + return $order->refresh(); + }); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..6a6f1b9c --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,54 @@ + $paymentDetails */ + public function pay(Checkout $checkout, array $paymentDetails = []): Order + { + $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->id)->first(); + + if ($existing) { + return $existing->load('lines', 'payments'); + } + + if ($checkout->status !== 'payment_selected' || ($checkout->expires_at && $checkout->expires_at->isPast())) { + abort(422, 'This checkout is not ready for payment.'); + } + + $method = PaymentMethod::from($checkout->payment_method); + $result = $this->provider->charge($method, $paymentDetails['card_number'] ?? null); + + if ($result['status'] === 'failed') { + DB::transaction(function () use ($checkout): void { + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + + foreach ($checkout->cart->lines as $line) { + if ($line->variant?->inventoryItem) { + $this->inventory->release($line->variant->inventoryItem, $line->quantity); + } + } + + $checkout->forceFill(['status' => 'shipping_selected', 'expires_at' => null])->save(); + }); + + $codeName = str_contains($result['message'], 'insufficient') ? 'insufficient_funds' : 'payment_declined'; + throw new PaymentDeclinedException($result['message'], $codeName); + } + + return $this->orders->createFromCheckout($checkout, $result); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..1ee293f7 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,74 @@ +> $lines + * @param Discount|list|null $discount + */ + public function calculate( + array $lines, + int $shippingAmount = 0, + Discount|array|null $discount = null, + int $taxRate = 0, + bool $pricesIncludeTax = false, + bool $shippingTaxable = false, + string $currency = 'USD', + bool $hasCustomer = false, + ?int $customerId = null, + ?string $customerEmail = null, + ): PricingResult { + $normalized = array_map(static function (array $line): array { + $quantity = max(1, (int) ($line['quantity'] ?? 1)); + $unitPrice = max(0, (int) ($line['unit_price_amount'] ?? 0)); + + return [...$line, 'quantity' => $quantity, 'unit_price_amount' => $unitPrice, 'line_subtotal_amount' => $quantity * $unitPrice]; + }, $lines); + $subtotal = array_sum(array_column($normalized, 'line_subtotal_amount')); + $discounts = $discount instanceof Discount ? [$discount] : ($discount ?? []); + $discountResult = $discounts !== [] + ? $this->discounts->calculateStacked( + $discounts, + $normalized, + $subtotal, + $customerId, + $customerEmail, + $hasCustomer || $customerId !== null, + ) + : ['amount' => 0, 'allocations' => [], 'applied_discounts' => []]; + $discountedLineAmounts = []; + + foreach ($normalized as $line) { + $key = (int) ($line['variant_id'] ?? $line['id'] ?? 0); + $discountedLineAmounts[] = max(0, $line['line_subtotal_amount'] - (int) ($discountResult['allocations'][$key] ?? 0)); + } + + $freeShipping = collect($discountResult['applied_discounts'])->contains(static fn (array $applied): bool => $applied['type'] === 'free_shipping'); + $shipping = $freeShipping ? 0 : max(0, $shippingAmount); + $taxLines = $this->taxes->calculate($discountedLineAmounts, $shipping, $taxRate, $shippingTaxable, $pricesIncludeTax); + $taxTotal = array_sum(array_map(static fn ($line): int => $line->amount, $taxLines)); + $total = max(0, $subtotal - $discountResult['amount'] + $shipping + ($pricesIncludeTax ? 0 : $taxTotal)); + + return new PricingResult( + $subtotal, + $discountResult['amount'], + $shipping, + $taxLines, + $taxTotal, + $total, + $currency, + $discountResult['allocations'], + $discountResult['applied_discounts'], + ); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..3ad14ec7 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,180 @@ + $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $requestedStatus = ProductStatus::from($data['status'] ?? ProductStatus::Draft->value); + $product = Product::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'title' => trim((string) $data['title']), + 'handle' => $this->handles->generate((string) ($data['handle'] ?? $data['title']), 'products', $store->id), + 'status' => ProductStatus::Draft->value, + 'description_html' => $data['description_html'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + ]); + + foreach ($data['options'] ?? [] as $optionPosition => $optionData) { + $option = $product->options()->create([ + 'name' => $optionData['name'], + 'position' => $optionPosition, + ]); + + foreach ($optionData['values'] ?? [] as $valuePosition => $value) { + $option->values()->create(['value' => $value, 'position' => $valuePosition]); + } + } + + $variants = $data['variants'] ?? [['price_amount' => (int) ($data['price_amount'] ?? 0), 'is_default' => true]]; + + foreach ($variants as $position => $variantData) { + $sku = $variantData['sku'] ?? null; + + if ($sku && DB::table('product_variants') + ->join('products', 'products.id', '=', 'product_variants.product_id') + ->where('products.store_id', $store->id) + ->where('product_variants.sku', $sku) + ->exists()) { + throw ValidationException::withMessages(['variants.'.$position.'.sku' => 'This SKU is already in use in this store.']); + } + + $variant = $product->variants()->create([ + 'sku' => $sku, + 'barcode' => $variantData['barcode'] ?? null, + 'price_amount' => (int) ($variantData['price_amount'] ?? 0), + 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, + 'currency' => $variantData['currency'] ?? $store->default_currency, + 'weight_g' => $variantData['weight_g'] ?? null, + 'requires_shipping' => $variantData['requires_shipping'] ?? true, + 'is_default' => $variantData['is_default'] ?? $position === 0, + 'position' => $position, + ]); + + $variant->inventoryItem()->create([ + 'store_id' => $store->id, + 'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0), + 'policy' => $variantData['inventory_policy'] ?? 'deny', + ]); + + if (isset($variantData['option_value_ids'])) { + $variant->optionValues()->sync($variantData['option_value_ids']); + } + } + + if ($requestedStatus !== ProductStatus::Draft) { + $this->transitionStatus($product, $requestedStatus); + } + + $product = $product->load(['options.values', 'variants.inventoryItem']); + ProductCreated::dispatch($product); + + return $product; + }); + } + + /** + * @param array $data + */ + public function update(Product $product, array $data, bool $dispatchDomainEvent = true): Product + { + $product->fill(collect($data)->only(['title', 'description_html', 'vendor', 'product_type', 'tags'])->all()); + + if (isset($data['handle']) && $data['handle'] !== $product->handle) { + $product->handle = $this->handles->generate($data['handle'], 'products', $product->store_id, $product->id); + } + + if ($product->isDirty()) { + $product->save(); + + if ($dispatchDomainEvent) { + ProductUpdated::dispatch($product->refresh()); + } + } + + return $product->refresh(); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus, bool $dispatchDomainEvent = true): void + { + $current = ProductStatus::from($product->status); + + if ($current === $newStatus) { + return; + } + + $hasOrderHistory = OrderLine::query() + ->whereIn('variant_id', $product->variants()->select('id')) + ->exists(); + + $allowed = match ($current) { + ProductStatus::Draft => in_array($newStatus, [ProductStatus::Active, ProductStatus::Archived], true), + ProductStatus::Active => $newStatus === ProductStatus::Archived || ($newStatus === ProductStatus::Draft && ! $hasOrderHistory), + ProductStatus::Archived => $newStatus === ProductStatus::Active || ($newStatus === ProductStatus::Draft && ! $hasOrderHistory), + }; + + if (! $allowed) { + throw new InvalidProductTransitionException("Cannot change a {$current->value} product to {$newStatus->value}."); + } + + if ($newStatus === ProductStatus::Active) { + if (blank($product->title) || ! $product->variants()->where('price_amount', '>', 0)->exists()) { + throw new InvalidProductTransitionException('A product needs a title and at least one priced variant before it can be activated.'); + } + + $product->published_at ??= now(); + } + + $wasRecentlyCreated = $product->wasRecentlyCreated; + $oldStatus = $product->status; + $product->status = $newStatus->value; + $product->save(); + + if ($dispatchDomainEvent) { + ProductStatusChanged::dispatch($product, $oldStatus, $newStatus->value, $wasRecentlyCreated); + } + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft->value || OrderLine::query()->whereIn('variant_id', $product->variants()->select('id'))->exists()) { + throw new InvalidProductTransitionException('Only draft products without order history can be deleted.'); + } + + $productSnapshot = [ + 'id' => $product->getKey(), + 'store_id' => $product->store_id, + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'tags' => $product->tags, + ]; + $storeId = (int) $product->store_id; + $product->delete(); + ProductDeleted::dispatch($storeId, $productSnapshot); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..ef22946a --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,122 @@ + $lineQuantities */ + public function refund( + Order $order, + int $amount = 0, + ?string $reason = null, + bool $restock = false, + array $lineQuantities = [], + bool $notifyCustomer = true, + ): Refund { + return DB::transaction(function () use ($order, $amount, $reason, $restock, $lineQuantities, $notifyCustomer): Refund { + $order = Order::withoutGlobalScopes()->with('payments', 'lines.variant.inventoryItem', 'lines.refundLines')->lockForUpdate()->findOrFail($order->id); + $payment = $order->payments->first(fn (Payment $candidate): bool => in_array($candidate->status, ['captured', 'partially_refunded'], true)); + + if (! $payment || ! in_array($order->financial_status, ['paid', 'partially_refunded'], true)) { + throw ValidationException::withMessages(['order' => 'Only a paid order can be refunded.']); + } + + $alreadyRefunded = (int) $order->refunds()->sum('amount'); + $remaining = max(0, $order->total_amount - $alreadyRefunded); + + if ($lineQuantities !== []) { + $lineAmount = 0; + $selectedLines = []; + + foreach ($lineQuantities as $lineId => $quantity) { + $line = $order->lines->firstWhere('id', (int) $lineId); + $previousQuantity = $line?->refundLines()->sum('quantity') ?? 0; + + if (! $line || $quantity < 1 || $quantity > $line->quantity - $previousQuantity) { + throw ValidationException::withMessages(['lines' => 'Refund quantity must be within the remaining quantity for each order line.']); + } + + $unitAmount = $line->quantity > 0 ? (int) round($line->total_amount / $line->quantity) : $line->unit_price_amount; + $lineAmount += $unitAmount * $quantity; + $selectedLines[] = [$line, $quantity, $unitAmount * $quantity]; + } + + if ($amount === 0) { + $amount = $lineAmount; + } + } else { + $selectedLines = []; + } + + if ($amount === 0) { + $amount = $remaining; + } + + if ($amount < 1 || $amount > $remaining) { + throw ValidationException::withMessages(['amount' => 'The refund amount must be greater than zero and cannot exceed the remaining refundable balance.']); + } + + if ($restock && $selectedLines === []) { + if ($amount !== $remaining) { + throw ValidationException::withMessages(['lines' => 'Choose the refunded item quantities before restocking a partial refund.']); + } + + foreach ($order->lines as $line) { + $alreadyRestocked = (int) DB::table('refund_lines') + ->join('refunds', 'refunds.id', '=', 'refund_lines.refund_id') + ->where('refunds.order_id', $order->id) + ->where('refund_lines.order_line_id', $line->id) + ->sum('refund_lines.quantity'); + $remainingQuantity = $line->quantity - $alreadyRestocked; + + if ($remainingQuantity > 0) { + $selectedLines[] = [$line, $remainingQuantity, $line->total_amount]; + } + } + } + + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => 'processed', + 'provider_refund_id' => 'mock_refund_'.Str::random(18), + 'created_at' => now(), + ]); + + foreach ($selectedLines as [$line, $quantity, $lineRefundAmount]) { + $refund->lines()->create([ + 'order_line_id' => $line->id, + 'quantity' => $quantity, + 'amount' => min($lineRefundAmount, $amount), + ]); + + if ($restock && $line->variant?->inventoryItem) { + $this->inventory->restock($line->variant->inventoryItem, $quantity); + } + } + + $totalRefunded = $alreadyRefunded + $amount; + $fullyRefunded = $totalRefunded >= $order->total_amount; + $payment->forceFill(['status' => $fullyRefunded ? 'refunded' : 'partially_refunded'])->save(); + $order->forceFill([ + 'financial_status' => $fullyRefunded ? 'refunded' : 'partially_refunded', + 'status' => $fullyRefunded ? 'refunded' : $order->status, + ])->save(); + + OrderRefunded::dispatch($order->refresh(), $refund, $notifyCustomer); + + return $refund->load('lines.orderLine'); + }); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..ab97455e --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,74 @@ +map(static fn (string $term): string => Str::lower(trim($term))) + ->filter() + ->values(); + + if ($terms->isEmpty()) { + return ''; + } + + $settings = DB::table('search_settings')->where('store_id', $storeId)->first(); + $stopWords = collect(json_decode((string) ($settings->stop_words_json ?? '[]'), true) ?: []) + ->map(static fn (mixed $word): string => Str::lower(trim((string) $word))) + ->flip(); + $synonyms = json_decode((string) ($settings->synonyms_json ?? '[]'), true) ?: []; + $expandedTerms = []; + $lastTermPosition = $terms->count() - 1; + + foreach ($terms as $position => $term) { + if ($stopWords->has($term)) { + continue; + } + + $alternatives = [$term]; + + foreach ($synonyms as $group) { + if (! is_array($group)) { + continue; + } + + $normalizedGroup = collect($group) + ->map(static fn (mixed $word): string => Str::lower(trim((string) $word))) + ->filter() + ->values(); + + if ($normalizedGroup->contains($term)) { + $alternatives = [...$alternatives, ...$normalizedGroup->all()]; + } + } + + foreach (array_unique($alternatives) as $alternative) { + $isPrefix = $prefixLastTerm && $position === $lastTermPosition; + $expandedTerms[] = '"'.str_replace('"', '""', $alternative).'"'.($isPrefix ? '*' : ''); + } + } + + return implode(' OR ', array_unique($expandedTerms)); + } + + /** @return list */ + public function rankedProductIds(int $storeId, string $fullTextExpression, int $limit = 500): array + { + if ($fullTextExpression === '') { + return []; + } + + $matches = DB::select( + 'SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY bm25(products_fts) LIMIT ?', + [$fullTextExpression, (string) $storeId, min(max($limit, 1), 1000)], + ); + + return array_map(static fn (object $match): string => (string) $match->product_id, $matches); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..51c383ab --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,134 @@ + */ + public function ratesForCheckout(Checkout $checkout): EloquentCollection + { + $checkout->loadMissing('cart.lines.variant'); + $cartLines = $checkout->cart?->lines ?? collect(); + $physicalLines = $cartLines->filter(static fn ($line): bool => (bool) $line->variant?->requires_shipping); + + if ($physicalLines->isEmpty()) { + return new EloquentCollection; + } + + $subtotal = (int) $cartLines->sum('line_subtotal_amount'); + $totalWeight = (int) $physicalLines->sum(static fn ($line): int => (int) ($line->variant?->weight_g ?? 0) * (int) $line->quantity); + $address = $checkout->shipping_address_json ?? []; + + return $this->ratesForAddress((int) $checkout->store_id, $address, $subtotal, $totalWeight); + } + + /** @param array $address + * @return EloquentCollection + */ + public function ratesForAddress(int $storeId, array $address, int $subtotal = 0, int $totalWeight = 0): EloquentCollection + { + $countryCode = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + $provinceCode = strtoupper((string) ($address['province_code'] ?? $address['state'] ?? '')); + $zone = $countryCode === '' ? null : $this->matchingZone($storeId, $countryCode, $provinceCode); + + if ($zone === null) { + return new EloquentCollection; + } + + return $zone->rates + ->filter(function (ShippingRate $rate) use ($subtotal, $totalWeight): bool { + if (! $this->isAvailable($rate, $subtotal)) { + return false; + } + + $amount = $this->amountForRate($rate, $subtotal, $totalWeight); + + if ($amount === null) { + return false; + } + + $rate->setAttribute('price_amount', $amount); + + return true; + }) + ->values(); + } + + public function matchingZone(int $storeId, string $countryCode, string $provinceCode): ?ShippingZone + { + $zones = ShippingZone::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('is_active', true) + ->with(['rates' => fn ($rates) => $rates->orderBy('id')]) + ->orderBy('id') + ->get(); + $bestZone = null; + $bestSpecificity = -1; + + foreach ($zones as $zone) { + $countries = array_map('strtoupper', $zone->countries ?? []); + + if (! in_array($countryCode, $countries, true)) { + continue; + } + + $regions = array_map('strtoupper', $zone->regions ?? []); + $regionMatches = $provinceCode !== '' && array_intersect($regions, [$provinceCode, $countryCode.'-'.$provinceCode]) !== []; + $specificity = $regionMatches ? 2 : 1; + + if ($specificity > $bestSpecificity) { + $bestZone = $zone; + $bestSpecificity = $specificity; + } + } + + return $bestZone; + } + + private function isAvailable(ShippingRate $rate, int $subtotal): bool + { + return $rate->is_active + && ($rate->min_order_amount === null || $subtotal >= (int) $rate->min_order_amount) + && ($rate->max_order_amount === null || $subtotal <= (int) $rate->max_order_amount); + } + + private function amountForRate(ShippingRate $rate, int $subtotal, int $totalWeight): ?int + { + $config = $rate->config_json ?? []; + + if ($rate->type === 'flat' || $rate->type === 'carrier') { + return (int) ($config['price_amount'] ?? $config['amount'] ?? $rate->price_amount ?? 0); + } + + $tiers = $config['tiers'] ?? $config['ranges'] ?? []; + + foreach ($tiers as $tier) { + if (! is_array($tier)) { + continue; + } + + if ($rate->type === 'weight') { + $minimum = (int) ($tier['min_weight_g'] ?? $tier['min_g'] ?? 0); + $maximum = $tier['max_weight_g'] ?? $tier['max_g'] ?? null; + $value = $totalWeight; + } elseif ($rate->type === 'price') { + $minimum = (int) ($tier['min_order_amount'] ?? $tier['min_amount'] ?? 0); + $maximum = $tier['max_order_amount'] ?? $tier['max_amount'] ?? null; + $value = $subtotal; + } else { + return null; + } + + if ($value >= $minimum && ($maximum === null || $value <= (int) $maximum)) { + return (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0); + } + } + + return null; + } +} diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php new file mode 100644 index 00000000..e3caaf20 --- /dev/null +++ b/app/Services/Tax/ManualTaxProvider.php @@ -0,0 +1,38 @@ +address['country_code'] ?? $request->address['country'] ?? ''); + $provinceCode = isset($request->address['province_code']) + ? (string) $request->address['province_code'] + : null; + $rate = $this->calculator->rateForAddress($request->rates, $countryCode, $provinceCode, $request->defaultRate); + $lines = $this->calculator->calculate( + $request->lineAmounts, + $request->shippingAmount, + $rate, + $request->shippingTaxable, + $request->pricesIncludeTax, + ); + $total = array_sum(array_map(static fn ($line): int => $line->amount, $lines)); + + return new TaxCalculationResult($lines, $total, [ + 'provider' => 'manual', + 'status' => 'calculated', + 'rate' => $rate, + 'tax_lines' => array_map(static fn ($line): array => $line->toArray(), $lines), + 'total_tax_amount' => $total, + ]); + } +} diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php new file mode 100644 index 00000000..bb31c08e --- /dev/null +++ b/app/Services/Tax/StripeTaxProvider.php @@ -0,0 +1,20 @@ + $lineAmounts + * @return list + */ + public function calculate(array $lineAmounts, int $shippingAmount, int $rate, bool $shippingTaxable = false, bool $pricesIncludeTax = false): array + { + if ($rate <= 0) { + return []; + } + + $taxAmount = array_sum(array_map( + fn (int $lineAmount): int => $this->taxForAmount($lineAmount, $rate, $pricesIncludeTax), + $lineAmounts, + )); + + if ($shippingTaxable) { + $taxAmount += $this->taxForAmount($shippingAmount, $rate, $pricesIncludeTax); + } + + return $taxAmount > 0 ? [new TaxLine('Sales tax', $rate, $taxAmount)] : []; + } + + private function taxForAmount(int $amount, int $rate, bool $pricesIncludeTax): int + { + if ($amount <= 0) { + return 0; + } + + return $pricesIncludeTax + ? intdiv($amount * $rate, 10000 + $rate) + : intdiv(($amount * $rate) + 5000, 10000); + } + + /** @param array $rates */ + public function rateForAddress(array $rates, string $countryCode, ?string $provinceCode = null, int $defaultRate = 0): int + { + $countryCode = strtoupper($countryCode); + $provinceCode = strtoupper((string) $provinceCode); + $countryRate = $rates[$countryCode] ?? null; + + if (is_array($countryRate) && $provinceCode !== '' && isset($countryRate[$provinceCode])) { + return (int) $countryRate[$provinceCode]; + } + + if (is_int($countryRate) || is_numeric($countryRate)) { + return (int) $countryRate; + } + + return $defaultRate; + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..dcf4fd9c --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,388 @@ +}> $options + * @return Collection + */ + public function rebuild(Product $product, array $options): Collection + { + $options = $this->normalizeOptions($options); + + return DB::transaction(function () use ($product, $options): Collection { + $product = Product::query() + ->whereKey($product->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $product->load([ + 'options.values', + 'variants.optionValues.option', + 'variants.inventoryItem', + ]); + + $existingOptions = $product->options; + $existingVariants = $product->variants; + $variantCombinationKeys = []; + $existingOptionPositions = $existingOptions->mapWithKeys( + static fn (ProductOption $option): array => [$option->id => $option->position], + ); + + foreach ($existingVariants as $variant) { + if ($variant->status === 'active') { + $variantCombinationKeys[$variant->id] = $this->combinationKey( + $variant->optionValues->map(static fn (ProductOptionValue $value): array => [ + 'option_id' => (int) $value->product_option_id, + 'value' => $value->value, + ])->all(), + ); + } + } + + $defaultVariant = $existingVariants->firstWhere('is_default', true) ?? $existingVariants->first(); + $defaultPricing = $this->defaultPricing($defaultVariant, (int) $product->store_id); + $oldOptionsByName = $existingOptions->keyBy(fn (ProductOption $option): string => $this->normalizeKey($option->name)); + $desiredOptionIds = []; + $desiredValueIdsByOption = []; + + $positionOffset = $this->positionOffset($existingOptions); + + foreach ($existingOptions as $option) { + $option->position += $positionOffset; + $option->save(); + + foreach ($option->values as $value) { + $value->position += $positionOffset; + $value->save(); + } + } + + $retainedOptionIds = []; + + foreach ($options as $optionPosition => $optionData) { + $optionKey = $this->normalizeKey($optionData['name']); + $option = $oldOptionsByName->get($optionKey); + + if ($option && in_array($option->id, $retainedOptionIds, true)) { + $option = null; + } + + if (! $option) { + $option = $existingOptions->first(function (ProductOption $candidate) use ($optionPosition, $existingOptionPositions, $retainedOptionIds): bool { + return ! in_array($candidate->id, $retainedOptionIds, true) + && $existingOptionPositions->get($candidate->id) === $optionPosition; + }) ?? $existingOptions->first( + fn (ProductOption $candidate): bool => ! in_array($candidate->id, $retainedOptionIds, true), + ); + } + + if (! $option) { + $option = $product->options()->create([ + 'name' => $optionData['name'], + 'position' => $optionPosition, + ]); + } else { + $option->name = $optionData['name']; + $option->position = $optionPosition; + $option->save(); + $retainedOptionIds[] = $option->id; + } + + $desiredOptionIds[] = $option->id; + $existingValuesByName = $option->values + ->keyBy(fn (ProductOptionValue $value): string => $this->normalizeKey($value->value)); + $desiredValueIdsByOption[$option->id] = []; + + foreach ($optionData['values'] as $valuePosition => $valueLabel) { + $value = $existingValuesByName->get($this->normalizeKey($valueLabel)); + + if (! $value) { + $value = $option->values()->create([ + 'value' => $valueLabel, + 'position' => $valuePosition, + ]); + } else { + $value->value = $valueLabel; + $value->position = $valuePosition; + $value->save(); + } + + $desiredValueIdsByOption[$option->id][] = $value->id; + } + + $obsoleteValueIds = $option->values() + ->whereNotIn('id', $desiredValueIdsByOption[$option->id]) + ->pluck('id'); + + if ($obsoleteValueIds->isNotEmpty()) { + ProductOptionValue::query()->whereIn('id', $obsoleteValueIds)->delete(); + } + } + + $obsoleteOptionIds = $existingOptions + ->whereNotIn('id', $retainedOptionIds) + ->pluck('id'); + + if ($obsoleteOptionIds->isNotEmpty()) { + ProductOption::query()->whereIn('id', $obsoleteOptionIds)->delete(); + } + + $desiredCombinations = $this->cartesianProduct($desiredOptionIds, $desiredValueIdsByOption); + $existingVariantsByCombination = []; + + foreach ($existingVariants as $variant) { + if (isset($variantCombinationKeys[$variant->id])) { + $existingVariantsByCombination[$variantCombinationKeys[$variant->id]] = $variant; + } + } + + $retainedVariantIds = []; + $rebuiltVariants = collect(); + + foreach ($desiredCombinations as $position => $combination) { + $combinationKey = $this->combinationKey($combination['labels']); + $variant = $existingVariantsByCombination[$combinationKey] ?? null; + + if ($variant) { + $variant->position = $position; + $variant->optionValues()->sync($combination['value_ids']); + $variant->save(); + } else { + $variant = $product->variants()->create([ + ...array_diff_key($defaultPricing, ['inventory_policy' => true]), + 'is_default' => false, + 'position' => $position, + 'status' => 'active', + ]); + + $variant->optionValues()->sync($combination['value_ids']); + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => $defaultPricing['inventory_policy'], + ]); + } + + $retainedVariantIds[] = $variant->id; + $rebuiltVariants->push($variant); + } + + $removedVariants = $existingVariants->reject( + fn (ProductVariant $variant): bool => in_array($variant->id, $retainedVariantIds, true), + ); + + if ($removedVariants->isNotEmpty()) { + $removedIds = $removedVariants->modelKeys(); + $variantsWithOrderHistory = OrderLine::query() + ->whereIn('variant_id', $removedIds) + ->pluck('variant_id') + ->unique() + ->all(); + $variantsToArchive = $removedVariants->whereIn('id', $variantsWithOrderHistory); + $variantsToDelete = $removedVariants->whereNotIn('id', $variantsWithOrderHistory); + + foreach ($variantsToArchive as $variant) { + $variant->status = 'archived'; + $variant->is_default = false; + $variant->save(); + } + + foreach ($variantsToDelete as $variant) { + $variant->delete(); + } + } + + $defaultVariantId = null; + + if ($options === []) { + $defaultVariantId = $rebuiltVariants->first()?->id; + } else { + $defaultVariantId = $rebuiltVariants + ->first(fn (ProductVariant $variant): bool => $variant->is_default && $variant->status === 'active') + ?->id ?? $rebuiltVariants->first()?->id; + } + + ProductVariant::query() + ->where('product_id', $product->id) + ->update(['is_default' => false]); + + if ($defaultVariantId !== null) { + ProductVariant::query()->whereKey($defaultVariantId)->update(['is_default' => true]); + } + + return $product->variants() + ->where('status', 'active') + ->with(['inventoryItem', 'optionValues.option']) + ->orderBy('position') + ->get(); + }); + } + + /** + * @param array $options + * @return array}> + */ + private function normalizeOptions(array $options): array + { + if (! array_is_list($options)) { + throw ValidationException::withMessages(['options' => 'Options must be provided as a list.']); + } + + if (count($options) > 3) { + throw ValidationException::withMessages(['options' => 'A product can have at most three options.']); + } + + $normalized = []; + $seenOptionNames = []; + + foreach ($options as $optionPosition => $option) { + if (! is_array($option) || ! is_string($option['name'] ?? null) || ! is_array($option['values'] ?? null)) { + throw ValidationException::withMessages(["options.{$optionPosition}" => 'Each option needs a name and a list of values.']); + } + + $name = trim($option['name']); + + if ($name === '' || mb_strlen($name) > 255) { + throw ValidationException::withMessages(["options.{$optionPosition}.name" => 'Option names must contain between 1 and 255 characters.']); + } + + $nameKey = $this->normalizeKey($name); + + if (isset($seenOptionNames[$nameKey])) { + throw ValidationException::withMessages(["options.{$optionPosition}.name" => 'Option names must be unique.']); + } + + $seenOptionNames[$nameKey] = true; + + if (! array_is_list($option['values']) || $option['values'] === []) { + throw ValidationException::withMessages(["options.{$optionPosition}.values" => 'Each option needs at least one value.']); + } + + $values = []; + $seenValues = []; + + foreach ($option['values'] as $valuePosition => $value) { + if (! is_string($value)) { + throw ValidationException::withMessages(["options.{$optionPosition}.values.{$valuePosition}" => 'Option values must be text.']); + } + + $value = trim($value); + + if ($value === '' || mb_strlen($value) > 255) { + throw ValidationException::withMessages(["options.{$optionPosition}.values.{$valuePosition}" => 'Option values must contain between 1 and 255 characters.']); + } + + $valueKey = $this->normalizeKey($value); + + if (isset($seenValues[$valueKey])) { + throw ValidationException::withMessages(["options.{$optionPosition}.values.{$valuePosition}" => 'Values for the same option must be unique.']); + } + + $seenValues[$valueKey] = true; + $values[] = $value; + } + + $normalized[] = ['name' => $name, 'values' => $values]; + } + + return $normalized; + } + + /** + * @param Collection $options + */ + private function positionOffset(Collection $options): int + { + $positions = $options->flatMap(fn (ProductOption $option): array => [ + $option->position, + ...$option->values->pluck('position')->all(), + ]); + + return ((int) $positions->max()) + $positions->count() + 100; + } + + /** + * @return array{sku: ?string, barcode: ?string, price_amount: int, compare_at_amount: ?int, currency: string, weight_g: ?int, requires_shipping: bool, inventory_policy: string} + */ + private function defaultPricing(?ProductVariant $variant, int $storeId): array + { + $currency = $variant?->currency ?? Store::query()->find($storeId)?->default_currency ?? 'USD'; + + return [ + 'sku' => null, + 'barcode' => null, + 'price_amount' => (int) ($variant?->price_amount ?? 0), + 'compare_at_amount' => $variant?->compare_at_amount, + 'currency' => $currency, + 'weight_g' => $variant?->weight_g, + 'requires_shipping' => $variant?->requires_shipping ?? true, + 'inventory_policy' => $variant?->inventoryItem?->policy ?? 'deny', + ]; + } + + /** + * @param array $optionIds + * @param array> $valueIdsByOption + * @return array, labels: array}> + */ + private function cartesianProduct(array $optionIds, array $valueIdsByOption): array + { + $combinations = [['value_ids' => [], 'labels' => []]]; + + foreach ($optionIds as $optionId) { + $option = ProductOption::query()->with('values')->findOrFail($optionId); + $next = []; + + foreach ($combinations as $combination) { + foreach ($valueIdsByOption[$optionId] as $valueId) { + $value = $option->values->firstWhere('id', $valueId); + + $next[] = [ + 'value_ids' => [...$combination['value_ids'], $valueId], + 'labels' => [...$combination['labels'], ['option_id' => $option->id, 'value' => (string) $value?->value]], + ]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** + * @param array $labels + */ + private function combinationKey(array $labels): string + { + $normalizedLabels = collect($labels) + ->map(fn (array $label): array => [(int) $label['option_id'], $this->normalizeKey($label['value'])]) + ->sortBy(static fn (array $label): string => str_pad((string) $label[0], 12, '0', STR_PAD_LEFT)."\0".$label[1]) + ->values() + ->all(); + + return json_encode($normalizedLabels, JSON_THROW_ON_ERROR); + } + + private function normalizeKey(string $value): string + { + return Str::lower(trim($value)); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..db684fed --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,168 @@ + $payload */ + public function dispatch(Store $store, string $eventType, array $payload, ?DateTimeInterface $occurredAt = null): int + { + $timestamp = $occurredAt === null + ? now('UTC')->timestamp + : CarbonImmutable::instance($occurredAt)->utc()->timestamp; + $subscriptionIds = WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('event_type', $eventType) + ->where('status', 'active') + ->orderBy('id') + ->pluck('id'); + + foreach ($subscriptionIds as $subscriptionId) { + DeliverWebhook::dispatch( + (int) $subscriptionId, + (string) Str::uuid(), + $eventType, + $payload, + $timestamp, + ); + } + + return $subscriptionIds->count(); + } + + /** @param array $payload */ + public function deliver(int $subscriptionId, string $deliveryId, string $eventType, array $payload, int $timestamp): void + { + $subscription = WebhookSubscription::withoutGlobalScopes()->find($subscriptionId); + + if (! $subscription || $subscription->status !== 'active') { + return; + } + + $delivery = WebhookDelivery::query()->firstOrCreate( + ['subscription_id' => $subscription->getKey(), 'event_id' => $deliveryId], + ['status' => 'pending', 'attempt_count' => 1, 'last_attempt_at' => now()], + ); + + if ($delivery->status === 'success') { + return; + } + + if (! $delivery->wasRecentlyCreated) { + $delivery->forceFill([ + 'status' => 'pending', + 'attempt_count' => (int) $delivery->attempt_count + 1, + 'last_attempt_at' => now(), + 'response_code' => null, + 'response_body_snippet' => null, + ])->save(); + } + + try { + $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + $signature = hash_hmac('sha256', $timestamp.'.'.$body, $subscription->signing_secret_encrypted); + $response = Http::connectTimeout(3) + ->timeout(10) + ->withHeaders([ + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $eventType, + 'X-Platform-Delivery-Id' => $deliveryId, + 'X-Platform-Timestamp' => (string) $timestamp, + 'Content-Type' => 'application/json', + ]) + ->withBody($body, 'application/json') + ->post($subscription->target_url); + } catch (Throwable $exception) { + $this->recordFailure($subscriptionId, (int) $delivery->getKey(), null, null); + + throw $exception; + } + + if (! $response->successful()) { + $this->recordFailure( + $subscriptionId, + (int) $delivery->getKey(), + $response->status(), + Str::limit($response->body(), 1000), + ); + + throw new RuntimeException("Webhook endpoint responded with HTTP {$response->status()}."); + } + + DB::transaction(function () use ($delivery, $response): void { + $delivery->forceFill([ + 'status' => 'success', + 'response_code' => $response->status(), + 'response_body_snippet' => Str::limit($response->body(), 1000), + ])->save(); + }); + } + + private function recordFailure(int $subscriptionId, int $deliveryId, ?int $responseCode, ?string $responseBodySnippet): void + { + DB::transaction(function () use ($subscriptionId, $deliveryId, $responseCode, $responseBodySnippet): void { + $subscription = WebhookSubscription::withoutGlobalScopes() + ->whereKey($subscriptionId) + ->lockForUpdate() + ->first(); + $delivery = WebhookDelivery::query()->whereKey($deliveryId)->first(); + + if (! $subscription || ! $delivery) { + return; + } + + $delivery->forceFill([ + 'status' => 'failed', + 'response_code' => $responseCode, + 'response_body_snippet' => $responseBodySnippet, + ])->save(); + + if ($subscription->status !== 'active') { + return; + } + + $consecutiveFailures = 0; + $recentDeliveries = WebhookDelivery::query() + ->where('subscription_id', $subscriptionId) + ->where('last_attempt_at', '>=', $subscription->updated_at) + ->whereIn('status', ['success', 'failed']) + ->orderByDesc('last_attempt_at') + ->orderByDesc('id') + ->limit(5) + ->get(['status', 'attempt_count']); + + foreach ($recentDeliveries as $recentDelivery) { + if ($recentDelivery->status === 'success') { + break; + } + + $consecutiveFailures += max(1, (int) $recentDelivery->attempt_count); + + if ($consecutiveFailures >= 5) { + break; + } + } + + if ($consecutiveFailures >= 5) { + $subscription->forceFill(['status' => 'paused'])->save(); + Log::warning('Webhook subscription paused after five consecutive delivery failures.', [ + 'subscription_id' => $subscriptionId, + 'consecutive_failures' => $consecutiveFailures, + ]); + } + }); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..d69404c1 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,35 @@ +where('store_id', $storeId) + ->where('handle', $candidate) + ->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists()) { + $candidate = $base.'-'.$suffix; + $suffix++; + } + + return $candidate; + } +} diff --git a/app/Support/HtmlSanitizer.php b/app/Support/HtmlSanitizer.php new file mode 100644 index 00000000..0dbbdd96 --- /dev/null +++ b/app/Support/HtmlSanitizer.php @@ -0,0 +1,115 @@ +loadHTML('
'.(string) $html.'
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $root = $document->getElementById('sanitizer-root'); + + if (! $root) { + return e(strip_tags((string) $html)); + } + + $this->sanitizeChildren($root); + $safe = ''; + + foreach ($root->childNodes as $child) { + $safe .= $document->saveHTML($child); + } + + return $safe; + } + + private function sanitizeChildren(DOMNode $node): void + { + foreach (iterator_to_array($node->childNodes) as $child) { + if ($child instanceof DOMText) { + continue; + } + + if (! $child instanceof DOMElement || ! in_array(strtolower($child->tagName), self::ALLOWED_TAGS, true)) { + if ($child instanceof DOMElement && in_array(strtolower($child->tagName), ['script', 'style', 'iframe', 'object', 'embed', 'svg', 'math'], true)) { + $node->removeChild($child); + + continue; + } + + while ($child->firstChild) { + $node->insertBefore($child->firstChild, $child); + } + + $node->removeChild($child); + + continue; + } + + $this->sanitizeAttributes($child); + + if (strtolower($child->tagName) !== 'img') { + $this->sanitizeChildren($child); + } + } + } + + private function sanitizeAttributes(DOMElement $element): void + { + $tag = strtolower($element->tagName); + + foreach (iterator_to_array($element->attributes) as $attribute) { + $name = strtolower($attribute->name); + $allowed = $tag === 'a' + ? in_array($name, ['href', 'title', 'target', 'rel'], true) + : ($tag === 'img' && in_array($name, ['src', 'alt', 'width', 'height'], true)); + + if (! $allowed) { + $element->removeAttribute($attribute->name); + } + } + + $urlAttribute = $tag === 'a' ? 'href' : ($tag === 'img' ? 'src' : null); + + if ($urlAttribute !== null && $element->hasAttribute($urlAttribute) && ! $this->isSafeUrl($element->getAttribute($urlAttribute))) { + $element->removeAttribute($urlAttribute); + } + + if ($tag === 'a' && $element->getAttribute('target') === '_blank') { + $element->setAttribute('rel', 'noopener noreferrer'); + } + + if ($tag === 'img') { + $element->setAttribute('loading', 'lazy'); + } + } + + private function isSafeUrl(string $url): bool + { + $url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + if ($url === '' || str_starts_with($url, '//')) { + return false; + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + return $scheme === null || in_array(strtolower($scheme), ['http', 'https', 'mailto', 'tel'], true); + } +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..1d00859e --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,43 @@ + $taxLines + * @param array $discountAllocations + * @param list}> $appliedDiscounts + */ + 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 array $discountAllocations = [], + public array $appliedDiscounts = [], + ) {} + + /** @return array */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'taxLines' => array_map(static fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'taxTotal' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + 'discountAllocations' => $this->discountAllocations, + 'appliedDiscounts' => $this->appliedDiscounts, + 'applied_discount_ids' => array_values(array_unique(array_filter(array_map( + static fn (array $discount): ?int => $discount['discount_id'], + $this->appliedDiscounts, + ), static fn (?int $discountId): bool => $discountId !== null))), + ]; + } +} diff --git a/app/ValueObjects/TaxCalculationRequest.php b/app/ValueObjects/TaxCalculationRequest.php new file mode 100644 index 00000000..1e6248bf --- /dev/null +++ b/app/ValueObjects/TaxCalculationRequest.php @@ -0,0 +1,25 @@ + $lineAmounts Amounts after discounts, in minor currency units. + * @param array $address + * @param array $rates Country and province rates in basis points. + * @param array $configuration + */ + public function __construct( + public int $storeId, + public array $lineAmounts, + public int $shippingAmount, + public array $address, + public array $rates, + public int $defaultRate, + public bool $pricesIncludeTax, + public bool $shippingTaxable, + public string $currency, + public array $configuration = [], + ) {} +} diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php new file mode 100644 index 00000000..db5fe401 --- /dev/null +++ b/app/ValueObjects/TaxCalculationResult.php @@ -0,0 +1,16 @@ + $lines + * @param array $snapshot + */ + public function __construct( + public array $lines, + public int $total, + public array $snapshot, + ) {} +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..52c24fdb --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,18 @@ + $this->name, 'rate' => $this->rate, 'amount' => $this->amount]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..56a654e8 --- /dev/null +++ b/boost.json @@ -0,0 +1,19 @@ +{ + "agents": [ + "codex" + ], + "cloud": false, + "guidelines": true, + "mcp": true, + "nightwatch": false, + "sail": false, + "skills": [ + "infer-conventions", + "developing-with-fortify", + "laravel-best-practices", + "testing-best-practices", + "fluxui-development", + "livewire-development", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..a11c349c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => ResolveStore::class, + 'api.token' => \App\Http\Middleware\AuthenticateApiToken::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->render(function (\App\Exceptions\InsufficientInventoryException $exception, \Illuminate\Http\Request $request) { + if ($request->expectsJson()) { + return response()->json(['message' => $exception->getMessage(), 'error_code' => 'insufficient_inventory'], 422); + } + + return null; + }); + $exceptions->render(function (\App\Exceptions\PaymentDeclinedException $exception, \Illuminate\Http\Request $request) { + if ($request->expectsJson()) { + return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->codeName], 422); + } + + return null; + }); + $exceptions->render(function (\App\Exceptions\CartVersionConflictException $exception, \Illuminate\Http\Request $request) { + if ($request->expectsJson()) { + return response()->json(['message' => $exception->getMessage(), 'current_version' => $exception->currentVersion], 409); + } + + return null; + }); })->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c573..b0d1f4c4 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,7 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\AuditServiceProvider::class, App\Providers\FortifyServiceProvider::class, + App\Providers\WebhookEventServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 1f848aaf..dc6ffadd 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.9", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", @@ -50,7 +50,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen database --queue=default,search --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "lint": [ "pint --parallel" diff --git a/composer.lock b/composer.lock index e4255dbd..31d53681 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": "ffd5cd2ae98f89dbfea2f2c9d23da3df", "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.9.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0", + "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0", "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|^1.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-09-17T02:40:42+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "cfa4f38f82873eeb6848527883545f98f871e229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/cfa4f38f82873eeb6848527883545f98f871e229", + "reference": "cfa4f38f82873eeb6848527883545f98f871e229", "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-09-14T14:35:19+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/config/auth.php b/config/auth.php index 7d1eb0de..a9868347 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,6 +69,11 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'store-customers', + 'model' => App\Models\Customer::class, + ], + // 'users' => [ // 'driver' => 'database', // 'table' => 'users', @@ -97,6 +106,14 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'driver' => 'store-database', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/cache.php b/config/cache.php index b32aead2..9289977f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/database.php b/config/database.php index df933e7f..210e1eac 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'WAL', + 'synchronous' => 'NORMAL', 'transaction_mode' => 'DEFERRED', ], diff --git a/config/fortify.php b/config/fortify.php index ce67e2c3..2bb13bef 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -144,8 +144,6 @@ */ 'features' => [ - Features::registration(), - Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ 'confirm' => true, diff --git a/config/logging.php b/config/logging.php index 9e998a49..502ca4f3 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,15 @@ 'replace_placeholders' => true, ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + 'replace_placeholders' => true, + 'tap' => [App\Logging\CustomizeAuditFormatter::class], + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/queue.php b/config/queue.php index 79c2c0a2..d0e0f50e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- diff --git a/config/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/shop.php b/config/shop.php new file mode 100644 index 00000000..79bd2ab4 --- /dev/null +++ b/config/shop.php @@ -0,0 +1,9 @@ + [ + 'image_max_bytes' => (int) env('SHOP_MEDIA_IMAGE_MAX_BYTES', 50 * 1024 * 1024), + 'video_max_bytes' => (int) env('SHOP_MEDIA_VIDEO_MAX_BYTES', 500 * 1024 * 1024), + 'signed_upload_minutes' => (int) env('SHOP_MEDIA_SIGNED_UPLOAD_MINUTES', 10), + ], +]; diff --git a/database/factories/AnalyticsExportFactory.php b/database/factories/AnalyticsExportFactory.php new file mode 100644 index 00000000..a9974354 --- /dev/null +++ b/database/factories/AnalyticsExportFactory.php @@ -0,0 +1,29 @@ + + */ +class AnalyticsExportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'from_date' => now()->subDays(29)->toDateString(), + 'to_date' => now()->toDateString(), + 'channel' => 'all', + 'device' => 'all', + 'status' => 'queued', + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..7ccb1854 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,28 @@ + + */ +class CartFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + 'discount_code' => null, + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..f94fd334 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,27 @@ + + */ +class CustomerFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..19c4817c --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,34 @@ + + */ +class DiscountFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'code' => strtoupper(fake()->unique()->bothify('SAVE-####')), + 'title' => 'Test discount', + 'type' => 'percentage', + 'value' => 10, + 'minimum_subtotal_amount' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'is_active' => true, + 'rules_json' => [], + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..0ca9ae64 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,28 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'variant_id' => ProductVariant::factory(), + 'store_id' => fn (array $attributes): int => (int) ProductVariant::query()->with('product')->findOrFail($attributes['variant_id'])->product->store_id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]; + } +} diff --git a/database/factories/OrderExportFactory.php b/database/factories/OrderExportFactory.php new file mode 100644 index 00000000..0569a797 --- /dev/null +++ b/database/factories/OrderExportFactory.php @@ -0,0 +1,33 @@ + + */ +class OrderExportFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'requested_by_user_id' => User::factory(), + 'format' => 'csv', + 'filters_json' => [], + 'status' => 'queued', + 'row_count' => null, + 'storage_key' => null, + 'error_message' => null, + 'completed_at' => null, + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..a991973b --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,24 @@ + + */ +class OrganizationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..78958e73 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,32 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'title' => fake()->words(3, true), + 'handle' => fn (array $attributes): string => Str::slug($attributes['title']).'-'.fake()->unique()->numberBetween(1, 100000), + 'status' => 'active', + 'description_html' => fake()->paragraph(), + 'vendor' => fake()->company(), + 'product_type' => 'General', + 'tags' => ['test'], + 'published_at' => now(), + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..82935311 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,31 @@ + + */ +class ProductVariantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => \App\Models\Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####??'), + 'price_amount' => fake()->numberBetween(500, 10000), + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..4257eb41 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,30 @@ + + */ +class ShippingRateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'shipping_zone_id' => \App\Models\ShippingZone::factory(), + 'name' => 'Standard shipping', + 'type' => 'flat', + 'price_amount' => 500, + 'min_order_amount' => null, + 'max_order_amount' => null, + 'config_json' => ['estimated_days_min' => 3, 'estimated_days_max' => 5], + 'is_active' => true, + ]; + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..ba5e782f --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,26 @@ + + */ +class ShippingZoneFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'name' => 'Germany', + 'countries' => ['DE'], + 'is_active' => true, + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..9bcee093 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,27 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..023b8ff7 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => \App\Models\Organization::factory(), + 'name' => fake()->company().' Store', + 'handle' => fake()->unique()->slug(2), + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } +} diff --git a/database/factories/TaxSettingFactory.php b/database/factories/TaxSettingFactory.php new file mode 100644 index 00000000..59141ba8 --- /dev/null +++ b/database/factories/TaxSettingFactory.php @@ -0,0 +1,26 @@ + + */ +class TaxSettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => \App\Models\Store::factory(), + 'prices_include_tax' => false, + 'default_rate' => 1900, + 'rates_json' => ['DE' => 1900], + ]; + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..33e55120 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -15,6 +15,8 @@ public function up(): void $table->id(); $table->string('name'); $table->string('email')->unique(); + $table->string('status')->default('active')->index(); + $table->timestamp('last_login_at')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); diff --git a/database/migrations/2026_09_23_053558_create_shop_system_tables.php b/database/migrations/2026_09_23_053558_create_shop_system_tables.php new file mode 100644 index 00000000..866db3a0 --- /dev/null +++ b/database/migrations/2026_09_23_053558_create_shop_system_tables.php @@ -0,0 +1,586 @@ +id(); + $table->string('name'); + $table->string('billing_email')->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('USD'); + $table->string('default_locale', 12)->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + }); + + 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->string('tls_mode')->default('managed'); + $table->timestamp('created_at')->nullable(); + $table->index(['store_id', 'is_primary']); + }); + + 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->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'user_id']); + $table->index(['store_id', 'role']); + }); + + Schema::create('store_settings', function (Blueprint $table): void { + $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('products', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->string('status')->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->json('tags')->default('[]'); + $table->timestamp('published_at')->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->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->unique(['product_option_id', 'position']); + }); + + Schema::create('product_variants', function (Blueprint $table): void { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->nullable()->index(); + $table->string('barcode')->nullable()->index(); + $table->unsignedInteger('price_amount')->default(0); + $table->unsignedInteger('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default('active'); + $table->timestamps(); + $table->index(['product_id', 'position']); + }); + + 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']); + }); + + 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->unsignedInteger('quantity_reserved')->default(0); + $table->string('policy')->default('deny'); + }); + + Schema::create('collections', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->string('type')->default('manual'); + $table->string('status')->default('active'); + $table->timestamps(); + $table->unique(['store_id', 'handle']); + $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']); + }); + + Schema::create('product_media', function (Blueprint $table): void { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('type')->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('byte_size')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default('processing'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('customers', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('password')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->rememberToken(); + $table->timestamps(); + $table->unique(['store_id', 'email']); + }); + + Schema::create('customer_addresses', function (Blueprint $table): void { + $table->id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->json('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + $table->index(['customer_id', 'is_default']); + }); + + 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('USD'); + $table->unsignedInteger('cart_version')->default(1); + $table->string('status')->default('active'); + $table->string('discount_code')->nullable(); + $table->timestamps(); + }); + + Schema::create('cart_lines', function (Blueprint $table): void { + $table->id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + $table->unsignedInteger('unit_price_amount')->default(0); + $table->unsignedInteger('line_subtotal_amount')->default(0); + $table->unsignedInteger('line_discount_amount')->default(0); + $table->unsignedInteger('line_total_amount')->default(0); + $table->unique(['cart_id', 'variant_id']); + }); + + Schema::create('shipping_zones', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('countries')->default('[]'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + 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->unsignedInteger('min_order_amount')->nullable(); + $table->unsignedInteger('max_order_amount')->nullable(); + $table->json('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('tax_settings', function (Blueprint $table): void { + $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->boolean('prices_include_tax')->default(false); + $table->unsignedInteger('default_rate')->default(0); + $table->json('rates_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('discounts', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('code')->nullable(); + $table->string('title'); + $table->string('type')->default('percentage'); + $table->unsignedInteger('value')->default(0); + $table->unsignedInteger('minimum_subtotal_amount')->nullable(); + $table->unsignedInteger('usage_limit')->nullable(); + $table->unsignedInteger('usage_count')->default(0); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('rules_json')->default('{}'); + $table->timestamps(); + $table->unique(['store_id', 'code']); + }); + + Schema::create('checkouts', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status')->default('started'); + $table->string('payment_method')->nullable(); + $table->string('email')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->foreignId('shipping_method_id')->nullable()->constrained('shipping_rates')->nullOnDelete(); + $table->string('discount_code')->nullable(); + $table->json('totals_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('expires_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('orders', function (Blueprint $table): void { + $table->id(); + $table->foreignId('checkout_id')->nullable()->unique()->constrained()->nullOnDelete(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('order_number'); + $table->string('payment_method'); + $table->string('status')->default('pending'); + $table->string('financial_status')->default('pending'); + $table->string('fulfillment_status')->default('unfulfilled'); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('subtotal_amount')->default(0); + $table->unsignedInteger('discount_amount')->default(0); + $table->unsignedInteger('shipping_amount')->default(0); + $table->unsignedInteger('tax_amount')->default(0); + $table->unsignedInteger('total_amount')->default(0); + $table->string('email')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'order_number']); + $table->index(['store_id', 'status']); + }); + + 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('title_snapshot'); + $table->string('variant_title_snapshot')->nullable(); + $table->string('sku_snapshot')->nullable(); + $table->unsignedInteger('quantity')->default(1); + $table->unsignedInteger('unit_price_amount')->default(0); + $table->unsignedInteger('total_amount')->default(0); + $table->json('tax_lines_json')->default('[]'); + $table->json('discount_allocations_json')->default('[]'); + }); + + Schema::create('payments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('provider')->default('mock'); + $table->string('method'); + $table->string('provider_payment_id')->nullable(); + $table->string('status')->default('pending'); + $table->unsignedInteger('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('refunds', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('amount'); + $table->string('reason')->nullable(); + $table->string('status')->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('refund_lines', function (Blueprint $table): void { + $table->id(); + $table->foreignId('refund_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity')->default(1); + $table->unsignedInteger('amount')->default(0); + $table->unique(['refund_id', 'order_line_id']); + }); + + Schema::create('fulfillments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + 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')->default(1); + $table->unique(['fulfillment_id', 'order_line_id']); + }); + + Schema::create('pages', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->longText('body_html')->nullable(); + $table->string('status')->default('draft'); + $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('handle'); + $table->string('title'); + $table->timestamps(); + $table->unique(['store_id', 'handle']); + }); + + Schema::create('navigation_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->string('type')->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedInteger('resource_id')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + + Schema::create('themes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('status')->default('draft'); + $table->boolean('is_active')->default(false); + $table->timestamps(); + }); + + Schema::create('theme_files', function (Blueprint $table): void { + $table->id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->longText('content'); + $table->timestamps(); + $table->unique(['theme_id', 'path']); + }); + + Schema::create('theme_settings', function (Blueprint $table): void { + $table->foreignId('theme_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('analytics_events', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('type', ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']); + $table->string('session_id')->nullable(); + $table->string('client_event_id')->nullable(); + $table->json('properties_json')->default('{}'); + $table->timestamp('occurred_at')->nullable(); + // Kept while storefront writers move to the canonical analytics fields. + $table->json('payload')->default('{}'); + $table->timestamp('created_at')->nullable(); + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + + 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->unsignedInteger('checkout_completed_count')->default(0); + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + + 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->string('session_id')->nullable(); + $table->timestamps(); + $table->index(['store_id', 'query']); + }); + + Schema::create('search_settings', function (Blueprint $table): void { + $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('synonyms_json')->default('{}'); + $table->json('stopwords_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('apps', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('handle')->unique(); + $table->text('description')->nullable(); + $table->json('scopes_json')->default('[]'); + $table->boolean('is_available')->default(true); + $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('settings_json')->default('{}'); + $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->string('client_secret_hash'); + $table->json('scopes_json')->default('[]'); + $table->timestamps(); + }); + + Schema::create('oauth_tokens', function (Blueprint $table): void { + $table->id(); + $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete(); + $table->string('token_hash')->unique(); + $table->json('scopes_json')->default('[]'); + $table->timestamp('expires_at'); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('personal_access_tokens', function (Blueprint $table): void { + $table->id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->json('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('webhook_subscriptions', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->enum('status', ['active', 'paused', 'disabled'])->default('active'); + $table->timestamps(); + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + + Schema::create('webhook_deliveries', function (Blueprint $table): void { + $table->id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->unsignedInteger('attempt_count')->default(1); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + $table->timestamps(); + $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + $table->unique(['subscription_id', 'event_id'], 'idx_webhook_deliveries_subscription_event'); + }); + + Schema::create('customer_password_reset_tokens', function (Blueprint $table): void { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'email']); + }); + + if (Schema::getConnection()->getDriverName() === 'sqlite') { + \Illuminate\Support\Facades\DB::statement("CREATE VIRTUAL TABLE products_fts USING fts5(store_id UNINDEXED, product_id UNINDEXED, title, description, vendor, tags, tokenize='unicode61')"); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach ([ + 'products_fts', 'customer_password_reset_tokens', 'webhook_deliveries', 'webhook_subscriptions', 'personal_access_tokens', + 'oauth_tokens', 'oauth_clients', 'app_installations', 'apps', 'search_settings', 'search_queries', 'analytics_daily', 'analytics_events', 'fulfillment_lines', 'fulfillments', 'refund_lines', 'refunds', + 'payments', 'order_lines', 'orders', 'checkouts', 'discounts', 'tax_settings', 'shipping_rates', + 'shipping_zones', 'cart_lines', 'carts', 'customer_addresses', 'customers', 'pages', 'theme_settings', + 'theme_files', 'themes', 'navigation_items', 'navigation_menus', 'product_media', 'collection_products', 'collections', + 'inventory_items', 'variant_option_values', 'product_variants', 'product_option_values', 'product_options', + 'products', 'store_settings', 'store_users', 'store_domains', 'stores', 'organizations', + ] as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/database/migrations/2026_09_23_063804_create_newsletter_subscriptions_table.php b/database/migrations/2026_09_23_063804_create_newsletter_subscriptions_table.php new file mode 100644 index 00000000..8a29610c --- /dev/null +++ b/database/migrations/2026_09_23_063804_create_newsletter_subscriptions_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('source')->default('storefront'); + $table->timestamp('subscribed_at')->nullable(); + $table->timestamp('unsubscribed_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('newsletter_subscriptions'); + } +}; diff --git a/database/migrations/2026_09_23_074000_add_product_type_to_products_fts.php b/database/migrations/2026_09_23_074000_add_product_type_to_products_fts.php new file mode 100644 index 00000000..c1e16f66 --- /dev/null +++ b/database/migrations/2026_09_23_074000_add_product_type_to_products_fts.php @@ -0,0 +1,50 @@ +getDriverName() !== 'sqlite') { + return; + } + + DB::statement('DROP TABLE IF EXISTS products_fts'); + DB::statement("CREATE VIRTUAL TABLE products_fts USING fts5(store_id UNINDEXED, product_id UNINDEXED, title, description, vendor, product_type, tags, tokenize='unicode61')"); + + foreach (DB::table('products')->orderBy('id')->cursor() as $product) { + DB::table('products_fts')->insert([ + 'store_id' => (string) $product->store_id, + 'product_id' => (string) $product->id, + 'title' => $product->title, + 'description' => strip_tags((string) $product->description_html), + 'vendor' => (string) $product->vendor, + 'product_type' => (string) $product->product_type, + 'tags' => implode(' ', json_decode((string) $product->tags, true) ?: []), + ]); + } + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'sqlite') { + return; + } + + DB::statement('DROP TABLE IF EXISTS products_fts'); + DB::statement("CREATE VIRTUAL TABLE products_fts USING fts5(store_id UNINDEXED, product_id UNINDEXED, title, description, vendor, tags, tokenize='unicode61')"); + + foreach (DB::table('products')->orderBy('id')->cursor() as $product) { + DB::table('products_fts')->insert([ + 'store_id' => (string) $product->store_id, + 'product_id' => (string) $product->id, + 'title' => $product->title, + 'description' => strip_tags((string) $product->description_html), + 'vendor' => (string) $product->vendor, + 'tags' => implode(' ', json_decode((string) $product->tags, true) ?: []), + ]); + } + } +}; diff --git a/database/migrations/2026_09_23_081107_rename_search_settings_stopwords_column.php b/database/migrations/2026_09_23_081107_rename_search_settings_stopwords_column.php new file mode 100644 index 00000000..4802f005 --- /dev/null +++ b/database/migrations/2026_09_23_081107_rename_search_settings_stopwords_column.php @@ -0,0 +1,40 @@ +renameColumn('stopwords_json', 'stop_words_json'); + $table->string('index_status')->default('ready'); + $table->timestamp('last_reindex_at')->nullable(); + $table->unsignedInteger('last_reindex_duration_seconds')->nullable(); + $table->unsignedInteger('documents_count')->default(0); + $table->unsignedInteger('pending_updates')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('search_settings', function (Blueprint $table): void { + $table->renameColumn('stop_words_json', 'stopwords_json'); + $table->dropColumn([ + 'index_status', + 'last_reindex_at', + 'last_reindex_duration_seconds', + 'documents_count', + 'pending_updates', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_23_082822_add_store_id_to_personal_access_tokens_table.php b/database/migrations/2026_09_23_082822_add_store_id_to_personal_access_tokens_table.php new file mode 100644 index 00000000..6fde3f15 --- /dev/null +++ b/database/migrations/2026_09_23_082822_add_store_id_to_personal_access_tokens_table.php @@ -0,0 +1,28 @@ +foreignId('store_id')->nullable()->after('tokenable_id')->constrained()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('personal_access_tokens', function (Blueprint $table): void { + $table->dropConstrainedForeignId('store_id'); + }); + } +}; diff --git a/database/migrations/2026_09_23_083306_add_api_theme_metadata_and_shipping_regions_to_shop_system_tables.php b/database/migrations/2026_09_23_083306_add_api_theme_metadata_and_shipping_regions_to_shop_system_tables.php new file mode 100644 index 00000000..605c785f --- /dev/null +++ b/database/migrations/2026_09_23_083306_add_api_theme_metadata_and_shipping_regions_to_shop_system_tables.php @@ -0,0 +1,37 @@ +string('version')->nullable(); + $table->timestamp('published_at')->nullable(); + }); + + Schema::table('shipping_zones', function (Blueprint $table): void { + $table->json('regions')->default('[]'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('shipping_zones', function (Blueprint $table): void { + $table->dropColumn('regions'); + }); + + Schema::table('themes', function (Blueprint $table): void { + $table->dropColumn(['version', 'published_at']); + }); + } +}; diff --git a/database/migrations/2026_09_23_084658_add_tax_configuration_fields_to_tax_settings_table.php b/database/migrations/2026_09_23_084658_add_tax_configuration_fields_to_tax_settings_table.php new file mode 100644 index 00000000..6fb6f824 --- /dev/null +++ b/database/migrations/2026_09_23_084658_add_tax_configuration_fields_to_tax_settings_table.php @@ -0,0 +1,50 @@ +string('mode')->default('manual'); + $table->string('provider')->default('none'); + $table->json('config_json')->default('{}'); + }); + + DB::table('tax_settings')->orderBy('store_id')->each(function (object $settings): void { + $legacyRates = json_decode((string) $settings->rates_json, true) ?: []; + $taxRates = []; + + foreach ($legacyRates as $countryCode => $rate) { + if (is_numeric($rate)) { + $taxRates[] = ['country_code' => strtoupper((string) $countryCode), 'rate' => (int) $rate]; + } + } + + DB::table('tax_settings')->where('store_id', $settings->store_id)->update([ + 'config_json' => json_encode([ + 'default_tax_rate' => (int) $settings->default_rate, + 'tax_rates' => $taxRates, + 'fallback' => 'allow', + ], JSON_THROW_ON_ERROR), + ]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tax_settings', function (Blueprint $table) { + $table->dropColumn(['mode', 'provider', 'config_json']); + }); + } +}; diff --git a/database/migrations/2026_09_23_090809_create_order_exports_table.php b/database/migrations/2026_09_23_090809_create_order_exports_table.php new file mode 100644 index 00000000..eb2d73e1 --- /dev/null +++ b/database/migrations/2026_09_23_090809_create_order_exports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('requested_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('format', 10)->default('csv'); + $table->json('filters_json')->default('{}'); + $table->string('status')->default('queued'); + $table->unsignedInteger('row_count')->nullable(); + $table->string('storage_key')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_exports'); + } +}; diff --git a/database/migrations/2026_09_23_093830_create_store_invitations_table.php b/database/migrations/2026_09_23_093830_create_store_invitations_table.php new file mode 100644 index 00000000..71961b78 --- /dev/null +++ b/database/migrations/2026_09_23_093830_create_store_invitations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('role'); + $table->string('token_hash')->unique(); + $table->timestamp('invited_at'); + $table->timestamp('expires_at')->index(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + $table->index(['store_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_invitations'); + } +}; diff --git a/database/migrations/2026_09_23_093835_add_parent_id_to_navigation_items_table.php b/database/migrations/2026_09_23_093835_add_parent_id_to_navigation_items_table.php new file mode 100644 index 00000000..70a2a213 --- /dev/null +++ b/database/migrations/2026_09_23_093835_add_parent_id_to_navigation_items_table.php @@ -0,0 +1,32 @@ +foreignId('parent_id') + ->nullable() + ->after('menu_id') + ->constrained('navigation_items') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('navigation_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('parent_id'); + }); + } +}; diff --git a/database/migrations/2026_09_23_100111_add_tax_provider_snapshot_to_checkouts_table.php b/database/migrations/2026_09_23_100111_add_tax_provider_snapshot_to_checkouts_table.php new file mode 100644 index 00000000..600bc1c1 --- /dev/null +++ b/database/migrations/2026_09_23_100111_add_tax_provider_snapshot_to_checkouts_table.php @@ -0,0 +1,28 @@ +json('tax_provider_snapshot_json')->nullable()->after('totals_json'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('checkouts', function (Blueprint $table) { + $table->dropColumn('tax_provider_snapshot_json'); + }); + } +}; diff --git a/database/migrations/2026_09_23_100744_create_analytics_exports_table.php b/database/migrations/2026_09_23_100744_create_analytics_exports_table.php new file mode 100644 index 00000000..5bba8ba0 --- /dev/null +++ b/database/migrations/2026_09_23_100744_create_analytics_exports_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('requested_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->date('from_date'); + $table->date('to_date'); + $table->string('channel', 20)->default('all'); + $table->string('device', 20)->default('all'); + $table->string('status', 20)->default('queued'); + $table->string('storage_key')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['store_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_exports'); + } +}; diff --git a/database/migrations/2026_09_23_100806_add_theme_file_metadata_to_shop_system_tables.php b/database/migrations/2026_09_23_100806_add_theme_file_metadata_to_shop_system_tables.php new file mode 100644 index 00000000..5f2ff627 --- /dev/null +++ b/database/migrations/2026_09_23_100806_add_theme_file_metadata_to_shop_system_tables.php @@ -0,0 +1,46 @@ +string('storage_key')->nullable(); + $table->string('sha256')->nullable(); + $table->unsignedInteger('byte_size')->default(0); + }); + + DB::table('theme_files')->orderBy('id')->chunkById(100, function ($files): void { + foreach ($files as $file) { + DB::table('theme_files')->where('id', $file->id)->update([ + 'storage_key' => 'themes/'.$file->theme_id.'/'.$file->path, + 'sha256' => hash('sha256', $file->content), + 'byte_size' => strlen($file->content), + ]); + } + }); + + Schema::table('theme_files', function (Blueprint $table) { + $table->string('storage_key')->nullable(false)->change(); + $table->string('sha256')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('theme_files', function (Blueprint $table) { + $table->dropColumn(['storage_key', 'sha256', 'byte_size']); + }); + } +}; diff --git a/database/migrations/2026_09_23_102225_add_canonical_analytics_event_fields.php b/database/migrations/2026_09_23_102225_add_canonical_analytics_event_fields.php new file mode 100644 index 00000000..4c3fa0b7 --- /dev/null +++ b/database/migrations/2026_09_23_102225_add_canonical_analytics_event_fields.php @@ -0,0 +1,64 @@ +json('properties_json')->default('{}'); + }); + } + + if (! Schema::hasColumn('analytics_events', 'occurred_at')) { + Schema::table('analytics_events', function (Blueprint $table): void { + $table->timestamp('occurred_at')->nullable(); + }); + } + + if (! Schema::hasColumn('analytics_daily', 'checkout_completed_count')) { + Schema::table('analytics_daily', function (Blueprint $table): void { + $table->unsignedInteger('checkout_completed_count')->default(0); + }); + } + + DB::table('analytics_events') + ->whereNotNull('payload') + ->update(['properties_json' => DB::raw('payload')]); + DB::table('analytics_events') + ->whereNull('occurred_at') + ->update(['occurred_at' => DB::raw('created_at')]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('analytics_daily', 'checkout_completed_count')) { + Schema::table('analytics_daily', function (Blueprint $table): void { + $table->dropColumn('checkout_completed_count'); + }); + } + + if (Schema::hasColumn('analytics_events', 'occurred_at')) { + Schema::table('analytics_events', function (Blueprint $table): void { + $table->dropColumn('occurred_at'); + }); + } + + if (Schema::hasColumn('analytics_events', 'properties_json')) { + Schema::table('analytics_events', function (Blueprint $table): void { + $table->dropColumn('properties_json'); + }); + } + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..4ccdf4ec 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(DemoShopSeeder::class); } } diff --git a/database/seeders/DemoShopSeeder.php b/database/seeders/DemoShopSeeder.php new file mode 100644 index 00000000..d519d5f1 --- /dev/null +++ b/database/seeders/DemoShopSeeder.php @@ -0,0 +1,653 @@ + */ + private array $products = []; + + /** @var array */ + private array $customers = []; + + public function run(): void + { + $organization = Organization::query()->updateOrCreate( + ['billing_email' => 'billing@acme.test'], + ['name' => 'Acme Corp'], + ); + $fashion = Store::query()->updateOrCreate(['handle' => 'acme-fashion'], [ + 'organization_id' => $organization->id, + 'name' => 'Acme Fashion', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + $electronics = Store::query()->updateOrCreate(['handle' => 'acme-electronics'], [ + 'organization_id' => $organization->id, + 'name' => 'Acme Electronics', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + + $this->seedDomains($fashion, $electronics); + $this->seedUsers($fashion, $electronics); + $this->seedSettings($fashion, $electronics); + $this->seedCatalog($fashion, $electronics); + $this->seedDiscounts($fashion); + $this->seedCustomers($fashion, $electronics); + $this->seedContent($fashion, $electronics); + $this->seedOrders($fashion, $electronics); + $this->seedAnalytics($fashion, $electronics); + } + + private function seedDomains(Store $fashion, Store $electronics): void + { + foreach ([ + [$fashion, 'acme-fashion.test', 'storefront', true], + [$fashion, 'shop.test', 'storefront', false], + [$fashion, 'admin.acme-fashion.test', 'admin', false], + [$electronics, 'acme-electronics.test', 'storefront', true], + ] as [$store, $hostname, $type, $primary]) { + StoreDomain::query()->updateOrCreate(['hostname' => $hostname], [ + 'store_id' => $store->id, + 'type' => $type, + 'is_primary' => $primary, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + } + } + + private function seedUsers(Store $fashion, Store $electronics): void + { + foreach ([ + ['admin@acme.test', 'Admin User', $fashion, 'owner', now()], + ['staff@acme.test', 'Staff User', $fashion, 'staff', now()->subDays(2)], + ['support@acme.test', 'Support User', $fashion, 'support', now()->subDay()], + ['manager@acme.test', 'Store Manager', $fashion, 'admin', now()->subDay()], + ['admin2@acme.test', 'Admin Two', $electronics, 'owner', now()->subDay()], + ] as [$email, $name, $store, $role, $lastLogin]) { + $user = \App\Models\User::query()->updateOrCreate(['email' => $email], [ + 'name' => $name, + 'password' => 'password', + 'status' => 'active', + 'last_login_at' => $lastLogin, + ]); + $store->users()->syncWithoutDetaching([$user->id => ['role' => $role, 'created_at' => now()]]); + } + } + + private function seedSettings(Store $fashion, Store $electronics): void + { + foreach ([[$fashion, 1001], [$electronics, 5001]] as [$store, $orderStart]) { + StoreSettings::query()->updateOrCreate(['store_id' => $store->id], ['settings_json' => [ + 'store_name' => $store->name, + 'contact_email' => $store->handle === 'acme-fashion' ? 'hello@acme-fashion.test' : 'hello@acme-electronics.test', + 'order_number_prefix' => '#', + 'order_number_start' => $orderStart, + ]]); + TaxSetting::query()->updateOrCreate(['store_id' => $store->id], [ + 'prices_include_tax' => true, + 'default_rate' => 1900, + 'rates_json' => ['DE' => 1900, 'AT' => 2000, 'FR' => 2000, 'US' => 0], + ]); + } + + $this->seedShipping($fashion, [ + ['Domestic', ['DE'], [['Standard Shipping', 499], ['Express Shipping', 999]]], + ['EU', ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], [['EU Standard', 899]]], + ['Rest of World', ['US', 'GB', 'CA', 'AU'], [['International', 1499]]], + ]); + $this->seedShipping($electronics, [['Germany', ['DE'], [['Standard', 0]]]]); + } + + /** @param list, 2: list}> $zones */ + private function seedShipping(Store $store, array $zones): void + { + foreach ($zones as [$name, $countries, $rates]) { + $zone = ShippingZone::query()->updateOrCreate( + ['store_id' => $store->id, 'name' => $name], + ['countries' => $countries, 'is_active' => true], + ); + + foreach ($rates as [$rateName, $amount]) { + $zone->rates()->updateOrCreate(['name' => $rateName], [ + 'type' => 'flat', + 'price_amount' => $amount, + 'config_json' => ['amount' => $amount], + 'is_active' => true, + ]); + } + } + } + + private function seedCatalog(Store $fashion, Store $electronics): void + { + $collections = []; + foreach ([ + [$fashion, 'New Arrivals', 'new-arrivals', 'Discover the latest additions to our store.'], + [$fashion, 'T-Shirts', 't-shirts', 'Premium cotton tees for every occasion.'], + [$fashion, 'Pants & Jeans', 'pants-jeans', 'Find the perfect fit from our denim and trouser range.'], + [$fashion, 'Sale', 'sale', 'Great deals on selected items.'], + [$electronics, 'Featured', 'featured', 'Professional technology we recommend.'], + [$electronics, 'Accessories', 'accessories', 'Accessories for your everyday setup.'], + ] as [$store, $title, $handle, $description]) { + $collections[$store->handle.':'.$handle] = \App\Models\Collection::query()->updateOrCreate( + ['store_id' => $store->id, 'handle' => $handle], + ['title' => $title, 'description_html' => '

'.$description.'

', 'type' => 'manual', 'status' => 'active'], + ); + } + + $fashionRows = $this->fashionProducts(); + $fashionAssignments = [ + 'new-arrivals' => ['classic-cotton-t-shirt', 'premium-slim-fit-jeans', 'organic-hoodie', 'running-sneakers', 'chino-shorts', 'bucket-hat', 'cashmere-overcoat'], + 't-shirts' => ['classic-cotton-t-shirt', 'graphic-print-tee', 'v-neck-linen-tee', 'striped-polo-shirt'], + 'pants-jeans' => ['premium-slim-fit-jeans', 'cargo-pants', 'chino-shorts', 'wide-leg-trousers'], + 'sale' => ['premium-slim-fit-jeans', 'striped-polo-shirt', 'wide-leg-trousers'], + ]; + + foreach ($fashionRows as $row) { + $this->products[$row['handle']] = $this->createProduct($fashion, $row); + } + + foreach ($this->electronicsProducts() as $row) { + $this->products[$row['handle']] = $this->createProduct($electronics, $row); + } + + foreach ($fashionAssignments as $handle => $productHandles) { + $collection = $collections['acme-fashion:'.$handle]; + $collection->products()->sync(collect($productHandles)->mapWithKeys(fn (string $productHandle, int $position): array => [$this->products[$productHandle]->id => ['position' => $position]])->all()); + } + + foreach ([ + ['featured', ['pro-laptop-15', 'wireless-headphones', 'mechanical-keyboard']], + ['accessories', ['usb-c-cable-2m', 'monitor-stand']], + ] as [$handle, $productHandles]) { + $collections['acme-electronics:'.$handle]->products()->sync(collect($productHandles)->mapWithKeys(fn (string $productHandle, int $position): array => [$this->products[$productHandle]->id => ['position' => $position]])->all()); + } + } + + /** @param array $row */ + private function createProduct(Store $store, array $row): Product + { + $optionSets = $row['options']; + $combinations = $this->combinations(array_column($optionSets, 'values')); + $variants = []; + + foreach ($combinations as $position => $combination) { + $skuParts = array_map(static fn (string $value): string => strtoupper(Str::slug($value, '-')), $combination); + $sku = $row['sku_prefix'] ?? strtoupper(Str::slug($row['handle'], '-')); + if ($skuParts !== []) { + $sku .= '-'.implode('-', $skuParts); + } + if ($row['handle'] === 'classic-cotton-t-shirt') { + $sku = 'ACME-CTSH-'.strtoupper($combination[0]).'-'.match ($combination[1]) { + 'White' => 'WHT', 'Black' => 'BLK', 'Navy' => 'NVY', default => strtoupper($combination[1]), + }; + } + + $variants[] = [ + 'sku' => $sku, + 'price_amount' => $row['prices'][$position] ?? $row['price'], + 'compare_at_amount' => $row['compare'] ?? null, + 'currency' => 'EUR', + 'weight_g' => $row['weight'], + 'requires_shipping' => $row['requires_shipping'] ?? true, + 'is_default' => $position === 0, + 'quantity_on_hand' => $row['inventory'], + 'inventory_policy' => $row['policy'] ?? 'deny', + ]; + } + + $product = app(ProductService::class)->create($store, [ + 'title' => $row['title'], + 'handle' => $row['handle'], + 'status' => $row['status'] ?? 'active', + 'description_html' => '

'.$row['description'].'

', + 'vendor' => $row['vendor'], + 'product_type' => $row['type'], + 'tags' => $row['tags'], + 'options' => $optionSets, + 'variants' => $variants, + ]); + + $optionValueIds = $product->options->map(static fn ($option) => $option->values->pluck('id')->all())->all(); + foreach ($product->variants as $position => $variant) { + $variant->optionValues()->sync($this->combinations($optionValueIds)[$position] ?? []); + } + + if (($row['status'] ?? 'active') === 'archived') { + $product->forceFill(['published_at' => now()->subMonths(6)])->save(); + } + + return $product->refresh()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'media']); + } + + /** @param list> $sets + * @return list> + */ + private function combinations(array $sets): array + { + $combinations = [[]]; + foreach ($sets as $set) { + $next = []; + foreach ($combinations as $prefix) { + foreach ($set as $value) { + $next[] = [...$prefix, $value]; + } + } + $combinations = $next; + } + + return $combinations; + } + + /** @return list> */ + private function fashionProducts(): array + { + return [ + ['title' => 'Classic Cotton T-Shirt', 'handle' => 'classic-cotton-t-shirt', 'vendor' => 'Acme Basics', 'type' => 'T-Shirts', 'tags' => ['new', 'popular'], 'description' => 'A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], ['name' => 'Color', 'values' => ['White', 'Black', 'Navy']]], 'price' => 2499, 'weight' => 200, 'inventory' => 15], + ['title' => 'Premium Slim Fit Jeans', 'handle' => 'premium-slim-fit-jeans', 'vendor' => 'Acme Denim', 'type' => 'Pants', 'tags' => ['new', 'sale'], 'description' => 'Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.', 'options' => [['name' => 'Size', 'values' => ['28', '30', '32', '34', '36']], ['name' => 'Color', 'values' => ['Blue', 'Black']]], 'price' => 7999, 'compare' => 9999, 'weight' => 800, 'inventory' => 8], + ['title' => 'Organic Hoodie', 'handle' => 'organic-hoodie', 'vendor' => 'Acme Basics', 'type' => 'Hoodies', 'tags' => ['new', 'trending'], 'description' => 'Made from 100% organic cotton. Warm, soft, and sustainably produced.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], 'price' => 5999, 'weight' => 500, 'inventory' => 20], + ['title' => 'Leather Belt', 'handle' => 'leather-belt', 'vendor' => 'Acme Accessories', 'type' => 'Accessories', 'tags' => ['popular'], 'description' => 'Genuine leather belt with brushed metal buckle. A wardrobe essential.', 'options' => [['name' => 'Size', 'values' => ['S/M', 'L/XL']], ['name' => 'Color', 'values' => ['Brown', 'Black']]], 'price' => 3499, 'weight' => 150, 'inventory' => 25], + ['title' => 'Running Sneakers', 'handle' => 'running-sneakers', 'vendor' => 'Acme Sport', 'type' => 'Shoes', 'tags' => ['trending'], 'description' => 'Lightweight running sneakers with responsive cushioning and breathable mesh upper.', 'options' => [['name' => 'Size', 'values' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], ['name' => 'Color', 'values' => ['White', 'Black']]], 'price' => 11999, 'weight' => 600, 'inventory' => 5], + ['title' => 'Graphic Print Tee', 'handle' => 'graphic-print-tee', 'vendor' => 'Acme Basics', 'type' => 'T-Shirts', 'tags' => ['new'], 'description' => 'Bold graphic print on soft cotton. Express yourself with this statement piece.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], 'price' => 2999, 'weight' => 210, 'inventory' => 18], + ['title' => 'V-Neck Linen Tee', 'handle' => 'v-neck-linen-tee', 'vendor' => 'Acme Basics', 'type' => 'T-Shirts', 'tags' => ['popular'], 'description' => 'Lightweight linen blend v-neck. Perfect for warm summer days.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L']], ['name' => 'Color', 'values' => ['Beige', 'Olive', 'Sky Blue']]], 'price' => 3499, 'weight' => 180, 'inventory' => 12], + ['title' => 'Striped Polo Shirt', 'handle' => 'striped-polo-shirt', 'vendor' => 'Acme Basics', 'type' => 'T-Shirts', 'tags' => ['sale'], 'description' => 'Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], 'price' => 2799, 'compare' => 3999, 'weight' => 250, 'inventory' => 10], + ['title' => 'Cargo Pants', 'handle' => 'cargo-pants', 'vendor' => 'Acme Workwear', 'type' => 'Pants', 'tags' => ['popular'], 'description' => 'Utility cargo pants with multiple pockets. Durable cotton twill construction.', 'options' => [['name' => 'Size', 'values' => ['30', '32', '34', '36']], ['name' => 'Color', 'values' => ['Khaki', 'Olive', 'Black']]], 'price' => 5499, 'weight' => 700, 'inventory' => 14], + ['title' => 'Chino Shorts', 'handle' => 'chino-shorts', 'vendor' => 'Acme Basics', 'type' => 'Pants', 'tags' => ['new', 'trending'], 'description' => 'Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.', 'options' => [['name' => 'Size', 'values' => ['30', '32', '34', '36']], ['name' => 'Color', 'values' => ['Navy', 'Sand']]], 'price' => 3999, 'weight' => 350, 'inventory' => 16], + ['title' => 'Wide Leg Trousers', 'handle' => 'wide-leg-trousers', 'vendor' => 'Acme Denim', 'type' => 'Pants', 'tags' => ['sale'], 'description' => 'Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L']]], 'price' => 4999, 'compare' => 6999, 'weight' => 550, 'inventory' => 7], + ['title' => 'Wool Scarf', 'handle' => 'wool-scarf', 'vendor' => 'Acme Accessories', 'type' => 'Accessories', 'tags' => ['popular'], 'description' => 'Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.', 'options' => [['name' => 'Color', 'values' => ['Grey', 'Burgundy', 'Navy']]], 'price' => 2999, 'weight' => 120, 'inventory' => 30], + ['title' => 'Canvas Tote Bag', 'handle' => 'canvas-tote-bag', 'vendor' => 'Acme Accessories', 'type' => 'Accessories', 'tags' => ['trending'], 'description' => 'Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.', 'options' => [['name' => 'Color', 'values' => ['Natural', 'Black']]], 'price' => 1999, 'weight' => 300, 'inventory' => 40], + ['title' => 'Bucket Hat', 'handle' => 'bucket-hat', 'vendor' => 'Acme Accessories', 'type' => 'Accessories', 'tags' => ['new', 'trending'], 'description' => 'Lightweight bucket hat for sun protection. Packable design, washed cotton twill.', 'options' => [['name' => 'Size', 'values' => ['S/M', 'L/XL']], ['name' => 'Color', 'values' => ['Beige', 'Black', 'Olive']]], 'price' => 2499, 'weight' => 80, 'inventory' => 22], + ['title' => 'Unreleased Winter Jacket', 'handle' => 'unreleased-winter-jacket', 'vendor' => 'Acme Outerwear', 'type' => 'Jackets', 'tags' => ['limited'], 'description' => 'Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], 'price' => 14999, 'weight' => 900, 'inventory' => 0, 'status' => 'draft'], + ['title' => 'Discontinued Raincoat', 'handle' => 'discontinued-raincoat', 'vendor' => 'Acme Outerwear', 'type' => 'Jackets', 'tags' => [], 'description' => 'Lightweight waterproof raincoat. This product has been discontinued.', 'options' => [['name' => 'Size', 'values' => ['M', 'L']]], 'price' => 8999, 'weight' => 400, 'inventory' => 3, 'status' => 'archived'], + ['title' => 'Limited Edition Sneakers', 'handle' => 'limited-edition-sneakers', 'vendor' => 'Acme Sport', 'type' => 'Shoes', 'tags' => ['limited'], 'description' => 'Limited edition collaboration sneakers. Once they are gone, they are gone.', 'options' => [['name' => 'Size', 'values' => ['EU 40', 'EU 42', 'EU 44']]], 'price' => 15999, 'weight' => 650, 'inventory' => 0], + ['title' => 'Backorder Denim Jacket', 'handle' => 'backorder-denim-jacket', 'vendor' => 'Acme Denim', 'type' => 'Jackets', 'tags' => ['popular'], 'description' => 'Classic denim jacket. Currently on backorder - ships within 2-3 weeks.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], 'price' => 9999, 'weight' => 750, 'inventory' => 0, 'policy' => 'continue'], + ['title' => 'Gift Card', 'handle' => 'gift-card', 'vendor' => 'Acme Fashion', 'type' => 'Gift Cards', 'tags' => ['popular'], 'description' => 'Digital gift card delivered via email. The perfect gift when you are not sure what to choose.', 'options' => [['name' => 'Amount', 'values' => ['25 EUR', '50 EUR', '100 EUR']]], 'prices' => [2500, 5000, 10000], 'weight' => 0, 'inventory' => 9999, 'requires_shipping' => false], + ['title' => 'Cashmere Overcoat', 'handle' => 'cashmere-overcoat', 'vendor' => 'Acme Premium', 'type' => 'Jackets', 'tags' => ['limited', 'new'], 'description' => 'Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.', 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L']], ['name' => 'Color', 'values' => ['Camel', 'Charcoal']]], 'price' => 49999, 'weight' => 1200, 'inventory' => 3], + ]; + } + + /** @return list> */ + private function electronicsProducts(): array + { + return [ + ['title' => 'Pro Laptop 15', 'handle' => 'pro-laptop-15', 'vendor' => 'TechCorp', 'type' => 'Laptops', 'tags' => ['professional', 'featured'], 'description' => 'A powerful professional laptop with a vivid 15-inch display.', 'options' => [['name' => 'Storage', 'values' => ['256GB', '512GB', '1TB']]], 'prices' => [99999, 119999, 149999], 'weight' => 1800, 'inventory' => 10], + ['title' => 'Wireless Headphones', 'handle' => 'wireless-headphones', 'vendor' => 'AudioMax', 'type' => 'Audio', 'tags' => ['featured'], 'description' => 'Comfortable wireless headphones with clear, balanced sound.', 'options' => [['name' => 'Color', 'values' => ['Black', 'Silver']]], 'price' => 14999, 'weight' => 250, 'inventory' => 25], + ['title' => 'USB-C Cable 2m', 'handle' => 'usb-c-cable-2m', 'vendor' => 'CablePro', 'type' => 'Cables', 'tags' => ['accessory'], 'description' => 'A durable two metre USB-C cable for charging and data.', 'options' => [], 'price' => 1299, 'weight' => 50, 'inventory' => 200], + ['title' => 'Mechanical Keyboard', 'handle' => 'mechanical-keyboard', 'vendor' => 'KeyTech', 'type' => 'Peripherals', 'tags' => ['featured'], 'description' => 'A sturdy mechanical keyboard available with three switch styles.', 'options' => [['name' => 'Switch Type', 'values' => ['Red', 'Blue', 'Brown']]], 'price' => 12999, 'weight' => 1100, 'inventory' => 15], + ['title' => 'Monitor Stand', 'handle' => 'monitor-stand', 'vendor' => 'DeskGear', 'type' => 'Accessories', 'tags' => ['accessory'], 'description' => 'A stable monitor stand that frees up desk space.', 'options' => [], 'price' => 4999, 'weight' => 2500, 'inventory' => 30], + ]; + } + + private function seedDiscounts(Store $fashion): void + { + foreach ([ + ['WELCOME10', 'Welcome 10% off', 'percentage', 10, 2000, null, 3, now()->subYear(), now()->addYear(), ['min_purchase_amount' => 2000]], + ['FLAT5', 'Five euros off', 'fixed_amount', 500, null, null, 0, now()->subYear(), now()->addYear(), []], + ['FREESHIP', 'Free shipping', 'free_shipping', 0, null, null, 1, now()->subYear(), now()->addYear(), []], + ['EXPIRED20', 'Expired 20% off', 'percentage', 20, null, null, 0, now()->subYears(2), now()->subYears(1), []], + ['MAXED', 'Maximum uses reached', 'percentage', 10, null, 5, 5, now()->subYear(), now()->addYear(), []], + ] as [$code, $title, $type, $value, $minimum, $limit, $count, $starts, $ends, $rules]) { + Discount::query()->updateOrCreate(['store_id' => $fashion->id, 'code' => $code], [ + 'title' => $title, + 'type' => $type, + 'value' => $value, + 'minimum_subtotal_amount' => $minimum, + 'usage_limit' => $limit, + 'usage_count' => $count, + 'starts_at' => $starts, + 'ends_at' => $ends, + 'is_active' => true, + 'rules_json' => $rules, + ]); + } + } + + private function seedCustomers(Store $fashion, Store $electronics): void + { + $fashionRows = [ + ['customer@acme.test', 'John Doe', true], ['jane@example.com', 'Jane Smith', false], + ['michael@example.com', 'Michael Brown', true], ['sarah@example.com', 'Sarah Wilson', false], + ['david@example.com', 'David Lee', true], ['emma@example.com', 'Emma Garcia', false], + ['james@example.com', 'James Taylor', false], ['lisa@example.com', 'Lisa Anderson', true], + ['robert@example.com', 'Robert Martinez', false], ['anna@example.com', 'Anna Thomas', true], + ]; + foreach ($fashionRows as $index => [$email, $name, $optIn]) { + $this->customers[$email] = $this->createCustomer($fashion, $email, $name, $optIn); + $addresses = match ($index) { + 0 => [ + ['Home', true, 'John', 'Doe', 'Hauptstrasse 1', '', 'Berlin', '', '10115', 'DE', '+49 30 12345678'], + ['Work', false, 'John', 'Doe', 'Friedrichstrasse 100', '3rd Floor', 'Berlin', '', '10117', 'DE', '+49 30 87654321'], + ], + 1 => [['Home', true, 'Jane', 'Smith', 'Schillerstrasse 45', '', 'Munich', 'Bavaria', '80336', 'DE', '']], + default => [['Home', true, Str::before($name, ' '), Str::after($name, ' '), 'Hauptstrasse '.(10 + $index), '', ['Berlin', 'Hamburg', 'Munich', 'Cologne'][$index % 4], '', '10115', 'DE', '+49 30 5550000']], + }; + $this->createAddresses($this->customers[$email], $addresses); + } + + foreach ([['techfan@example.com', 'Tech Fan'], ['gadgetlover@example.com', 'Gadget Lover']] as [$email, $name]) { + $this->customers[$email] = $this->createCustomer($electronics, $email, $name, false); + $this->createAddresses($this->customers[$email], [['Home', true, Str::before($name, ' '), Str::after($name, ' '), 'Hauptstrasse 20', '', 'Berlin', '', '10115', 'DE', '']]); + } + } + + private function createCustomer(Store $store, string $email, string $name, bool $marketingOptIn): Customer + { + return Customer::query()->updateOrCreate(['store_id' => $store->id, 'email' => $email], [ + 'name' => $name, + 'password' => Hash::make('password'), + 'marketing_opt_in' => $marketingOptIn, + ]); + } + + /** @param list $addresses */ + private function createAddresses(Customer $customer, array $addresses): void + { + $customer->addresses()->delete(); + foreach ($addresses as [$label, $default, $first, $last, $line1, $line2, $city, $state, $zip, $country, $phone]) { + CustomerAddress::query()->create([ + 'customer_id' => $customer->id, + 'label' => $label, + 'is_default' => $default, + 'address_json' => [ + 'first_name' => $first, 'last_name' => $last, + 'address_line_1' => $line1, 'address_line_2' => $line2, + 'address1' => $line1, 'address2' => $line2, + 'city' => $city, 'state' => $state, 'province' => $state, + 'postal_code' => $zip, 'zip' => $zip, 'country' => $country, 'country_code' => $country, 'phone' => $phone, + ], + ]); + } + } + + private function seedContent(Store $fashion, Store $electronics): void + { + $fashionPages = [ + ['About Us', 'about', '

Our Story

We are a Berlin-based team creating modern essentials for everyday life. We believe the best pieces are useful, thoughtfully made, and designed to last.

Our Values

We work with responsible suppliers, choose materials with care, and value ethical sourcing, sustainability, and fair labor.

Our Team

Our designers and makers bring a practical, curious point of view to every collection.

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

Frequently Asked Questions

When will my order arrive?

Orders in Germany usually arrive in 2–4 days with standard shipping and 1–2 days with express shipping. EU orders typically arrive in 5–7 days.

What is your return policy?

You may return unworn items in their original packaging within 30 days.

Where do you ship?

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

How do I track my order?

We email a tracking number as soon as your order ships.

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

Shipping

Germany

  • Standard shipping: €4.99
  • Express shipping: €9.99

European Union

  • EU standard shipping: €8.99

International

  • International shipping: €14.99

Returns

Return unworn items in their original packaging within 30 days. Return shipping is paid by the customer unless an item is defective.

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

Information We Collect

We collect the information needed to fulfill orders and provide customer support.

How We Use Your Information

We use order details to process purchases, communicate about delivery, and improve our service.

Cookies

Our site uses essential cookies to keep your cart and account working.

Contact

For privacy questions, contact privacy@acme-fashion.test.

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

Orders and Payments

Prices are shown in EUR and include applicable tax. Orders are subject to availability and payment confirmation.

Product Descriptions

We work to show products accurately; screen settings may create slight color differences.

Limitation of Liability

Nothing in these terms limits rights that cannot be limited under applicable law.

Governing Law

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

'], + ]; + foreach ($fashionPages as [$title, $handle, $html]) { + Page::query()->updateOrCreate(['store_id' => $fashion->id, 'handle' => $handle], ['title' => $title, 'body_html' => $html, 'status' => 'published', 'published_at' => now()->subMonths(3)]); + } + + $this->seedNavigation($fashion, $electronics); + $this->seedTheme($fashion, $electronics); + DB::table('search_settings')->updateOrInsert(['store_id' => $fashion->id], [ + 'synonyms_json' => json_encode([['tee', 't-shirt', 'tshirt'], ['pants', 'trousers', 'jeans'], ['sneakers', 'trainers', 'shoes'], ['hoodie', 'sweatshirt']], JSON_THROW_ON_ERROR), + 'stop_words_json' => json_encode(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is'], JSON_THROW_ON_ERROR), + 'updated_at' => now(), + ]); + DB::table('search_settings')->updateOrInsert(['store_id' => $electronics->id], [ + 'synonyms_json' => json_encode([['laptop', 'notebook', 'computer'], ['headphones', 'earphones', 'earbuds'], ['cable', 'cord', 'wire']], JSON_THROW_ON_ERROR), + 'stop_words_json' => json_encode(['the', 'a', 'an', 'and', 'or'], JSON_THROW_ON_ERROR), + 'updated_at' => now(), + ]); + } + + private function seedNavigation(Store $fashion, Store $electronics): void + { + $menus = [ + [$fashion, 'main-menu', 'Main Menu', [['Home', 'link', '/', null], ['New Arrivals', 'collection', null, 'new-arrivals'], ['T-Shirts', 'collection', null, 't-shirts'], ['Pants & Jeans', 'collection', null, 'pants-jeans'], ['Sale', 'collection', null, 'sale']]], + [$fashion, 'footer-menu', 'Footer Menu', [['About Us', 'page', null, 'about'], ['FAQ', 'page', null, 'faq'], ['Shipping & Returns', 'page', null, 'shipping-returns'], ['Privacy Policy', 'page', null, 'privacy-policy'], ['Terms of Service', 'page', null, 'terms']]], + [$electronics, 'main-menu', 'Main Menu', [['Home', 'link', '/', null], ['Featured', 'collection', null, 'featured'], ['Accessories', 'collection', null, 'accessories']]], + ]; + + foreach ($menus as [$store, $handle, $title, $items]) { + $menu = \App\Models\NavigationMenu::query()->updateOrCreate(['store_id' => $store->id, 'handle' => $handle], ['title' => $title]); + $menu->items()->delete(); + foreach ($items as $position => [$label, $type, $url, $resourceHandle]) { + $resourceId = null; + if ($resourceHandle !== null) { + $resourceId = $type === 'collection' + ? \App\Models\Collection::query()->where('store_id', $store->id)->where('handle', $resourceHandle)->value('id') + : Page::query()->where('store_id', $store->id)->where('handle', $resourceHandle)->value('id'); + $url = $type === 'collection' ? '/collections/'.$resourceHandle : '/pages/'.$resourceHandle; + } + $menu->items()->create(['label' => $label, 'type' => $type, 'url' => $url, 'resource_id' => $resourceId, 'position' => $position]); + } + } + } + + private function seedTheme(Store $fashion, Store $electronics): void + { + foreach ([ + [$fashion, '#1a1a2e', '#e94560', 'Welcome to Acme Fashion', 'Discover our curated collection of modern essentials', '/collections/new-arrivals', ['new-arrivals', 't-shirts', 'sale']], + [$electronics, '#0f172a', '#3b82f6', 'Acme Electronics', 'Premium tech for professionals', '/collections/featured', ['featured']], + ] as [$store, $primary, $secondary, $heading, $subheading, $ctaUrl, $featuredHandles]) { + $theme = Theme::query()->updateOrCreate(['store_id' => $store->id, 'name' => 'Default Theme'], ['status' => 'published', 'is_active' => true]); + $mainMenu = \App\Models\NavigationMenu::query()->where('store_id', $store->id)->where('handle', 'main-menu')->first(); + $footerItems = \App\Models\NavigationMenu::query()->where('store_id', $store->id)->where('handle', 'footer-menu')->first()?->items ?? collect(); + $footerMenu = $footerItems->chunk(3)->map(fn ($items, int $index): array => [ + 'label' => $index === 0 ? 'Explore' : 'More', + 'children' => $items->map(fn ($item): array => ['label' => $item->label, 'url' => $item->url])->all(), + ])->all(); + $mainLinks = $mainMenu?->items->map(fn ($item): array => ['label' => $item->label, 'url' => $item->url])->all() ?? []; + $theme->settings()->updateOrCreate(['theme_id' => $theme->id], ['settings_json' => [ + 'colors' => ['primary' => $primary, 'secondary' => $secondary, 'accent' => $secondary], + 'font_family' => 'Inter, sans-serif', + 'home' => ['hero' => ['enabled' => true, 'heading' => $heading, 'subheading' => $subheading, 'cta_text' => $store->id === $fashion->id ? 'Shop New Arrivals' : 'Shop Featured', 'cta_url' => $ctaUrl], 'featured_collections' => ['enabled' => true, 'count' => 4], 'featured_products' => ['enabled' => true, 'count' => 8], 'newsletter' => ['enabled' => true]], + 'featured_collection_handles' => $featuredHandles, + 'navigation' => ['main-menu' => $mainLinks, 'footer-menu' => $footerMenu], + 'footer_text' => now()->year.' '.$store->name.'. All rights reserved.', + 'announcement' => ['enabled' => $store->id === $fashion->id, 'text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', 'url' => null], + 'sticky_header' => true, + 'dark_mode' => 'system', + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + 'home_sections' => ['hero', 'featured_collections', 'featured_products', 'newsletter'], + 'seo' => ['home_description' => $subheading], + 'payment_methods' => ['Visa', 'Mastercard', 'PayPal'], + ]]); + } + } + + private function seedOrders(Store $fashion, Store $electronics): void + { + $orders = [ + [1001, 'customer@acme.test', 'paid', 'paid', 'unfulfilled', 'credit_card', 4998, 0, 499, 798, 5497, 2, [['classic-cotton-t-shirt', 'S / White', 2]], null], + [1002, 'customer@acme.test', 'fulfilled', 'paid', 'fulfilled', 'credit_card', 8498, 0, 499, 1357, 8997, 10, [['organic-hoodie', 'M', 1], ['classic-cotton-t-shirt', 'L / Black', 1]], ['DHL', 'DHL1234567890', 8, 'delivered', null]], + [1003, 'jane@example.com', 'paid', 'paid', 'partial', 'credit_card', 11498, 0, 499, 1836, 11997, 5, [['premium-slim-fit-jeans', '32 / Blue', 1], ['leather-belt', 'L/XL / Brown', 1]], ['DHL', 'DHL9876543210', 3, 'shipped', [0]]], + [1004, 'customer@acme.test', 'cancelled', 'refunded', 'unfulfilled', 'credit_card', 2499, 0, 499, 399, 2998, 15, [['classic-cotton-t-shirt', 'M / Navy', 1]], null], + [1005, 'jane@example.com', 'pending', 'pending', 'unfulfilled', 'bank_transfer', 3499, 0, 499, 559, 3998, 0, [['leather-belt', 'S/M / Black', 1]], null], + [1006, 'michael@example.com', 'paid', 'paid', 'unfulfilled', 'credit_card', 11999, 0, 499, 1916, 12498, 1, [['running-sneakers', 'EU 42 / Black', 1]], null], + [1007, 'sarah@example.com', 'fulfilled', 'paid', 'fulfilled', 'paypal', 9997, 0, 499, 1596, 10496, 20, [['v-neck-linen-tee', 'M / Beige', 2], ['wool-scarf', 'Grey', 1]], ['DHL', 'DHL1112223334', 18, 'delivered', null]], + [1008, 'david@example.com', 'paid', 'partially_refunded', 'fulfilled', 'credit_card', 8498, 0, 499, 1357, 8997, 12, [['cargo-pants', '32 / Khaki', 1], ['graphic-print-tee', 'L', 1]], ['UPS', 'UPS5556667778', 10, 'delivered', null]], + [1009, 'emma@example.com', 'paid', 'paid', 'unfulfilled', 'credit_card', 4498, 0, 499, 718, 4997, 3, [['canvas-tote-bag', 'Natural', 1], ['bucket-hat', 'S/M / Black', 1]], null], + [1010, 'customer@acme.test', 'paid', 'paid', 'unfulfilled', 'paypal', 49999, 0, 499, 7983, 50498, 1, [['cashmere-overcoat', 'M / Camel', 1]], null], + [1011, 'james@example.com', 'fulfilled', 'paid', 'fulfilled', 'credit_card', 2799, 0, 499, 447, 3298, 25, [['striped-polo-shirt', 'XL', 1]], ['FedEx', 'FX9998887776', 23, 'delivered', null]], + [1012, 'lisa@example.com', 'paid', 'paid', 'unfulfilled', 'credit_card', 7998, 0, 499, 1277, 8497, 4, [['chino-shorts', '34 / Navy', 2]], null], + [1013, 'robert@example.com', 'paid', 'paid', 'unfulfilled', 'credit_card', 7998, 0, 499, 1277, 8497, 1, [['wide-leg-trousers', 'M', 1], ['wool-scarf', 'Burgundy', 1]], null], + [1014, 'anna@example.com', 'fulfilled', 'paid', 'fulfilled', 'credit_card', 5000, 0, 0, 798, 5000, 14, [['gift-card', '50 EUR', 1]], ['', '', 14, 'delivered', null]], + [1015, 'customer@acme.test', 'paid', 'paid', 'unfulfilled', 'bank_transfer', 5498, 550, 499, 790, 5447, 0, [['classic-cotton-t-shirt', 'M / White', 1, 250], ['graphic-print-tee', 'M', 1, 300]], null], + ]; + + foreach ($orders as $orderData) { + $this->createOrder($fashion, $orderData); + } + + $this->createOrder($electronics, [5001, 'techfan@example.com', 'fulfilled', 'paid', 'fulfilled', 'credit_card', 121298, 0, 0, 0, 121298, 3, [['pro-laptop-15', '512GB', 1], ['usb-c-cable-2m', 'Default Title', 1]], ['DHL', 'ACME5001', 2, 'delivered', null]]); + $this->createOrder($electronics, [5002, 'gadgetlover@example.com', 'paid', 'paid', 'unfulfilled', 'credit_card', 14999, 0, 0, 0, 14999, 2, [['wireless-headphones', 'Black', 1]], null]); + $this->createOrder($electronics, [5003, 'techfan@example.com', 'pending', 'pending', 'unfulfilled', 'bank_transfer', 4999, 0, 0, 0, 4999, 1, [['monitor-stand', 'Default Title', 1]], null]); + } + + /** @param array $data */ + private function createOrder(Store $store, array $data): Order + { + [$number, $email, $status, $financial, $fulfillmentStatus, $method, $subtotal, $discount, $shipping, $tax, $total, $daysAgo, $lineData, $fulfillmentData] = $data; + $customer = $this->customers[$email]; + $address = $customer->addresses()->where('is_default', true)->first()?->address_json; + $placedAt = now()->subDays($daysAgo); + $orderNumber = '#'.str_pad((string) $number, 4, '0', STR_PAD_LEFT); + $order = Order::query()->updateOrCreate(['store_id' => $store->id, 'order_number' => $orderNumber], [ + 'customer_id' => $customer->id, + 'payment_method' => $method, + 'status' => $status, + 'financial_status' => $financial, + 'fulfillment_status' => $fulfillmentStatus, + 'currency' => 'EUR', + 'subtotal_amount' => $subtotal, + 'discount_amount' => $discount, + 'shipping_amount' => $shipping, + 'tax_amount' => $tax, + 'total_amount' => $total, + 'email' => $email, + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => $placedAt, + ]); + $order->refunds()->delete(); + $order->fulfillments()->delete(); + $order->payments()->delete(); + $order->lines()->delete(); + $lineModels = []; + + foreach ($lineData as $lineEntry) { + [$handle, $variantTitle, $quantity, $lineDiscount] = array_pad($lineEntry, 4, 0); + $product = $this->products[$handle]; + $variant = $product->variants->first(static fn (ProductVariant $candidate): bool => $candidate->title === $variantTitle) + ?? throw new \RuntimeException("Missing seeded variant {$handle}: {$variantTitle}."); + $lineTotal = ($variant->price_amount * $quantity) - $lineDiscount; + $lineModels[] = $order->lines()->create([ + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'variant_title_snapshot' => $variant->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $quantity, + 'unit_price_amount' => $variant->price_amount, + 'total_amount' => $lineTotal, + 'tax_lines_json' => [], + 'discount_allocations_json' => $lineDiscount > 0 ? [['code' => 'WELCOME10', 'amount' => $lineDiscount]] : [], + ]); + } + + $payment = $order->payments()->create([ + 'provider' => 'mock', + 'method' => $method, + 'provider_payment_id' => 'mock_test_order'.$number, + 'status' => $financial === 'pending' ? 'pending' : ($financial === 'refunded' ? 'refunded' : ($financial === 'partially_refunded' ? 'partially_refunded' : 'captured')), + 'amount' => $total, + 'currency' => 'EUR', + 'raw_json_encrypted' => Crypt::encryptString(json_encode(['status' => $financial === 'pending' ? 'pending' : 'captured'], JSON_THROW_ON_ERROR)), + 'created_at' => $placedAt, + ]); + + if ($fulfillmentData !== null) { + [$company, $tracking, $shippedDaysAgo, $fulfillmentState, $linePositions] = $fulfillmentData; + $shippedAt = now()->subDays($shippedDaysAgo); + $fulfillment = $order->fulfillments()->create([ + 'status' => $fulfillmentState, + 'tracking_company' => $company ?: null, + 'tracking_number' => $tracking ?: null, + 'shipped_at' => $shippedAt, + 'delivered_at' => $fulfillmentState === 'delivered' ? $shippedAt : null, + 'created_at' => $shippedAt, + ]); + $linePositions ??= array_keys($lineModels); + foreach ($linePositions as $position) { + $fulfillment->lines()->create(['order_line_id' => $lineModels[$position]->id, 'quantity' => $lineModels[$position]->quantity]); + } + } + + if ((int) $number === 1004 || (int) $number === 1008) { + $refundAmount = (int) $number === 1004 ? $total : 2999; + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $refundAmount, + 'reason' => (int) $number === 1004 ? 'Customer requested cancellation' : 'Item returned', + 'status' => 'processed', + 'provider_refund_id' => 'mock_re_test_order'.$number, + 'created_at' => $placedAt, + ]); + $refund->lines()->create([ + 'order_line_id' => (int) $number === 1004 ? $lineModels[0]->id : $lineModels[1]->id, + 'quantity' => 1, + 'amount' => (int) $number === 1004 ? $lineModels[0]->total_amount : 2999, + ]); + } + + $order->forceFill(['created_at' => $placedAt, 'updated_at' => $placedAt])->save(); + + return $order->refresh(); + } + + private function seedAnalytics(Store $fashion, Store $electronics): void + { + foreach ([$fashion, $electronics] as $store) { + for ($daysAgo = 30; $daysAgo >= 0; $daysAgo--) { + $growth = 1 + (30 - $daysAgo) * 0.03; + $visits = (int) round((50 + (($daysAgo * 17) % 51)) * $growth); + $addToCart = (int) round($visits * (18 + (($daysAgo * 3) % 8)) / 100); + $checkouts = (int) round($addToCart * (40 + (($daysAgo * 5) % 16)) / 100); + $orders = max(2, (int) round($checkouts * (35 + (($daysAgo * 7) % 21)) / 100)); + $aov = 4000 + (($daysAgo * 379) % 5000); + DB::table('analytics_daily')->updateOrInsert(['store_id' => $store->id, 'date' => now()->subDays($daysAgo)->toDateString()], [ + 'orders_count' => $orders, + 'revenue_amount' => $orders * $aov, + 'aov_amount' => $aov, + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkouts, + ]); + } + } + + $eventTypes = ['page_view', 'page_view', 'product_view', 'product_view', 'add_to_cart', 'checkout_started', 'checkout_completed', 'search']; + $handles = array_values(array_filter(array_keys($this->products), static fn (string $handle): bool => ! in_array($handle, ['pro-laptop-15', 'wireless-headphones', 'usb-c-cable-2m', 'mechanical-keyboard', 'monitor-stand'], true))); + for ($index = 0; $index < 220; $index++) { + $type = $eventTypes[$index % count($eventTypes)]; + $product = $this->products[$handles[$index % count($handles)]]; + $sessionId = 'demo-session-'.str_pad((string) ($index % 35), 3, '0', STR_PAD_LEFT); + $payload = match ($type) { + 'page_view' => ['url' => $index % 2 === 0 ? '/' : '/collections/new-arrivals', 'referrer' => 'https://www.google.com'], + 'product_view' => ['product_id' => $product->id, 'product_title' => $product->title, 'url' => '/products/'.$product->handle], + 'add_to_cart' => ['product_id' => $product->id, 'variant_id' => $product->variants->first()->id, 'quantity' => 1 + ($index % 3), 'price_amount' => $product->variants->first()->price_amount], + 'checkout_started' => ['cart_id' => null, 'item_count' => 1 + ($index % 4), 'cart_total' => 5000 + (($index * 137) % 15000)], + 'checkout_completed' => ['order_id' => null, 'order_number' => '#'.(1001 + ($index % 15)), 'total_amount' => 5000 + (($index * 137) % 25000)], + default => ['query' => ['cotton t-shirt', 'jeans', 'gift card'][$index % 3], 'results_count' => 1 + ($index % 8)], + }; + AnalyticsEvent::query()->updateOrCreate( + ['store_id' => $fashion->id, 'client_event_id' => 'demo-event-'.$index], + [ + 'customer_id' => $index % 10 < 3 ? $this->customers[array_keys($this->customers)[$index % 10]]->id : null, + 'type' => $type, + 'session_id' => $sessionId, + 'payload' => $payload, + 'created_at' => now()->subDays($index % 7)->subMinutes($index * 3), + ], + ); + } + } +} diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeedc..0b2f84b1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -10,6 +10,9 @@ @theme { --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --color-store-primary: var(--store-primary, #1d4ed8); + --color-store-secondary: var(--store-secondary, #292524); + --color-store-accent: var(--store-accent, #0f766e); --color-zinc-50: #fafafa; --color-zinc-100: #f5f5f5; @@ -28,6 +31,179 @@ --color-accent-foreground: var(--color-white); } +html { + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +.storefront-skip-link { + position: fixed; + z-index: 100; + top: 1rem; + left: 1rem; + transform: translateY(-200%); + border-radius: 0.75rem; + background: #171717; + padding: 0.75rem 1rem; + color: #fff; + font-size: 0.875rem; + font-weight: 600; + box-shadow: 0 8px 24px rgb(0 0 0 / 20%); +} + +.storefront-skip-link:focus { + transform: translateY(0); + outline: 3px solid #2563eb; + outline-offset: 3px; +} + +.dark .storefront-skip-link { + background: #fff; + color: #171717; +} + +.storefront-input { + min-height: 3rem; + width: 100%; + border: 1px solid #d6d3d1; + border-radius: 0.75rem; + background: #fff; + padding: 0.65rem 0.875rem; + color: #1c1917; + font-size: 0.875rem; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.storefront-input::placeholder { + color: #78716c; +} + +.storefront-input:focus { + border-color: #1d4ed8; + outline: 2px solid transparent; + box-shadow: 0 0 0 3px rgb(29 78 216 / 18%); +} + +.storefront-input:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.dark .storefront-input { + border-color: #44403c; + background: #0c0a09; + color: #fafaf9; +} + +.dark .storefront-input::placeholder { + color: #a8a29e; +} + +.dark .storefront-input:focus { + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgb(96 165 250 / 22%); +} + +.storefront-prose { + color: #57534e; + font-size: 1rem; + line-height: 1.8; +} + +.dark .storefront-prose { + color: #d6d3d1; +} + +.storefront-prose > :first-child { + margin-top: 0; +} + +.storefront-prose > :last-child { + margin-bottom: 0; +} + +.storefront-prose :where(h2, h3, h4) { + margin-top: 2rem; + margin-bottom: 0.75rem; + color: #1c1917; + font-weight: 600; + line-height: 1.3; +} + +.dark .storefront-prose :where(h2, h3, h4) { + color: #fafaf9; +} + +.storefront-prose h2 { + font-size: 1.5rem; +} + +.storefront-prose h3 { + font-size: 1.25rem; +} + +.storefront-prose :where(p, ul, ol, blockquote, pre, table) { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.storefront-prose :where(ul, ol) { + padding-left: 1.5rem; +} + +.storefront-prose ul { + list-style: disc; +} + +.storefront-prose ol { + list-style: decimal; +} + +.storefront-prose :where(a) { + color: #1d4ed8; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.dark .storefront-prose :where(a) { + color: #93c5fd; +} + +.storefront-prose :where(img, video) { + height: auto; + max-width: 100%; + border-radius: 1rem; +} + +.storefront-prose blockquote { + border-left: 3px solid #d6d3d1; + padding-left: 1rem; + color: #57534e; +} + +.dark .storefront-prose blockquote { + border-color: #57534e; + color: #d6d3d1; +} + +[data-storefront-theme] .bg-blue-700, +[data-storefront-theme] .dark\:bg-blue-400:is(.dark *) { + background-color: var(--store-primary, #1d4ed8); +} + +[data-storefront-theme] .hover\:bg-blue-800:hover { + background-color: color-mix(in srgb, var(--store-primary, #1d4ed8) 86%, black); +} + +[data-storefront-theme] .focus-visible\:outline-blue-700:focus-visible { + outline-color: var(--store-primary, #1d4ed8); +} + @layer theme { .dark { --color-accent: var(--color-white); diff --git a/resources/js/app.js b/resources/js/app.js index e69de29b..88ee8d2b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1 @@ +import './storefront-analytics'; diff --git a/resources/js/storefront-analytics.js b/resources/js/storefront-analytics.js new file mode 100644 index 00000000..6c6c248c --- /dev/null +++ b/resources/js/storefront-analytics.js @@ -0,0 +1,120 @@ +(() => { + const root = document.querySelector('[data-storefront-analytics]'); + + if (!root) { + return; + } + + const endpoint = root.dataset.analyticsEndpoint; + const storageKey = `storefront-analytics-session:${window.location.hostname}`; + const createId = () => { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; + }; + + let sessionId; + + try { + sessionId = window.sessionStorage.getItem(storageKey); + + if (!sessionId) { + sessionId = createId(); + window.sessionStorage.setItem(storageKey, sessionId); + } + } catch { + sessionId = createId(); + } + + const device = /ipad|tablet/i.test(navigator.userAgent) + ? 'tablet' + : /mobile|iphone|android/i.test(navigator.userAgent) + ? 'mobile' + : 'desktop'; + + const send = (type, properties = {}) => { + const event = { + type, + session_id: sessionId, + client_event_id: createId(), + occurred_at: new Date().toISOString(), + properties: { + ...properties, + channel: 'storefront', + device, + }, + }; + + fetch(endpoint, { + method: 'POST', + credentials: 'same-origin', + keepalive: true, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ events: [event] }), + }).catch(() => {}); + }; + + let lastPageUrl = window.location.href; + let livewireNavigationStarted = false; + + const trackPage = () => { + send('page_view', { + url: `${window.location.pathname}${window.location.search}`, + referrer: document.referrer, + user_agent: navigator.userAgent, + }); + + const productMatch = window.location.pathname.match(/^\/products\/([^/]+)/); + + if (productMatch) { + send('product_view', { + handle: decodeURIComponent(productMatch[1]), + url: `${window.location.pathname}${window.location.search}`, + }); + } + + const checkoutMatch = window.location.pathname.match(/^\/checkout(?:\/([^/]+))?/); + + if (checkoutMatch) { + const checkoutKey = `storefront-analytics-checkout:${sessionId}:${checkoutMatch[1] ?? 'new'}`; + + try { + if (!window.sessionStorage.getItem(checkoutKey)) { + window.sessionStorage.setItem(checkoutKey, 'tracked'); + send('checkout_started', { checkout_id: checkoutMatch[1] ?? null }); + } + } catch { + send('checkout_started', { checkout_id: checkoutMatch[1] ?? null }); + } + } + }; + + trackPage(); + + document.addEventListener('livewire:navigated', () => { + const nextUrl = window.location.href; + + if (!livewireNavigationStarted && nextUrl === lastPageUrl) { + livewireNavigationStarted = true; + + return; + } + + livewireNavigationStarted = true; + lastPageUrl = nextUrl; + trackPage(); + }); + + window.addEventListener('storefront-analytics', (event) => { + const { type, properties } = event.detail ?? {}; + + if (typeof type === 'string') { + send(type, properties ?? {}); + } + }); +})(); diff --git a/resources/views/layouts/admin-theme-editor.blade.php b/resources/views/layouts/admin-theme-editor.blade.php new file mode 100644 index 00000000..a08348b5 --- /dev/null +++ b/resources/views/layouts/admin-theme-editor.blade.php @@ -0,0 +1,19 @@ + + + + @include('partials.head', ['title' => 'Customize '.$theme->name.' · '.config('app.name')]) + + + + + {{ $slot }} + + @fluxScripts + + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 00000000..9cf53a1c --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,98 @@ +@php + $adminStore = app()->bound('current_store') ? app('current_store') : null; + $adminUser = auth()->user(); + $activeRoute = request()->route()?->getName() ?? ''; + $navigationGroups = [ + ['label' => 'Overview', 'items' => [['label' => 'Dashboard', 'href' => '/admin', 'route' => 'admin.dashboard', 'icon' => 'chart-bar']]], + ['label' => 'Catalog', 'items' => [ + ['label' => 'Products', 'href' => '/admin/products', 'route' => 'admin.products*', 'icon' => 'cube'], + ['label' => 'Collections', 'href' => '/admin/collections', 'route' => 'admin.collections*', 'icon' => 'rectangle-stack'], + ['label' => 'Inventory', 'href' => '/admin/inventory', 'route' => 'admin.inventory*', 'icon' => 'archive-box'], + ]], + ['label' => 'Sales', 'items' => [ + ['label' => 'Orders', 'href' => '/admin/orders', 'route' => 'admin.orders*', 'icon' => 'shopping-bag'], + ['label' => 'Customers', 'href' => '/admin/customers', 'route' => 'admin.customers*', 'icon' => 'users'], + ['label' => 'Discounts', 'href' => '/admin/discounts', 'route' => 'admin.discounts*', 'icon' => 'tag'], + ]], + ['label' => 'Store', 'items' => [ + ['label' => 'Pages', 'href' => '/admin/pages', 'route' => 'admin.pages*', 'icon' => 'document-text'], + ['label' => 'Navigation', 'href' => '/admin/navigation', 'route' => 'admin.navigation*', 'icon' => 'bars-3'], + ['label' => 'Apps', 'href' => '/admin/apps', 'route' => 'admin.apps*', 'icon' => 'squares-2x2'], + ['label' => 'Themes', 'href' => '/admin/themes', 'route' => 'admin.themes*', 'icon' => 'paint-brush'], + ['label' => 'Analytics', 'href' => '/admin/analytics', 'route' => 'admin.analytics*', 'icon' => 'chart-pie'], + ['label' => 'Settings', 'href' => '/admin/settings', 'route' => 'admin.settings*', 'icon' => 'cog-6-tooth'], + ['label' => 'Search settings', 'href' => '/admin/search/settings', 'route' => 'admin.search.settings', 'icon' => 'magnifying-glass'], + ['label' => 'Developers', 'href' => '/admin/developers', 'route' => 'admin.developers*', 'icon' => 'code-bracket'], + ]], + ]; +@endphp + + + + @include('partials.head', ['title' => 'Admin · '.config('app.name')]) + + + + + Skip to main content + + + + + +
+
+
+ +
+ +
+ +
+ @if (session('status')) +
{{ session('status') }}
+ @endif + {{ $slot }} +
+
+ +
+ +
+ + @fluxScripts + + diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php new file mode 100644 index 00000000..5443b76d --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,131 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Analytics', 'description' => 'Understand sales performance and how customers find your store.']) + +
+
+ + +
+ @if ($dateRange === 'custom') +
+ + + @error('customStartDate')

{{ $message }}

@enderror +
+
+ + + @error('customEndDate')

{{ $message }}

@enderror +
+ @endif +
+ + +
+
+ + +
+
+ @if ($isExporting) + Preparing your CSV export… + @endif + + @if ($exportUrl) + + Download CSV + + @endif +
+
+ @if ($exportError) + + @endif + +
+ @foreach ([['Total sales', number_format($analytics['total_sales'] / 100, 2, '.', ',').' '.$analytics['currency'], 'banknotes'], ['Orders', number_format($analytics['orders_count']), 'shopping-bag'], ['Average order value', number_format($analytics['average_order_value'] / 100, 2, '.', ',').' '.$analytics['currency'], 'receipt-percent'], ['Conversion rate', number_format($analytics['conversion_rate'], 2, '.', ',').'%', 'chart-bar']] as [$label, $value, $icon]) +
+
+

{{ $label }}

{{ $value }}

+ +
+

{{ $analytics['from'] }} to {{ $analytics['to'] }} · {{ number_format($analytics['visits_count']) }} visits

+
+ @endforeach +
+ +
+
+

Sales over time

+ @if ($analytics['orders_count'] > 0) + + @else +
+ +

No sales in this period

+

Sales will appear here after paid orders are placed.

+
+ @endif +
+ +
+

Top products

+ @if ($analytics['top_products'] !== []) +
+ + + + @foreach ($analytics['top_products'] as $rank => $product) + + @endforeach + +
RankProductUnits soldRevenueShare
{{ $rank + 1 }}{{ $product['title'] }}{{ number_format($product['units_sold']) }}{{ number_format($product['revenue_amount'] / 100, 2, '.', ',') }} {{ $analytics['currency'] }}{{ number_format($product['revenue_percent'], 1) }}%
+
+ @else +
No product sales for this period.
+ @endif +
+
+ +
+

Top referrers

+ @if ($analytics['top_referrers'] !== []) +
+ + + + @foreach ($analytics['top_referrers'] as $referrer) + + @endforeach + +
SourceSessionsOrdersConversion rate
{{ $referrer['source'] }}{{ number_format($referrer['sessions']) }}{{ number_format($referrer['orders']) }}{{ number_format($referrer['conversion_rate'], 2) }}%
+
+ @else +
Traffic source reports will appear when storefront visits are recorded.
+ @endif +
+
diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php new file mode 100644 index 00000000..a464692f --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,27 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Apps', 'breadcrumbs' => [['label' => 'Apps']]]) + + @if ($installedApps->isEmpty()) +
+ +

No apps installed

+

Apps connected to this store will appear here. The app marketplace and installation API are not available yet.

+
+ @else +
+ @foreach ($installedApps as $app) +
+ +
+

{{ $app->name }}

{{ ucfirst($app->status) }}
+

Installed {{ \Illuminate\Support\Carbon::parse($app->created_at)->diffForHumans() }}

+ @if ($app->description) +

{{ $app->description }}

+ @endif +
+ Uninstall +
+ @endforeach +
+ @endif +
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..06b5edc0 --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,64 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => $installation->name, 'breadcrumbs' => [['label' => 'Apps', 'href' => route('admin.apps')], ['label' => $installation->name]]]) + +
+
+
+
+
+

Scopes granted

+

Permissions available to this installation.

+
+ {{ ucfirst($installation->status) }} +
+ @if (count($grantedScopes)) +
    + @foreach ($grantedScopes as $scope) +
  • {{ $scope }}
  • + @endforeach +
+ @else +

No scopes are recorded for this app.

+ @endif +
+ +
+
+

Webhook subscriptions

+
+ @if ($subscriptions->isNotEmpty()) +
+ + + + + + @foreach ($subscriptions as $subscription) + + + + + + @endforeach + +
EventTarget URLStatus
{{ $subscription->event_type }}{{ $subscription->target_url }}{{ ucfirst($subscription->status) }}
+
+ @else +

This app has no webhook subscriptions.

+ @endif +
+
+ + +
+
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..65eb7911 --- /dev/null +++ b/resources/views/livewire/admin/auth/forgot-password.blade.php @@ -0,0 +1,10 @@ +
+ {{ config('app.name', 'Shop') }} Admin +
+

Reset your password

Enter your staff email address and we’ll send reset instructions if an account exists.

+ @if ($statusMessage)

{{ $statusMessage }}

@endif +
@error('email')

{{ $message }}

@enderror
+ + Back to sign in +
+
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..69dfc5e5 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,8 @@ +
+ {{ config('app.name', 'Shop') }} Admin +

Sign in to your store

Use your staff account to continue.

+ @if ($errors->any())@endif +
@error('email')

{{ $message }}

@enderror
@error('password')

{{ $message }}

@enderror
+
Forgot password?Admin access is limited to authorized store staff.
+
+
diff --git a/resources/views/livewire/admin/auth/logout.blade.php b/resources/views/livewire/admin/auth/logout.blade.php new file mode 100644 index 00000000..a3323aa4 --- /dev/null +++ b/resources/views/livewire/admin/auth/logout.blade.php @@ -0,0 +1 @@ +

Signing you out

You’ll be redirected to the admin sign-in page.

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..0c1b1ee7 --- /dev/null +++ b/resources/views/livewire/admin/auth/reset-password.blade.php @@ -0,0 +1,11 @@ +
+ {{ config('app.name', 'Shop') }} Admin +
+

Choose a new password

Use a password you don’t use elsewhere.

+
@error('email')

{{ $message }}

@enderror
+
@error('password')

{{ $message }}

@enderror
+
+ + +
+
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..cc9f4378 --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,10 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => $isEditing ? 'Edit collection' : 'New collection', 'breadcrumbs' => [['label' => 'Collections', 'href' => '/admin/collections'], ['label' => $isEditing ? ($title ?: 'Edit collection') : 'New collection']]]) +
+
+

Collection details

@error('title')

{{ $message }}

@enderror
@error('descriptionHtml')

{{ $message }}

@enderror
+ +
+
Discard
+
+
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..838bac54 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,14 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Collections', 'description' => 'Group products so customers can browse your catalog.', 'actionLabel' => $canManage ? 'Add collection' : null, 'actionHref' => $canManage ? '/admin/collections/create' : null]) +
+
+
+ @if ($collections->count()) + @foreach ($collections as $collection)@endforeach
CollectionProductsStatusHandle
@if ($canManage){{ $collection->title }}@else{{ $collection->title }}@endif{{ number_format($collection->products_count) }}@include('livewire.admin.components.status-badge', ['status' => $collection->status])/{{ $collection->handle }}
+ @else +

{{ $search ? 'No collections found' : 'No collections yet' }}

{{ $search ? 'Try another search term.' : 'Collections make it easier for customers to discover related products.' }}

@if (! $search && $canManage)Add collection@endif
+ @endif +
+ @if ($collections->hasPages())
{{ $collections->links() }}
@endif +
+
diff --git a/resources/views/livewire/admin/components/navigation.blade.php b/resources/views/livewire/admin/components/navigation.blade.php new file mode 100644 index 00000000..83aea712 --- /dev/null +++ b/resources/views/livewire/admin/components/navigation.blade.php @@ -0,0 +1,23 @@ + diff --git a/resources/views/livewire/admin/components/page-heading.blade.php b/resources/views/livewire/admin/components/page-heading.blade.php new file mode 100644 index 00000000..9f29f6fc --- /dev/null +++ b/resources/views/livewire/admin/components/page-heading.blade.php @@ -0,0 +1,16 @@ +@php($crumbs = $breadcrumbs ?? []) +
+ +
+

{{ $title }}

@if (!empty($description))

{{ $description }}

@endif
+ @if (!empty($actionLabel) && !empty($actionHref)){{ $actionLabel }}@endif +
+
diff --git a/resources/views/livewire/admin/components/settings-nav.blade.php b/resources/views/livewire/admin/components/settings-nav.blade.php new file mode 100644 index 00000000..6dc064e9 --- /dev/null +++ b/resources/views/livewire/admin/components/settings-nav.blade.php @@ -0,0 +1,5 @@ + diff --git a/resources/views/livewire/admin/components/status-badge.blade.php b/resources/views/livewire/admin/components/status-badge.blade.php new file mode 100644 index 00000000..73ffb69e --- /dev/null +++ b/resources/views/livewire/admin/components/status-badge.blade.php @@ -0,0 +1,10 @@ +@php + $statusLabel = \Illuminate\Support\Str::headline((string) ($status ?? 'unknown')); + $statusTone = match (strtolower((string) ($status ?? ''))) { + 'active', 'published', 'paid', 'fulfilled', 'complete', 'completed', 'success' => 'bg-emerald-50 text-emerald-800 ring-emerald-600/20 dark:bg-emerald-950/60 dark:text-emerald-200 dark:ring-emerald-400/20', + 'pending', 'draft', 'processing', 'partially_refunded' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950/60 dark:text-amber-200 dark:ring-amber-400/20', + 'cancelled', 'canceled', 'archived', 'failed', 'refunded', 'inactive' => 'bg-rose-50 text-rose-800 ring-rose-600/20 dark:bg-rose-950/60 dark:text-rose-200 dark:ring-rose-400/20', + default => 'bg-zinc-100 text-zinc-700 ring-zinc-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-400/20', + }; +@endphp +{{ $statusLabel }} 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..c54eb541 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,10 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Customers', 'description' => 'See customer profiles, order history, and marketing preferences.']) +
+
+
+ @if ($customers->count())@foreach ($customers as $customer)@endforeach
CustomerOrdersMarketingJoined
{{ $customer->name ?: 'Guest customer' }}{{ $customer->email }}{{ number_format($customer->orders_count) }}{{ $customer->marketing_opt_in ? 'Subscribed' : 'Not subscribed' }}{{ $customer->created_at?->format('M j, Y') }}
@else

{{ $search ? 'No customers found' : 'No customers yet' }}

{{ $search ? 'Try searching for a different name or email.' : 'Customer accounts will appear here after someone places an order or signs up.' }}

@endif +
+ @if ($customers->hasPages())
{{ $customers->links() }}
@endif +
+
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..a141b6de --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,10 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => $customer->name ?: 'Customer profile', 'breadcrumbs' => [['label' => 'Customers', 'href' => '/admin/customers'], ['label' => $customer->email]]]) +
+
+

Customer information

Update their contact name and marketing preference.

@error('name')

{{ $message }}

@enderror
Email address

{{ $customer->email }}

+

Orders

@if ($customer->orders->count())@else
No orders from this customer yet.
@endif
+
+ +
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..3244d7b5 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,33 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Dashboard', 'description' => 'A quick view of what is happening in your store.']) + @php + $currency = data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR'); + $todayAov = ($ordersToday ?? 0) > 0 ? (int) round($revenueToday / $ordersToday) : 0; + $metrics = [ + ['label' => 'Sales today', 'value' => number_format(($revenueToday ?? 0) / 100, 2).' '.$currency, 'detail' => 'Paid orders placed today', 'icon' => 'banknotes', 'tone' => 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'], + ['label' => 'Orders today', 'value' => number_format($ordersToday ?? 0), 'detail' => 'Orders placed today', 'icon' => 'shopping-bag', 'tone' => 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'], + ['label' => 'Open orders', 'value' => number_format($openOrders ?? 0), 'detail' => 'Ready for your attention', 'icon' => 'clock', 'tone' => 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300'], + ['label' => 'Products', 'value' => number_format($productsCount ?? 0), 'detail' => 'Across your catalog', 'icon' => 'cube', 'tone' => 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-300'], + ]; + @endphp +
+ @foreach ($metrics as $metric) +

{{ $metric['label'] }}

{{ $metric['value'] }}

{{ $metric['detail'] }}

+ @endforeach +
+
+
+

Recent orders

The latest orders from your store

View all
+ @if (count($recentOrders ?? [])) +
+ @foreach ($recentOrders as $order) + + @endforeach +
OrderCustomerDateStatusTotal
#{{ $order->order_number }}{{ $order->customer?->name ?: $order->email }}{{ $order->placed_at?->diffForHumans() ?? '—' }}@include('livewire.admin.components.status-badge', ['status' => $order->status]){{ number_format($order->total_amount / 100, 2) }} {{ $currency }}
+ @else +

No orders yet

New orders will appear here.

+ @endif +
+

Store activity

A snapshot of today's activity

Store visits
{{ number_format($visits ?? 0) }}
Average order
{{ number_format($todayAov / 100, 2) }} {{ $currency }}
Orders to fulfill
{{ number_format($openOrders ?? 0) }}

Keep your store moving

Review open orders, keep product details current, and make sure your delivery settings are up to date.

Review shipping
+
+
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..5089b0c3 --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,173 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Developers', 'description' => 'Manage API access and event notifications for connected services.']) + + @if ($generatedToken) + +

The plain-text token is shown once. Store it somewhere safe before leaving this page.

+
+ {{ $generatedToken }} + I've copied it +
+
+ @endif + +
+
+
+

API tokens

+

Manage personal access tokens for the Admin API. Tokens are limited to this store.

+
+ Generate new token +
+ + @if ($tokens->isEmpty()) +
+ +

No API tokens yet

+

Generate a token to connect an integration to this store.

+
+ @else +
+ + + + + + @foreach ($tokens as $token) + + + + + + + + @endforeach + +
NameLast usedCreatedExpiresActions
{{ $token->name }}{{ $token->tokenable?->name }}{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->created_at?->format('M j, Y') }}{{ $token->expires_at?->format('M j, Y') ?? 'Never' }}Revoke
+
+ @endif +
+ + + +
+
+
+

Webhooks

+

Manage webhook subscriptions for real-time event notifications.

+
+ Add webhook +
+ + @if ($webhooks->isEmpty()) +
+ +

No webhook subscriptions yet

+

Add an endpoint to receive store events.

+
+ @else +
+ + + + + + @foreach ($webhooks as $webhook) + @php + $webhookFailing = $webhook->latestDelivery?->status === 'failed'; + $webhookStatusLabel = $webhookFailing ? 'Failing' : \Illuminate\Support\Str::headline($webhook->status); + $webhookStatusColor = $webhookFailing ? 'red' : ($webhook->status === 'active' ? 'green' : 'zinc'); + @endphp + + + + + + + @endforeach + +
Event typeURLStatusActions
{{ $webhook->event_type }}{{ $webhook->target_url }}{{ $webhookStatusLabel }} + Edit + Delete +
+
+ @endif +

Each webhook receives a unique signing secret. Secrets are encrypted and used to sign event deliveries.

+
+ + +
+
Generate API tokenCreate a token that can access this store only.
+ + + Token name + + + + +
+ API permissions +

Choose the smallest set of permissions the integration needs.

+
+ @foreach ($tokenAbilityOptions as $ability) + + @endforeach +
+ @error('tokenAbilities')

{{ $message }}

@enderror + @error('tokenAbilities.*')

{{ $message }}

@enderror +
+ + + Expiry date (defaults to one year) + + + + +
+ Cancel + Generate +
+
+
+ + +
+
{{ $editingWebhookId === null ? 'Add webhook' : 'Edit webhook' }}Choose an event and the endpoint that should receive it.
+ + + Event type + + + + + + Endpoint URL + + + + + + Status + + + + +
+ Cancel + Save +
+
+
+
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..8f723c6e --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,173 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => $isEditing ? 'Edit discount' : 'Create discount', 'breadcrumbs' => [['label' => 'Discounts', 'href' => '/admin/discounts'], ['label' => $isEditing ? ($title ?: 'Edit discount') : 'New discount']]]) + +
+
+
+
+

Discount method

+
+ + +
+ @error('type')

{{ $message }}

@enderror + +
+
+ + + @error('title')

{{ $message }}

@enderror +
+ @if ($type === 'code') +
+ +
+ + +
+ @error('code')

{{ $message }}

@enderror +
+ @endif +
+
+ +
+

Value

+
+ @foreach ([['percentage', 'Percentage'], ['fixed_amount', 'Fixed amount'], ['free_shipping', 'Free shipping']] as [$value, $label]) + + @endforeach +
+ @error('valueType')

{{ $message }}

@enderror + @if ($valueType !== 'free_shipping') +
+ + + @if ($valueType === 'fixed_amount')

For example, 500 = 5.00 {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}.

@endif + @error('valueAmount')

{{ $message }}

@enderror +
+ @else +

The discount removes the shipping charge for qualifying orders.

+ @endif +
+ +
+

Conditions

+
+
+ + +

Leave empty for no minimum. For example, 2500 = 25.00 {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}.

+ @error('minimumPurchaseAmount')

{{ $message }}

@enderror +
+ +
+ + + @error('specificProductIds.*')

{{ $message }}

@enderror + @if ($products->isNotEmpty()) +
    + @foreach ($products as $product) +
  • + @endforeach +
+ @elseif (mb_strlen(trim($productSearch)) >= 2) +

No matching products.

+ @endif + @if ($selectedProducts->isNotEmpty()) +
    + @foreach ($selectedProducts as $product) +
  • {{ $product->title }}
  • + @endforeach +
+ @endif +
+ +
+ + + @error('specificCollectionIds.*')

{{ $message }}

@enderror + @if ($collections->isNotEmpty()) +
    + @foreach ($collections as $collection) +
  • + @endforeach +
+ @elseif (mb_strlen(trim($collectionSearch)) >= 2) +

No matching collections.

+ @endif + @if ($selectedCollections->isNotEmpty()) +
    + @foreach ($selectedCollections as $collection) +
  • {{ $collection->title }}
  • + @endforeach +
+ @endif +
+
+
+ +
+

Usage limits

+
+
+ + + @error('usageLimit')

{{ $message }}

@enderror +
+ +
+
+ +
+

Active dates

+
+
+ + + @error('startsAt')

{{ $message }}

@enderror +
+
+ + + @error('endsAt')

{{ $message }}

@enderror +
+
+
+ + +
+ +
+
+ Discard + +
+
+
+
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..c6576ca4 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,92 @@ +
+ @include('livewire.admin.components.page-heading', [ + 'title' => 'Discounts', + 'description' => 'Create and manage codes and automatic promotions.', + 'actionLabel' => $canManage ? 'Create discount' : null, + 'actionHref' => $canManage ? route('admin.discounts.create') : null, + ]) + +
+
+

Discounts

+
+
+ + +
+
+ + +
+
+
+ + @if ($discounts->count()) +
+ + + + + + + + + + @if ($canManage)@endif + + + + @foreach ($discounts as $discount) + + + + + + + + @if ($canManage) + + @endif + + @endforeach + +
PromotionActivationValueUsageStatusDatesActions
+ {{ $discount->title }} + {{ $discount->code ?: 'Automatic' }} + {{ ($discount->rules_json['activation_method'] ?? ($discount->code ? 'code' : 'automatic')) === 'automatic' ? 'Automatic' : 'Code' }} + @if ($discount->type === 'percentage') + {{ $discount->value }}% + @elseif ($discount->type === 'fixed_amount') + {{ number_format($discount->value / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }} + @else + Free shipping + @endif + {{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'unlimited' }} + @php($discountStatus = ! $discount->is_active ? 'inactive' : ($discount->ends_at?->isPast() ? 'expired' : ($discount->starts_at?->isFuture() ? 'scheduled' : 'active'))) + @include('livewire.admin.components.status-badge', ['status' => $discountStatus]) + {{ $discount->starts_at?->format('M j, Y H:i') }}
{{ $discount->ends_at?->format('M j, Y H:i') ?? 'No end date' }}
+
+ Edit + + +
+
+
+ @else +
+

No discounts yet

+

Create a code or automatic promotion for your customers.

+ @if ($canManage)Create discount@endif +
+ @endif + + @if ($discounts->hasPages()) +
{{ $discounts->links() }}
+ @endif +
+
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..f6ead1ce --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,112 @@ +
+ @include('livewire.admin.components.page-heading', [ + 'title' => 'Inventory', + 'description' => 'Track on-hand stock and backorder settings across your product variants.', + ]) + +
+
+ +
+ + +
+ + + +
+ +
+ @if ($inventoryItems->count()) + + + + + + + + + + @if ($canManageInventory)@endif + + + + @foreach ($inventoryItems as $inventoryItem) + @php($variantTitle = $inventoryItem->variant->title) + + + + + + + + @if ($canManageInventory)@endif + + @endforeach + +
ProductVariantSKUOn handReservedPolicyActions
+ {{ $inventoryItem->variant->product->title }} + {{ $variantTitle }}{{ $inventoryItem->variant->sku ?: '—' }} + @if ($canManageInventory) + + + @error('quantities.'.$inventoryItem->id) + {{ $message }} + @enderror + @else + {{ number_format($inventoryItem->quantity_on_hand) }} + @endif + {{ number_format($inventoryItem->quantity_reserved) }} +
+ {{ $inventoryItem->policy }} + @if ($canManageInventory) + + + @error('policies.'.$inventoryItem->id) + {{ $message }} + @enderror + @endif +
+
+ +
+ @else +
+ +

{{ $search || $stockFilter !== 'all' ? 'No inventory matches these filters' : 'No inventory to manage yet' }}

+

{{ $search || $stockFilter !== 'all' ? 'Try a different product, SKU, or stock level.' : 'Inventory appears here when products have variants.' }}

+
+ @endif +
+ + @if ($inventoryItems->hasPages()) +
{{ $inventoryItems->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/layout/top-bar.blade.php b/resources/views/livewire/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..eed46f45 --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,49 @@ +
+
+ @if ($stores->count() > 1) +
+ + Current store{{ $currentStoreName }} + + +
+

Your stores

+ @foreach ($stores as $store) + + @endforeach +
+
+ @else +
Current store{{ $currentStoreName }}
+ @endif +
+ +
+ @if ($unreadNotificationCount > 0) + + @else + + @endif + +
+ + + + +
+ Settings +
+
@csrf
+
+
+
+
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..2f88be8f --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,143 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Navigation', 'breadcrumbs' => [['label' => 'Navigation']]]) + +
+
+ +

Choose a menu to manage its links.

+
+ @foreach ($menus as $menu) + + @endforeach +
+
+ + @if ($selectedMenu) +
+
+
+ +

Drag links to reorder them. Drop a link onto a top-level item to make it a submenu link.

+
+ Add item +
+ + @error('menuItems') + + @enderror + + @if ($menuItems === []) +
+

This menu is empty

+

Add links, pages, collections, or products to show them in your storefront.

+
+ @else +
    + @foreach ($menuItems as $index => $item) + @php + $resourceGroup = ['page' => 'pages', 'collection' => 'collections', 'product' => 'products'][$item['type']] ?? null; + $resource = $resourceGroup && ! empty($item['resource_id']) ? $resources[$resourceGroup]->firstWhere('id', (int) $item['resource_id']) : null; + $target = $item['type'] === 'link' ? $item['url'] : $item['type'].': '.($resource?->title ?? 'Unavailable resource'); + @endphp +
  • +
    + +

    {{ $item['label'] }}

    {{ $target }}

    +
    + + + +
    +
    + +
      + @foreach ($item['children'] as $childIndex => $child) + @php + $childResourceGroup = ['page' => 'pages', 'collection' => 'collections', 'product' => 'products'][$child['type']] ?? null; + $childResource = $childResourceGroup && ! empty($child['resource_id']) ? $resources[$childResourceGroup]->firstWhere('id', (int) $child['resource_id']) : null; + $childTarget = $child['type'] === 'link' ? $child['url'] : $child['type'].': '.($childResource?->title ?? 'Unavailable resource'); + @endphp +
    • + +

      {{ $child['label'] }}

      {{ $childTarget }}

      +
      + + +
      +
    • + @endforeach +
    +
  • + @endforeach +
+ @endif + +
+ Save menu +
+
+ @endif +
+ + +
+
{{ $editingItemId === null ? 'Add menu item' : 'Edit menu item' }}Choose a link target and where it appears in the menu.
+ + Label + + + + + Type + + Custom link + Page + Collection + Product + + + + @if ($itemType === 'link') + + URL + + + + @else + @php($resourceGroup = ['page' => 'pages', 'collection' => 'collections', 'product' => 'products'][$itemType] ?? null) + + {{ ucfirst($itemType) }} + + @foreach ($resources[$resourceGroup] ?? [] as $resource) + {{ $resource->title }} + @endforeach + + + + @endif + + Parent item + + Top-level item + @foreach ($menuItems as $parentItem) + @if ($parentItem['id'] !== $editingItemId) + {{ $parentItem['label'] }} + @endif + @endforeach + + + +
+ Cancel + Save item +
+
+
+
diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php new file mode 100644 index 00000000..582b952d --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,8 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Orders', 'description' => 'Review payments, fulfillment, and customer orders.']) +
+
+
@if($orders->count())@foreach($orders as $order)@endforeach
OrderDateCustomerPaymentFulfillmentTotal
#{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') ?? $order->created_at?->format('M j, Y') }}{{ $order->customer?->name ?: $order->email }}@include('livewire.admin.components.status-badge', ['status' => $order->financial_status])@include('livewire.admin.components.status-badge', ['status' => $order->fulfillment_status ?: 'unfulfilled']){{ number_format($order->total_amount / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}
@else

{{ $search || $status !== 'all' ? 'No matching orders' : 'No orders yet' }}

{{ $search || $status !== 'all' ? 'Try another search or status filter.' : 'Orders will appear here when customers complete checkout.' }}

@endif
+ @if($orders->hasPages())
{{ $orders->links() }}
@endif +
+
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..60cbc8db --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,129 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Order #'.$order->order_number, 'breadcrumbs' => [['label' => 'Orders', 'href' => '/admin/orders'], ['label' => '#'.$order->order_number]]]) +
+
+

Items

Placed {{ $order->placed_at?->format('F j, Y · g:i A') ?? $order->created_at?->format('F j, Y · g:i A') }}

@include('livewire.admin.components.status-badge', ['status' => $order->status])@include('livewire.admin.components.status-badge', ['status' => $order->financial_status])
@if($order->lines->count())
@foreach($order->lines as $line)

{{ $line->title_snapshot }}

{{ $line->variant_title_snapshot }}@if($line->sku_snapshot) · SKU {{ $line->sku_snapshot }}@endif · Qty {{ $line->quantity }}

{{ number_format($line->total_amount / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}

@endforeach
@else
No line items are available.
@endif
Subtotal
{{ number_format($order->subtotal_amount / 100, 2) }}
Shipping
{{ number_format($order->shipping_amount / 100, 2) }}
Tax
{{ number_format($order->tax_amount / 100, 2) }}
@if($order->discount_amount > 0)
Discount
−{{ number_format($order->discount_amount / 100, 2) }}
@endif
Total
{{ number_format($order->total_amount / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}
+
+
+
+

Fulfillment

+

Create shipments for selected quantities, then record when each shipment leaves and arrives.

+
+ @include('livewire.admin.components.status-badge', ['status' => $order->fulfillment_status ?: 'unfulfilled']) +
+ + @if($order->fulfillments->isNotEmpty()) +
+ @foreach($order->fulfillments as $fulfillment) +
+
+
+

{{ $fulfillment->tracking_company ?: 'Shipment' }}

+

{{ $fulfillment->tracking_number ?: 'Tracking number not provided' }}

+

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

+ @if($fulfillment->shipped_at)

Shipped {{ $fulfillment->shipped_at->format('M j, Y · g:i A') }}

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

Delivered {{ $fulfillment->delivered_at->format('M j, Y · g:i A') }}

@endif +
+ @include('livewire.admin.components.status-badge', ['status' => $fulfillment->status]) +
+ + @if($fulfillment->tracking_url) + Track shipment (opens in new tab) + @endif + + @if($fulfillment->lines->isNotEmpty()) +
    + @foreach($fulfillment->lines as $fulfillmentLine) +
  • {{ $fulfillmentLine->orderLine?->title_snapshot ?? 'Order item' }} · Qty {{ $fulfillmentLine->quantity }}
  • + @endforeach +
+ @endif + + @can('update', $order) +
+ @if($fulfillment->status === 'pending') + + @elseif($fulfillment->status === 'shipped') + + @endif +
+ @endcan +
+ @endforeach +
+ @else +

No shipments have been created for this order.

+ @endif + + @if(in_array($order->financial_status, ['paid', 'partially_refunded'], true) && !in_array($order->status, ['cancelled', 'canceled', 'refunded'], true) && $order->fulfillment_status !== 'fulfilled' && $order->lines->contains(fn ($line) => (int) $line->fulfillmentLines->sum('quantity') < $line->quantity)) + @can('createFulfillment', $order) +
+
+

Create fulfillment

+

Choose the quantity of each remaining item to include in this shipment.

+
+ + @if($order->lines->isNotEmpty()) +
+ @foreach($order->lines as $line) + @php($remainingQuantity = max(0, $line->quantity - (int) $line->fulfillmentLines->sum('quantity'))) +
+
+ +

{{ $line->variant_title_snapshot }} · {{ $remainingQuantity }} remaining of {{ $line->quantity }}

+
+ + @error("fulfillmentLines.{$line->id}")

{{ $message }}

@enderror +
+ @endforeach +
+ @else +

There are no order items available to fulfill.

+ @endif + + @error('fulfillmentLines')

{{ $message }}

@enderror + @error('lines')

{{ $message }}

@enderror + +
+
+ + + @error('trackingCompany')

{{ $message }}

@enderror +
+
+ + + @error('trackingNumber')

{{ $message }}

@enderror +
+
+ + + @error('trackingUrl')

{{ $message }}

@enderror +
+
+ + +
+ @endcan + @endif +
+ @if($order->payments->count())

Payments

@foreach($order->payments as $payment)

{{ \Illuminate\Support\Str::headline($payment->method ?? 'Payment') }}

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

@include('livewire.admin.components.status-badge', ['status' => $payment->status]){{ number_format($payment->amount / 100, 2) }}
@endforeach
@endif + @if($order->refunds->count())

Refunds

@foreach($order->refunds as $refund)

{{ $refund->reason ?: 'Refund' }}

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

−{{ number_format($refund->amount / 100, 2) }}

@endforeach
@endif +
+ +
+
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..c74bce0b --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,4 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => $isEditing ? 'Edit page' : 'New page', 'breadcrumbs' => [['label' => 'Pages', 'href' => '/admin/pages'], ['label' => $isEditing ? ($title ?: 'Edit page') : 'New page']]]) +

Page content

@error('title')

{{ $message }}

@enderror

Plain text content is escaped when saved.

@error('bodyHtml')

{{ $message }}

@enderror
Discard
+
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..406e1bb2 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,11 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Pages', 'description' => 'Publish the content pages for your storefront.', 'actionLabel' => 'Add page', 'actionHref' => '/admin/pages/create']) +
+ @if ($pages->count()) +
@foreach ($pages as $page)@endforeach
PageStatusUpdatedActions
{{ $page->title }}/pages/{{ $page->handle }}@include('livewire.admin.components.status-badge', ['status' => $page->status]){{ $page->updated_at?->diffForHumans() }}
Edit
+ @else +

Create your first page

Add helpful content such as an About page or your store policies.

Add page
+ @endif + @if ($pages->hasPages())
{{ $pages->links() }}
@endif +
+
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..2bd749d9 --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,218 @@ +
+ @include('livewire.admin.components.page-heading', [ + 'title' => $isEditing ? 'Edit product' : 'Add product', + 'breadcrumbs' => [['label' => 'Products', 'href' => route('admin.products')], ['label' => $isEditing ? ($title ?: 'Edit product') : 'New product']], + ]) + +
+
+
+
+

Product details

+
+
+ + + @error('title')

{{ $message }}

@enderror +
+
+ + +

Safe formatting such as paragraphs, emphasis, and lists is retained.

+ @error('descriptionHtml')

{{ $message }}

@enderror +
+
+
+ +
+

Default pricing and inventory

+

New variants inherit this price. Each variant can be adjusted below after saving.

+
+
+ + +

For example, 2499 = 24.99 {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}

+ @error('priceAmount')

{{ $message }}

@enderror +
+
+ + + @error('compareAtAmount')

{{ $message }}

@enderror +
+
+ + + @error('quantityOnHand')

{{ $message }}

@enderror +
+
+ + + @error('sku')

{{ $message }}

@enderror +
+
+
+ +
+
+
+

Media

+

Upload product images. Responsive sizes and WebP versions are generated in the background.

+
+ @if ($isEditing && count($uploads) > 0) + + @endif +
+ + + @error('uploads')

{{ $message }}

@enderror + @error('uploads.*')

{{ $message }}

@enderror +
+ + @if (count($uploads) > 0) +
+ @foreach ($uploads as $uploadIndex => $upload) +
+ Preview of {{ $upload->getClientOriginalName() }} +

{{ $upload->getClientOriginalName() }}

+
+ @endforeach +
+ @endif + + @if (! $isEditing) +

Save the product to add the selected images.

+ @endif + + @if ($mediaItems->isNotEmpty()) +
+ @foreach ($mediaItems as $mediaIndex => $media) +
+ {{ $media->alt_text ?: $title.' image '.($mediaIndex + 1) }} +
+ + +
+ {{ ucfirst($media->status) }} +
+ + + +
+
+
+
+ @endforeach +
+ @endif +
+ +
+
+
+

Options and variants

+

Add up to three options. Separate values with commas; the full combination matrix is created on save.

+
+ @if (count($options) < 3) + + @endif +
+
+ @forelse ($options as $index => $option) +
+
+ + +
+
+ + +
+
+ +
+ @error("options.{$index}.name")

{{ $message }}

@enderror + @error("options.{$index}.values")

{{ $message }}

@enderror +
+ @empty +

No options yet. Products without options use a single default variant.

+ @endforelse +
+ @error('options')

{{ $message }}

@enderror +
+ + @if ($isEditing && $hasOptionMatrix) +
+

Variant inventory and pricing

+
+ + + + @foreach ($variants as $index => $variant) + + + + + + + + + + @endforeach + +
VariantSKUPriceCompare-atAvailablePolicyShipping
{{ $variant['title'] }}{{ $variant['quantity_reserved'] }} reserved
+
+ @error('variants')

{{ $message }}

@enderror +
+ @endif + +
+

Organization

+
+
@error('productType')

{{ $message }}

@enderror
+
@error('vendor')

{{ $message }}

@enderror
+
+
+ Collections +
+ @forelse ($collections as $collection) + + @empty +

Create a collection before assigning this product.

+ @endforelse +
+ @error('collectionIds')

{{ $message }}

@enderror +
+
+
+ + +
+ +
+
Discard
+
+
+
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..c4ff215d --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,22 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Products', 'description' => 'Manage the products and variants in your catalog.', 'actionLabel' => $canCreate ? 'Add product' : null, 'actionHref' => $canCreate ? '/admin/products/create' : null]) +
+
+
+ +
+
+ @if ($products->count()) + + @foreach ($products as $product) + @php($inventory = $product->variants->sum(fn ($variant) => $variant->inventoryItem?->quantity_on_hand ?? 0)) + + @endforeach +
ImageProductStatusInventoryTypePriceActions
@if ($canUpdate){{ $product->title }}@else{{ $product->title }}@endif/{{ $product->handle }}@include('livewire.admin.components.status-badge', ['status' => $product->status]){{ number_format($inventory) }} in stock{{ $product->product_type ?: '—' }}{{ number_format(($product->variants->first()?->price_amount ?? 0) / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}
@if ($canUpdate)Edit@endif @if ($canDelete)@endif
+ @else +

{{ $search || $status !== 'all' ? 'No products match your filters' : ($canCreate ? 'Add your first product' : 'No products yet') }}

{{ $search || $status !== 'all' ? 'Try another search or status.' : 'Your store products appear here.' }}

@if (! ($search || $status !== 'all') && $canCreate)Add product@endif
+ @endif +
+ @if ($products->hasPages())
{{ $products->links() }}
@endif +
+
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..0c13daa1 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,81 @@ +
+ @include('livewire.admin.components.page-heading', [ + 'title' => 'Search settings', + 'description' => 'Tune how customers discover products in your storefront.', + 'breadcrumbs' => [['label' => 'Settings', 'href' => '/admin/settings']], + ]) + +
+
+
+

Synonyms

+

Treat related words as equivalent when customers search.

+
+ +
+ @foreach ($synonymGroups as $index => $group) +
+
+ + + @error('synonymGroups.'.$index)

{{ $message }}

@enderror +
+ +
+ @endforeach + @error('synonymGroups')

{{ $message }}

@enderror +
+ + +
+ +
+

Stop words

+

Words that should not affect search results. Separate words with commas.

+ + + @error('stopWords')

{{ $message }}

@enderror +
+ +
+
+
+

Search index

+

Rebuild the product index after a bulk catalog change.

+
+ +
+ +
+ Index status: {{ str($indexStatus)->headline() }} + {{ number_format($documentsCount) }} documents + @if ($lastReindexedAt)Last indexed {{ \Illuminate\Support\Carbon::parse($lastReindexedAt)->timezone(config('app.timezone'))->format('M j, Y g:i A') }}@endif +
+ + @if (in_array($indexStatus, ['queued', 'processing'], true)) +
+
Reindexing products{{ $progress }}% complete
+
+ @if ($pendingUpdates > 0)

{{ number_format($pendingUpdates) }} remaining

@endif +
+ @endif + + @error('reindex')

{{ $message }}

@enderror +
+ +
+ +
+
+
diff --git a/resources/views/livewire/admin/select-store.blade.php b/resources/views/livewire/admin/select-store.blade.php new file mode 100644 index 00000000..552b7168 --- /dev/null +++ b/resources/views/livewire/admin/select-store.blade.php @@ -0,0 +1,20 @@ +
+
+ +

Choose a store

+

Select the store you want to manage.

+
+ +
    + @foreach ($stores as $store) +
  • + +
  • + @endforeach +
+ +
@csrf
+
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..8ba30df2 --- /dev/null +++ b/resources/views/livewire/admin/settings/domains.blade.php @@ -0,0 +1,42 @@ +
+
+

Domains

Manage storefront, admin, and API hostnames for this store.

+ Add domain +
+ +
+ @if ($domains->isEmpty()) +

No domains added

Add a hostname to make this store available on your own domain.

+ @else +
+ + + + + @foreach ($domains as $domain) + + @endforeach + +
Domains connected to this store
HostnameTypePrimaryTLSActions
{{ $domain->hostname }}{{ $domain->type }}@if ($domain->is_primary)Primary@else—@endif{{ $domain->tls_mode }}
@if (! $domain->is_primary)Set primary@endifRemove
+
+ @endif +

Managed TLS is enabled for new domains. Domain DNS configuration may be required before the hostname becomes reachable.

+
+ + +
+
Add domainConnect a hostname to this store.
+ + Hostname + + + + + Type + + + +
CancelAdd domain
+
+
+
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..26064b21 --- /dev/null +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -0,0 +1,118 @@ +
+ @php + $activeSettingsTab = match (request()->query('tab')) { + 'domains' => 'Domains', + 'checkout' => 'Checkout', + 'notifications' => 'Notifications', + default => 'General', + }; + + $tabDescriptions = [ + 'General' => 'Manage your store identity and regional defaults.', + 'Domains' => 'Manage storefront, admin, and API hostnames for this store.', + 'Checkout' => 'Review the settings that shape your checkout experience.', + 'Notifications' => 'Review how store notifications are handled.', + ]; + @endphp + + @include('livewire.admin.components.page-heading', [ + 'title' => $activeSettingsTab === 'General' ? 'General settings' : $activeSettingsTab, + 'description' => $tabDescriptions[$activeSettingsTab], + 'breadcrumbs' => [['label' => 'Settings']], + ]) + @include('livewire.admin.components.settings-nav', ['active' => $activeSettingsTab]) + + @if ($activeSettingsTab === 'Domains') + + @elseif ($activeSettingsTab === 'Checkout') +
+

Checkout settings

+

Checkout uses this store’s regional defaults, shipping rates, and tax rules.

+ +
+ @elseif ($activeSettingsTab === 'Notifications') +
+

Notification settings

+

No additional notification preferences are available for this store yet. Order confirmations, refunds, cancellations, and shipment updates are sent to the email address on each order.

+ Review general settings +
+ @else + @if (session('status')) +

{{ session('status') }}

+ @endif + +
+
+
+

Store details

+

Basic information about your store.

+
+
+
+ + + @error('storeName')

{{ $message }}

@enderror +
+
+ + +

The store handle cannot be changed after creation.

+
+
+ + + @error('contactEmail')

{{ $message }}

@enderror +
+
+
+ +
+
+

Defaults

+

Currency, language, and timezone settings.

+
+
+
+ + + @error('defaultCurrency')

{{ $message }}

@enderror +
+
+ + + @error('defaultLocale')

{{ $message }}

@enderror +
+
+ + + @error('timezone')

{{ $message }}

@enderror +
+
+
+ +
+ +
+
+ @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..87eb6aa3 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,119 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Shipping', 'description' => 'Manage delivery zones and the rates customers see at checkout.', 'breadcrumbs' => [['label' => 'Settings', 'href' => '/admin/settings']]]) + @include('livewire.admin.components.settings-nav', ['active' => 'Shipping']) + +
+
+
+ @forelse ($zones as $zone) +
+
+
+

{{ $zone->name }}

+

Countries: {{ implode(', ', $zone->countries ?? []) }}

+ @if (count($zone->regions ?? []))

Regions: {{ implode(', ', $zone->regions) }}

@endif +
+
+ + +
+
+ + @if ($zone->rates->isNotEmpty()) +
+ + + + @foreach ($zone->rates as $rate) + + + + + + + + @endforeach + +
NameTypeConfigActiveActions
{{ $rate->name }}{{ $rate->type }} + @if ($rate->type === 'flat'){{ number_format($rate->price_amount / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }} + @elseif ($rate->type === 'carrier'){{ strtoupper($rate->config_json['carrier'] ?? 'carrier') }} · {{ $rate->config_json['service'] ?? 'service' }} + @else{{ count($rate->config_json['tiers'] ?? $rate->config_json['ranges'] ?? []) }} price tiers @endif +
+
+ @else +

No rates in this zone yet.

+ @endif + +
+
+ @empty +

No shipping zones

Add a zone to define where your store can deliver.

+ @endforelse +
+ +
+

Test shipping address

+

Enter an address and basket size to see which zone and rates match.

+
+
@error('testCountryCode')

{{ $message }}

@enderror
+
+
+
+
+
+
+
+ @if ($hasTestedAddress) + @if ($testResult === null) +

No shipping zone matches this address.

+ @else +

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

@forelse ($testResult['rates'] as $rate)

{{ $rate['name'] }} · {{ number_format($rate['price_amount'] / 100, 2) }} {{ data_get(app()->bound('current_store') ? app('current_store') : null, 'default_currency', 'EUR') }}

@empty

No active rates match this basket.

@endforelse
+ @endif + @endif +
+
+ +
+
+

{{ $editingZoneId ? 'Edit shipping zone' : 'Add shipping zone' }}

+
+
@error('zoneName')

{{ $message }}

@enderror
+

Two-letter country codes separated by commas.

@error('countries')

{{ $message }}

@enderror
+

Region codes must use CC-XX format.

@error('regions')

{{ $message }}

@enderror
+
@if ($editingZoneId)@endif
+
+
+ +
+

{{ $editingRateId ? 'Edit shipping rate' : 'Add shipping rate' }}

+
+
@error('zoneId')

{{ $message }}

@enderror
+
@error('rateName')

{{ $message }}

@enderror
+
+ + @if ($rateType === 'flat' || $rateType === 'carrier') +
@error('rateAmount')

{{ $message }}

@enderror
+ @endif + + @if ($rateType === 'weight') +
Weight tiers (grams)@foreach ($weightTiers as $index => $tier)
@endforeach
+ @endif + + @if ($rateType === 'price') +
Order value tiers (minor units)@foreach ($priceTiers as $index => $tier)
@endforeach
+ @endif + + @if ($rateType === 'carrier') +
Carrier-calculated rates use the fallback price until a carrier integration is configured.
+
+
+ @endif + +
@error('minOrderAmount')

{{ $message }}

@enderror
@error('maxOrderAmount')

{{ $message }}

@enderror
+ +
@if ($editingRateId)@endif
+
+
+
+
+
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..18aa2435 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,41 @@ +
+ @include('livewire.admin.components.page-heading', ['title' => 'Taxes', 'description' => 'Configure manual tax zones or a tax provider for checkout.', 'breadcrumbs' => [['label' => 'Settings', 'href' => '/admin/settings']]]) + @include('livewire.admin.components.settings-nav', ['active' => 'Taxes']) + +
+
+

Tax calculation mode

+
+ + +
+ @error('mode')

{{ $message }}

@enderror +
+ + @if ($mode === 'manual') +
+

Manual rates

Use a country code such as DE or a region code such as US-CA.

+
@foreach ($manualRates as $index => $rate)@endforeach
Zone codeRate (%)
@error("manualRates.{$index}.zone_name")

{{ $message }}

@enderror
%
@error("manualRates.{$index}.rate_percentage")

{{ $message }}

@enderror
+ @error('manualRates')

{{ $message }}

@enderror +
+ @else +
+

Provider configuration

Provider-backed calculation is a stub; the configured fallback behavior applies if a provider is unavailable.

+
@error('provider')

{{ $message }}

@enderror
+ @if ($provider === 'stripe_tax') +
@if($hasSavedProviderKey)

A provider key is saved and encrypted.

@endif @error('providerApiKey')

{{ $message }}

@enderror
+ @endif +
@error('fallback')

{{ $message }}

@enderror
+
+ @endif + +
+

Tax-inclusive pricing

Choose how the checkout treats displayed product prices.

+ + @error('pricesIncludeTax')

{{ $message }}

@enderror +
@error('defaultRatePercentage')

{{ $message }}

@enderror
+
+ +
+
+
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..be960e93 --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,124 @@ +
+
+
+ + + + Back + + +
+

{{ $theme->name }}

+ +
+
+ +
+ + + +
+
+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+ + +
+
+

Live preview

+ Storefront +
+
+ +
+
+ + +
+
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..25448ea5 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,87 @@ +
+ @include('livewire.admin.components.page-heading', [ + 'title' => 'Themes', + 'description' => 'Choose and customize the look of your online store.', + ]) + + @if ($themes->isEmpty()) +
+ + + +

No themes installed

+

Install a theme to customize your storefront appearance.

+
+ @else +
+ @foreach ($themes as $theme) +
$theme->is_active, + 'border-zinc-200 dark:border-zinc-800' => ! $theme->is_active, + ])> + + +
+
+
+
+

{{ $theme->name }}

+ {{ $theme->version ? 'v'.$theme->version : 'No version' }} +
+
+ @include('livewire.admin.components.status-badge', ['status' => $theme->status]) +
+
+ +
+ + + +
+ + Preview store (opens in a new tab) + + @unless ($theme->is_active) + + @endunless + + + +
+
+
+ +
+ + Customize + + @if (data_get($theme->settings?->settings_json ?? [], 'colors.primary')) + + + Primary color + + @endif +
+
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/storefront/account/addresses/index.blade.php b/resources/views/livewire/storefront/account/addresses/index.blade.php new file mode 100644 index 00000000..850d6a74 --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Well begun is half done. - Aristotle --}} +
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..649c3d60 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,3 @@ +
+ {{-- People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius --}} +
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..67deb83a --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Act only according to that maxim whereby you can, at the same time, will that it should become a universal law. - Immanuel Kant --}} +
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..401ee286 --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.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/storefront/account/orders/index.blade.php b/resources/views/livewire/storefront/account/orders/index.blade.php new file mode 100644 index 00000000..62ebcf33 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,3 @@ +
+ {{-- When there is no desire, all things are at peace. - Laozi --}} +
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..7e910999 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.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/storefront/cart-count.blade.php b/resources/views/livewire/storefront/cart-count.blade.php new file mode 100644 index 00000000..5b559f48 --- /dev/null +++ b/resources/views/livewire/storefront/cart-count.blade.php @@ -0,0 +1,4 @@ + 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..a2713ce4 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Live as if you were to die tomorrow. Learn as if you were to live forever. - Mahatma Gandhi --}} +
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..2eb5e426 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,3 @@ +
+ {{-- It always seems impossible until it is done. - Nelson Mandela --}} +
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..2eb5e426 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,3 @@ +
+ {{-- It always seems impossible until it is done. - Nelson Mandela --}} +
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..13ee5d58 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,3 @@ +
+ {{-- He who is contented is rich. - Laozi --}} +
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..4b68a488 --- /dev/null +++ b/resources/views/livewire/storefront/collections/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/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..6b564865 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,3 @@ +
+ {{-- The best way to take care of the future is to take care of the present moment. - Thich Nhat Hanh --}} +
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..9a717dcc --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,3 @@ +
+ {{-- It is quality rather than quantity that matters. - Lucius Annaeus Seneca --}} +
diff --git a/resources/views/livewire/storefront/navigation/menu.blade.php b/resources/views/livewire/storefront/navigation/menu.blade.php new file mode 100644 index 00000000..0a6a6c5e --- /dev/null +++ b/resources/views/livewire/storefront/navigation/menu.blade.php @@ -0,0 +1,74 @@ +
+@if ($presentation === 'mobile') +
+ + +
+@elseif ($presentation === 'desktop') + +@else +
+ @forelse (array_slice($columns, 0, 3) as $column) +
+

{{ data_get($column, 'label', 'Explore') }}

+ +
+ @empty + + @endforelse +
+@endif +
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php new file mode 100644 index 00000000..44e73cee --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Simplicity is the essence of happiness. - Cedric Bledsoe --}} +
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..67deb83a --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Act only according to that maxim whereby you can, at the same time, will that it should become a universal law. - Immanuel Kant --}} +
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..8f10a552 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Knowing is not enough; we must apply. Being willing is not enough; we must do. - Leonardo da Vinci --}} +
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..5eced208 --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,3 @@ +
+ {{-- Because you are alive, everything is possible. - Thich Nhat Hanh --}} +
diff --git a/resources/views/mail/orders/customer-notification.blade.php b/resources/views/mail/orders/customer-notification.blade.php new file mode 100644 index 00000000..d831074a --- /dev/null +++ b/resources/views/mail/orders/customer-notification.blade.php @@ -0,0 +1,51 @@ +

+ @switch($notificationType) + @case(\App\Mail\CustomerOrderNotification::ORDER_CONFIRMATION) + Thank you for your order + @break + @case(\App\Mail\CustomerOrderNotification::REFUND) + Your refund has been processed + @break + @case(\App\Mail\CustomerOrderNotification::SHIPPED) + Your order has shipped + @break + @case(\App\Mail\CustomerOrderNotification::CANCELLED) + Your order has been cancelled + @break + @endswitch +

+ +

Order {{ $order->order_number }}

+ +@if ($notificationType === \App\Mail\CustomerOrderNotification::ORDER_CONFIRMATION) +

We have received your order. Its current payment status is {{ str_replace('_', ' ', $order->financial_status) }}.

+

Order summary

+
    + @foreach ($order->lines as $line) +
  • {{ $line->title_snapshot }}@if ($line->variant_title_snapshot) — {{ $line->variant_title_snapshot }}@endif, {{ $line->quantity }} × {{ number_format($line->unit_price_amount / 100, 2) }} {{ $order->currency }}
  • + @endforeach +
+

Total: {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}

+ @if ($order->payment_method === 'bank_transfer' && $order->financial_status === 'pending') +

Bank transfer instructions

+

Bank: Mock Bank AG
IBAN: DE89 3704 0044 0532 0130 00
BIC: COBADEFFXXX
Reference: {{ $order->order_number }}
Amount: {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}

+ @endif +@elseif ($notificationType === \App\Mail\CustomerOrderNotification::REFUND) +

A refund of {{ number_format(($details['amount'] ?? 0) / 100, 2) }} {{ $order->currency }} has been processed for your order.

+ @if (filled($details['reason'] ?? null)) +

Reason: {{ $details['reason'] }}

+ @endif +@elseif ($notificationType === \App\Mail\CustomerOrderNotification::SHIPPED) +

Your items are on their way.

+ @if (filled($details['tracking_company'] ?? null)) +

Carrier: {{ $details['tracking_company'] }}

+ @endif + @if (filled($details['tracking_number'] ?? null)) +

Tracking number: {{ $details['tracking_number'] }}

+ @endif + @if (filter_var($details['tracking_url'] ?? null, FILTER_VALIDATE_URL)) +

Track your shipment

+ @endif +@elseif ($notificationType === \App\Mail\CustomerOrderNotification::CANCELLED) +

Your order was cancelled. Any reserved inventory has been released.

+@endif diff --git a/resources/views/storefront/account/addresses.blade.php b/resources/views/storefront/account/addresses.blade.php new file mode 100644 index 00000000..d582054a --- /dev/null +++ b/resources/views/storefront/account/addresses.blade.php @@ -0,0 +1,27 @@ + +@section('title', 'Your addresses') +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'My account', 'url' => '/account'], ['label' => 'Addresses']]]) +

Your account

Your addresses

Choose where your orders should arrive.

+ @if (count($addresses)) +
+ @foreach ($addresses as $address) + @php($addressId = data_get($address, 'id')) +
+ @if (data_get($address, 'is_default'))@include('storefront.components.badge', ['text' => 'Default address', 'variant' => 'new'])@endif +
{{ data_get($address, 'first_name') }} {{ data_get($address, 'last_name') }}
{{ data_get($address, 'address_line_1') }}@if(data_get($address, 'address_line_2'))
{{ data_get($address, 'address_line_2') }}@endif
{{ data_get($address, 'city') }}, {{ data_get($address, 'state') }} {{ data_get($address, 'postal_code') }}
{{ data_get($address, 'country') }}@if(data_get($address, 'phone'))
{{ data_get($address, 'phone') }}@endif
+
@unless(data_get($address, 'is_default'))@endunless
+
+ @endforeach +
+ @else +
@include('storefront.components.icon', ['name' => 'pin', 'class' => 'size-6'])

No saved addresses

Add an address to make your next checkout quicker.

+ @endif + + @if ($showAddressForm ?? false) +
+ @endif + @if ($deleteAddressId ?? false)

Delete this address?

This saved address will be removed from your account.

@endif +
+
diff --git a/resources/views/storefront/account/forgot-password.blade.php b/resources/views/storefront/account/forgot-password.blade.php new file mode 100644 index 00000000..4737ceed --- /dev/null +++ b/resources/views/storefront/account/forgot-password.blade.php @@ -0,0 +1,11 @@ +@section('title', 'Reset password') +
+
+
+

Account recovery

Reset your password

Enter your account email. We’ll send a link if an account exists.

+ @if ($statusMessage)

{{ $statusMessage }}

@endif +
@error('email')

{{ $message }}

@enderror
+

Back to sign in

+
+
+
diff --git a/resources/views/storefront/account/index.blade.php b/resources/views/storefront/account/index.blade.php new file mode 100644 index 00000000..ca8d77d9 --- /dev/null +++ b/resources/views/storefront/account/index.blade.php @@ -0,0 +1,24 @@ + +@php($recentOrders = method_exists($orders, 'take') ? $orders->take(5) : array_slice(is_array($orders) ? $orders : [], 0, 5)) +@section('title', 'My account') +
+
+

Your account

Welcome back, {{ data_get($customer, 'first_name', data_get($customer, 'name', 'there')) }}

+
+ @include('storefront.components.icon', ['name' => 'package', 'class' => 'size-5'])

Order history

View all your orders

+ @include('storefront.components.icon', ['name' => 'pin', 'class' => 'size-5'])

Addresses

Manage your saved addresses

+
@csrf@include('storefront.components.icon', ['name' => 'user', 'class' => 'size-5'])

Log out

Sign out of this device

+
+ + + @if (count($recentOrders)) +
+ + @foreach ($recentOrders as $order)@endforeach +
OrderStatusView
#{{ data_get($order, 'number', data_get($order, 'order_number', data_get($order, 'id'))) }}@include('storefront.components.badge', ['text' => \Illuminate\Support\Str::headline(data_get($order, 'status', 'pending')), 'variant' => strtolower(data_get($order, 'status', 'pending'))])View
+
+ @else +

No orders yet

Your order history will appear here.

Start shopping
+ @endif +
+
diff --git a/resources/views/storefront/account/login.blade.php b/resources/views/storefront/account/login.blade.php new file mode 100644 index 00000000..a8b46556 --- /dev/null +++ b/resources/views/storefront/account/login.blade.php @@ -0,0 +1,16 @@ + +@section('title', 'Log in') +
+
+
+

Welcome back

Log in to your account

See your orders, addresses, and saved details.

+ @if ($errors->has('credentials'))@endif +
+
@error('email')

{{ $message }}

@enderror
+
@error('password')

{{ $message }}

@enderror
+ +
+

Don’t have an account? Create one

+
+
+
diff --git a/resources/views/storefront/account/orders/index.blade.php b/resources/views/storefront/account/orders/index.blade.php new file mode 100644 index 00000000..5abeabfe --- /dev/null +++ b/resources/views/storefront/account/orders/index.blade.php @@ -0,0 +1,15 @@ + +@section('title', 'Order history') +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'My account', 'url' => '/account'], ['label' => 'Order history']]]) +

Your account

Order History

+ @if (count($orders)) + +
    @foreach ($orders as $order)
  • Order #{{ data_get($order, 'number', data_get($order, 'order_number', data_get($order, 'id'))) }}

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

    @include('storefront.components.badge', ['text' => \Illuminate\Support\Str::headline(data_get($order, 'status', 'pending')), 'variant' => strtolower(data_get($order, 'status', 'pending'))])
    @include('storefront.components.price', ['amount' => (int) data_get($order, 'total_amount', 0)])View order
  • @endforeach
+ @include('storefront.components.pagination', ['paginator' => $orders]) + @else +

No orders yet

When you place an order, you’ll find it here.

Browse the shop
+ @endif +
+
diff --git a/resources/views/storefront/account/orders/show.blade.php b/resources/views/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..cf0ac1e2 --- /dev/null +++ b/resources/views/storefront/account/orders/show.blade.php @@ -0,0 +1,43 @@ + +@php + $orderNumber = data_get($order, 'number', data_get($order, 'order_number', data_get($order, 'id', ''))); + $orderCurrency = data_get($currentStore ?? null, 'currency', 'EUR'); + $orderLines = data_get($order, 'lines', data_get($order, 'items', [])); + $orderSubtotal = (int) data_get($order, 'subtotal_amount', 0); + $orderDiscount = (int) data_get($order, 'discount_amount', 0); + $orderShipping = (int) data_get($order, 'shipping_amount', 0); + $orderTax = (int) data_get($order, 'tax_amount', 0); + $orderTotal = (int) data_get($order, 'total_amount', $orderSubtotal - $orderDiscount + $orderShipping + $orderTax); +@endphp + +@section('title', 'Order #'.$orderNumber) +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'My account', 'url' => '/account'], ['label' => 'Orders', 'url' => '/account/orders'], ['label' => '#'.$orderNumber]]]) +

Order details

Order #{{ $orderNumber }}

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

@include('storefront.components.badge', ['text' => \Illuminate\Support\Str::headline(data_get($order, 'status', 'pending')), 'variant' => strtolower(data_get($order, 'status', 'pending'))])@if(data_get($order, 'fulfillment_status'))@include('storefront.components.badge', ['text' => \Illuminate\Support\Str::headline(data_get($order, 'fulfillment_status')), 'variant' => 'fulfilled'])@endif
+ +
+
+

Items

    @foreach ($orderLines as $line) + @php($lineTitle = data_get($line, 'title', data_get($line, 'product.title', 'Item'))) + @php($lineImage = data_get($line, 'image_url', data_get($line, 'product.media.0.url'))) + @php($lineQuantity = (int) data_get($line, 'quantity', 1)) + @php($lineAmount = (int) data_get($line, 'line_total_amount', data_get($line, 'total_amount', data_get($line, 'price_amount', 0) * $lineQuantity))) +
  • @if ($lineImage){{ data_get($line, 'image_alt', $lineTitle) }}@else@include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-7 text-stone-400'])@endif

    {{ $lineTitle }} × {{ $lineQuantity }}

    {{ data_get($line, 'variant_title', '') }}

    @include('storefront.components.price', ['amount' => $lineAmount, 'currency' => $orderCurrency])
  • + @endforeach
+ +
+

Shipping address

{{ data_get($order, 'shipping_address.first_name') }} {{ data_get($order, 'shipping_address.last_name') }}
{{ data_get($order, 'shipping_address.address_line_1') }}
{{ data_get($order, 'shipping_address.city') }}, {{ data_get($order, 'shipping_address.postal_code') }}
{{ data_get($order, 'shipping_address.country') }}
+

Billing address

@if(data_get($order, 'billing_same_as_shipping', false))

Same as shipping

@else
{{ data_get($order, 'billing_address.first_name') }} {{ data_get($order, 'billing_address.last_name') }}
{{ data_get($order, 'billing_address.address_line_1') }}
{{ data_get($order, 'billing_address.city') }}, {{ data_get($order, 'billing_address.postal_code') }}
@endif
+

Payment

@if(data_get($order, 'payment_method') === 'credit_card')Card ending in {{ data_get($order, 'payment_last_four', '••••') }}@else{{ \Illuminate\Support\Str::headline(data_get($order, 'payment_method', 'Payment')) }}@endif

+
+ + @if (count(data_get($order, 'fulfillments', []))) +

Fulfillment

@foreach (data_get($order, 'fulfillments', []) as $fulfillment)

Shipped via {{ data_get($fulfillment, 'tracking_company', 'Carrier') }}@if(data_get($fulfillment, 'tracking_number')) · {{ data_get($fulfillment, 'tracking_number') }}@endif

@if(data_get($fulfillment, 'tracking_url'))Track shipment (opens in a new tab)@include('storefront.components.icon', ['name' => 'arrow-right', 'class' => 'size-4'])@endif @endforeach
+ @endif +
+ + +
+
+
diff --git a/resources/views/storefront/account/register.blade.php b/resources/views/storefront/account/register.blade.php new file mode 100644 index 00000000..ab81c861 --- /dev/null +++ b/resources/views/storefront/account/register.blade.php @@ -0,0 +1,18 @@ + +@section('title', 'Create an account') +
+
+
+

A little more convenient

Create an account

Keep your orders and addresses together.

+
+
@error('name')

{{ $message }}

@enderror
+
@error('email')

{{ $message }}

@enderror
+
@error('password')

{{ $message }}

@enderror
+
@error('password_confirmation')

{{ $message }}

@enderror
+ + +
+

Already have an account? Log in

+
+
+
diff --git a/resources/views/storefront/account/reset-password.blade.php b/resources/views/storefront/account/reset-password.blade.php new file mode 100644 index 00000000..7df78b13 --- /dev/null +++ b/resources/views/storefront/account/reset-password.blade.php @@ -0,0 +1,9 @@ +@section('title', 'Choose a new password') +
+
+
+

Account recovery

Choose a new password

Your reset link can only be used once.

+
@error('email')

{{ $message }}

@enderror
@error('password')

{{ $message }}

@enderror
+
+
+
diff --git a/resources/views/storefront/cart.blade.php b/resources/views/storefront/cart.blade.php new file mode 100644 index 00000000..d2e0ed64 --- /dev/null +++ b/resources/views/storefront/cart.blade.php @@ -0,0 +1,79 @@ + +@php + $cartLines = data_get($cart, 'lines', data_get($cart, 'items', [])); + $cartCurrency = data_get($currentStore ?? null, 'currency', 'EUR'); + $cartSubtotal = (int) data_get($cart, 'subtotal_amount', data_get($cart, 'subtotal', 0)); + $cartDiscount = (int) data_get($cart, 'discount_amount', 0); + $cartTotal = (int) data_get($cart, 'total_amount', $cartSubtotal - $cartDiscount); +@endphp + +@section('title', 'Your cart') +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'Home', 'url' => '/'], ['label' => 'Your Cart']]]) +

Review your picks

Your Cart

{{ count($cartLines) }} {{ \Illuminate\Support\Str::plural('item', count($cartLines)) }}
+ + @if (count($cartLines)) +
+
+ + +
    + @foreach ($cartLines as $line) + @php($lineId = data_get($line, 'id', $loop->index)) + @php($lineProduct = data_get($line, 'product', data_get($line, 'variant.product'))) + @php($lineTitle = data_get($line, 'title', data_get($lineProduct, 'title', 'Item'))) + @php($lineImage = data_get($line, 'image_url', data_get($lineProduct, 'media.0.url', data_get($lineProduct, 'image_url')))) + @php($lineQuantity = (int) data_get($line, 'quantity', 1)) + @php($lineTotal = (int) data_get($line, 'line_total_amount', data_get($line, 'total_amount', data_get($line, 'price_amount', 0) * $lineQuantity))) +
  • @if ($lineImage){{ data_get($line, 'image_alt', $lineTitle) }}@else@include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-7 text-stone-400'])@endif
    {{ $lineTitle }}

    {{ data_get($line, 'variant_title', '') }}

    @include('storefront.components.quantity-selector', ['value' => $lineQuantity, 'min' => 1, 'wireModel' => 'quantities.'.$lineId, 'compact' => true, 'decreaseAction' => 'decreaseQuantity('.$lineId.')', 'increaseAction' => 'increaseQuantity('.$lineId.')'])@include('storefront.components.price', ['amount' => $lineTotal, 'currency' => $cartCurrency])
  • + @endforeach +
+
+ + +
+ @else +
+ @include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-8']) +

Your cart is empty

Your next favorite thing is waiting to be found.

Continue shopping +
+ @endif +
+
diff --git a/resources/views/storefront/checkout/confirmation.blade.php b/resources/views/storefront/checkout/confirmation.blade.php new file mode 100644 index 00000000..e0be32b7 --- /dev/null +++ b/resources/views/storefront/checkout/confirmation.blade.php @@ -0,0 +1,39 @@ + +@php + $orderNumber = data_get($order, 'number', data_get($order, 'order_number', data_get($order, 'id', ''))); + $orderCurrency = data_get($currentStore ?? null, 'currency', 'EUR'); + $orderLines = data_get($order, 'lines', data_get($order, 'items', [])); + $paymentMethod = data_get($order, 'payment_method', 'credit_card'); + $shippingAddress = data_get($order, 'shipping_address', []); + $orderSubtotal = (int) data_get($order, 'subtotal_amount', 0); + $orderShipping = (int) data_get($order, 'shipping_amount', 0); + $orderTax = (int) data_get($order, 'tax_amount', 0); + $orderTotal = (int) data_get($order, 'total_amount', $orderSubtotal + $orderShipping + $orderTax); +@endphp + +@section('title', 'Order confirmation · '.$orderNumber) +
+
+
@include('storefront.components.icon', ['name' => 'check', 'class' => 'size-8'])

Thank you for your order!

Order #{{ $orderNumber }}

We’ve sent a confirmation to {{ data_get($order, 'email', '') }}.

+ +

Order summary

    @foreach ($orderLines as $line) + @php($lineTitle = data_get($line, 'title', data_get($line, 'product.title', 'Item'))) + @php($lineImage = data_get($line, 'image_url', data_get($line, 'product.media.0.url'))) + @php($lineQuantity = (int) data_get($line, 'quantity', 1)) + @php($lineTotal = (int) data_get($line, 'line_total_amount', data_get($line, 'total_amount', data_get($line, 'price_amount', 0) * $lineQuantity))) +
  • @if ($lineImage){{ data_get($line, 'image_alt', $lineTitle) }}@else@include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-6 text-stone-400'])@endif

    {{ $lineTitle }} × {{ $lineQuantity }}

    {{ data_get($line, 'variant_title', '') }}

    @include('storefront.components.price', ['amount' => $lineTotal, 'currency' => $orderCurrency])
  • + @endforeach
+ +
+

Shipping address

{{ data_get($shippingAddress, 'first_name') }} {{ data_get($shippingAddress, 'last_name') }}
{{ data_get($shippingAddress, 'address_line_1') }}@if(data_get($shippingAddress, 'address_line_2'))
{{ data_get($shippingAddress, 'address_line_2') }}@endif
{{ data_get($shippingAddress, 'city') }}, {{ data_get($shippingAddress, 'state') }} {{ data_get($shippingAddress, 'postal_code') }}
{{ data_get($shippingAddress, 'country') }}
+

Payment method

@if ($paymentMethod === 'credit_card') Credit card ending in {{ data_get($order, 'payment_last_four', '••••') }} @elseif ($paymentMethod === 'bank_transfer') Bank transfer @else PayPal @endif

+
+ + @if ($paymentMethod === 'bank_transfer') +
@include('storefront.components.icon', ['name' => 'alert', 'class' => 'mt-0.5 size-5 shrink-0 text-blue-800 dark:text-blue-200'])

Bank transfer instructions

Please transfer the total amount to the following account 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
@include('storefront.components.price', ['amount' => $orderTotal, 'currency' => $orderCurrency])
Reference
#{{ $orderNumber }}
+ @endif + +

Order totals

Subtotal
@include('storefront.components.price', ['amount' => $orderSubtotal, 'currency' => $orderCurrency])
Shipping
@include('storefront.components.price', ['amount' => $orderShipping, 'currency' => $orderCurrency])
Tax
@include('storefront.components.price', ['amount' => $orderTax, 'currency' => $orderCurrency])
Total
@include('storefront.components.price', ['amount' => $orderTotal, 'currency' => $orderCurrency])
+
Continue shopping@if (auth('customer')->check())View order@endif
+
+
diff --git a/resources/views/storefront/checkout/index.blade.php b/resources/views/storefront/checkout/index.blade.php new file mode 100644 index 00000000..6c962332 --- /dev/null +++ b/resources/views/storefront/checkout/index.blade.php @@ -0,0 +1,97 @@ + +@php + $checkoutCurrency = data_get($currentStore ?? null, 'currency', 'EUR'); + $checkoutTotal = (int) data_get($checkout, 'total_amount', data_get($checkout, 'subtotal_amount', 0)); + $selectedPaymentMethod = data_get($checkout, 'payment_method', 'credit_card'); +@endphp + +@section('title', 'Checkout') +
+
+ @include('storefront.components.icon', ['name' => 'chevron-left', 'class' => 'size-4']) Back to cart +
+
+

Checkout

+
    + @foreach (['Contact', 'Address', 'Delivery', 'Payment'] as $index => $stepName)
  1. {{ $index + 1 }}. {{ $stepName }}@if ($index < 3)@endif
  2. @endforeach +
+ +
+
+

1Contact information

@if ($checkoutStep > 1)@endif
+ @if ($checkoutStep === 1) +
@error('email')

{{ $message }}

@enderror
Already have an account? Log in
+ @else +

{{ data_get($checkout, 'email', $email ?? '') }}

+ @endif +
+ +
+

2Shipping address

@if ($checkoutStep > 2)@endif
+ @if ($checkoutStep === 2) +
+ @if (!empty($addresses ?? []))
@endif + @include('storefront.components.address-form', ['address' => data_get($checkout, 'shipping_address', []), 'prefix' => 'shipping']) + + @if (!($billingSameAsShipping ?? true))

Billing address

@include('storefront.components.address-form', ['address' => data_get($checkout, 'billing_address', []), 'prefix' => 'billing'])
@endif + +
+ @elseif ($checkoutStep > 2) +
{{ data_get($checkout, 'shipping_address.first_name') }} {{ data_get($checkout, 'shipping_address.last_name') }}
{{ data_get($checkout, 'shipping_address.address_line_1') }}
{{ data_get($checkout, 'shipping_address.city') }}, {{ data_get($checkout, 'shipping_address.postal_code') }} · {{ data_get($checkout, 'shipping_address.country') }}
+ @else +

Complete contact information to continue.

+ @endif +
+ +
+

3Shipping method

@if ($checkoutStep > 3)@endif
+ @if ($checkoutStep === 3) +
Choose a shipping method@forelse ($shippingRates as $rate)@empty
@include('storefront.components.icon', ['name' => 'alert', 'class' => 'mt-0.5 size-5 shrink-0'])

No shipping methods are available for your address. Please verify your address or contact us.

@endforelse
@error('shippingRateId')

{{ $message }}

@enderror
+ @elseif ($checkoutStep > 3) +

{{ data_get($checkout, 'shipping_rate.name', 'Shipping method selected') }}

+ @else +

Complete your shipping address to see available methods.

+ @endif +
+ +
+

4Payment method

+ @if ($checkoutStep === 4) +
+
Select a payment method + @foreach (['credit_card' => 'Credit card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank transfer'] as $method => $label) + + @endforeach +
+
+ @if ($selectedPaymentMethod === 'credit_card') +
+
@error('cardNumber')

{{ $message }}

@enderror
+
+
+ @if ($paymentError ?? false)@endif + +
+ @elseif ($selectedPaymentMethod === 'paypal') +

Your PayPal payment will be processed securely. You’ll stay on this page while your payment is completed.

@if ($paymentError ?? false)@endif
+ @else +
After placing your order, you’ll receive bank transfer instructions. Your order will be held for 7 days while we await payment.
@if ($paymentError ?? false)@endif
+ @endif +
+
+ @else +

Complete the earlier steps to choose a payment method.

+ @endif +
+
+
+ +
+ +
+ @include('storefront.components.order-summary', ['checkout' => $checkout, 'showDiscountInput' => true]) +
+
+
+
+
diff --git a/resources/views/storefront/collection.blade.php b/resources/views/storefront/collection.blade.php new file mode 100644 index 00000000..a4a4b06d --- /dev/null +++ b/resources/views/storefront/collection.blade.php @@ -0,0 +1,73 @@ + +@php + $collectionTitle = data_get($collection, 'title', 'Collection'); + $collectionDescription = data_get($collection, 'description', ''); + $productsCount = method_exists($products, 'total') ? $products->total() : (is_countable($products) ? count($products) : 0); + $productTypes = data_get($collection, 'product_types') ?? []; + $vendors = data_get($collection, 'vendors') ?? []; +@endphp + +@section('title', $collectionTitle) +@section('meta_description', \Illuminate\Support\Str::limit(trim(strip_tags($collectionDescription)), 160)) + +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'Home', 'url' => '/'], ['label' => 'Collections', 'url' => '/collections'], ['label' => $collectionTitle]]]) +
+

{{ $collectionTitle }}

+ @if ($collectionDescription)
{!! $collectionDescription !!}
@endif +
+ +
+ + +
+ + +
+
+ + + +
+ + +
+ @if (!empty($activeFilters ?? []))
@foreach ($activeFilters as $filter){{ data_get($filter, 'label', $filter) }}@endforeach
@endif + @if ($productsCount > 0) +
+ @foreach ($products as $product) +
@include('storefront.components.product-card', ['product' => $product])
+ @endforeach +
+ @include('storefront.components.pagination', ['paginator' => $products]) + @else +
+ @include('storefront.components.icon', ['name' => 'search', 'class' => 'size-7']) +

No products found

Try adjusting your filters or browse the full collection.

+
+ @endif +
+
+
+
diff --git a/resources/views/storefront/collections.blade.php b/resources/views/storefront/collections.blade.php new file mode 100644 index 00000000..f5b313d4 --- /dev/null +++ b/resources/views/storefront/collections.blade.php @@ -0,0 +1,21 @@ + +@section('title', 'Collections') +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'Home', 'url' => '/'], ['label' => 'Collections']]]) +

Find your next favorite

Collections

Browse the latest collections from {{ data_get($currentStore ?? null, 'name', 'our store') }}.

+ @if ($collections->isNotEmpty()) + +
{{ $collections->links() }}
+ @else +

Collections are coming soon.

+ @endif +
+
diff --git a/resources/views/storefront/components/address-form.blade.php b/resources/views/storefront/components/address-form.blade.php new file mode 100644 index 00000000..99a5b249 --- /dev/null +++ b/resources/views/storefront/components/address-form.blade.php @@ -0,0 +1,40 @@ +@php + $address = $address ?? []; + $prefix = $prefix ?? ''; + $addressField = static fn (string $field): string => $prefix !== '' ? $prefix.'.'.$field : $field; + $addressId = static fn (string $field): string => str_replace(['.', '_'], '-', $addressField($field)); + $countries = $countries ?? ['DE' => 'Germany', 'AT' => 'Austria', 'CH' => 'Switzerland', 'FR' => 'France', 'GB' => 'United Kingdom', 'US' => 'United States']; + $fields = [ + ['first_name', 'First name', 'text', true, 'given-name', false], + ['last_name', 'Last name', 'text', true, 'family-name', false], + ['address_line_1', 'Address line 1', 'text', true, 'address-line1', true], + ['address_line_2', 'Address line 2', 'text', false, 'address-line2', true], + ['city', 'City', 'text', true, 'address-level2', false], + ['state', 'State / Province', 'text', true, 'address-level1', false], + ['postal_code', 'Postal code', 'text', true, 'postal-code', false], + ]; +@endphp +
+ @foreach ($fields as [$field, $label, $type, $required, $autocomplete, $fullWidth]) +
+ + + @error($addressField($field))

{{ $message }}

@enderror +
+ @endforeach +
+ + + @error($addressField('country'))

{{ $message }}

@enderror +
+
+ + + @error($addressField('phone'))

{{ $message }}

@enderror +
+
diff --git a/resources/views/storefront/components/badge.blade.php b/resources/views/storefront/components/badge.blade.php new file mode 100644 index 00000000..d432a510 --- /dev/null +++ b/resources/views/storefront/components/badge.blade.php @@ -0,0 +1,15 @@ +@php + $variant = $variant ?? 'default'; + $styles = [ + 'sale' => 'bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200', + 'sold-out' => 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-200', + 'new' => 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200', + 'pending' => 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200', + 'paid' => 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200', + 'fulfilled' => 'bg-blue-100 text-blue-900 dark:bg-blue-950 dark:text-blue-200', + 'cancelled' => 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-200', + 'refunded' => 'bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200', + 'default' => 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-200', + ]; +@endphp +{{ $text ?? 'Info' }} diff --git a/resources/views/storefront/components/breadcrumbs.blade.php b/resources/views/storefront/components/breadcrumbs.blade.php new file mode 100644 index 00000000..aa4d9cea --- /dev/null +++ b/resources/views/storefront/components/breadcrumbs.blade.php @@ -0,0 +1,21 @@ +@php($items = $items ?? []) + diff --git a/resources/views/storefront/components/cart-drawer.blade.php b/resources/views/storefront/components/cart-drawer.blade.php new file mode 100644 index 00000000..8bbfc73d --- /dev/null +++ b/resources/views/storefront/components/cart-drawer.blade.php @@ -0,0 +1,69 @@ +@php + $cartLines = data_get($cart ?? null, 'lines', data_get($cart ?? null, 'items', [])); + $currency = data_get($currentStore ?? null, 'currency', 'EUR'); + $cartSubtotal = (int) data_get($cart ?? null, 'subtotal_amount', data_get($cart ?? null, 'subtotal', 0)); + $cartDiscount = (int) data_get($cart ?? null, 'discount_amount', 0); + $cartTotal = (int) data_get($cart ?? null, 'total_amount', $cartSubtotal - $cartDiscount); +@endphp +
+ @if ($isOpen ?? false) + + + @endif +
diff --git a/resources/views/storefront/components/icon.blade.php b/resources/views/storefront/components/icon.blade.php new file mode 100644 index 00000000..0c57d92c --- /dev/null +++ b/resources/views/storefront/components/icon.blade.php @@ -0,0 +1,58 @@ +@php($class = $class ?? 'size-5') + diff --git a/resources/views/storefront/components/order-summary.blade.php b/resources/views/storefront/components/order-summary.blade.php new file mode 100644 index 00000000..6763715c --- /dev/null +++ b/resources/views/storefront/components/order-summary.blade.php @@ -0,0 +1,55 @@ +@php + $orderLines = data_get($checkout ?? null, 'cart.lines', data_get($checkout ?? null, 'lines', data_get($checkout ?? null, 'items', []))); + $currency = data_get($currentStore ?? null, 'currency', 'EUR'); + $subtotal = (int) data_get($checkout ?? null, 'subtotal_amount', data_get($checkout ?? null, 'subtotal', 0)); + $discount = (int) data_get($checkout ?? null, 'discount_amount', 0); + $shipping = data_get($checkout ?? null, 'shipping_amount'); + $tax = data_get($checkout ?? null, 'tax_amount'); + $total = (int) data_get($checkout ?? null, 'total_amount', $subtotal - $discount + (int) $shipping + (int) $tax); +@endphp +
+

Order summary

+
    + @forelse ($orderLines as $line) + @php + $lineProduct = data_get($line, 'product', data_get($line, 'variant.product')); + $lineTitle = data_get($line, 'title', data_get($lineProduct, 'title', 'Item')); + $lineImage = data_get($line, 'image_url', data_get($lineProduct, 'media.0.url', data_get($lineProduct, 'image_url'))); + $lineQuantity = (int) data_get($line, 'quantity', 1); + $lineAmount = (int) data_get($line, 'line_total_amount', data_get($line, 'total_amount', data_get($line, 'price_amount', 0) * $lineQuantity)); + @endphp +
  • +
    + @if ($lineImage){{ data_get($line, 'image_alt', $lineTitle) }}@else @include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-6'])@endif + {{ $lineQuantity }} +
    +
    +

    {{ $lineTitle }}

    + @if (data_get($line, 'variant_title') || data_get($line, 'options')) +

    {{ data_get($line, 'variant_title', is_array(data_get($line, 'options')) ? implode(' / ', data_get($line, 'options')) : '') }}

    + @endif +
    +
    @include('storefront.components.price', ['amount' => $lineAmount, 'currency' => $currency])
    +
  • + @empty +
  • Your order summary will appear here.
  • + @endforelse +
+ + @if ($showDiscountInput ?? true) +
+ + + +
+ @error('discountCode')

{{ $message }}

@enderror + @endif + +
+
Subtotal
@include('storefront.components.price', ['amount' => $subtotal, 'currency' => $currency])
+ @if ($discount > 0)
Discount
−@include('storefront.components.price', ['amount' => $discount, 'currency' => $currency])
@endif +
Shipping
@if ($shipping !== null) @include('storefront.components.price', ['amount' => (int) $shipping, 'currency' => $currency]) @else Calculated at next step @endif
+
Tax
@if ($tax !== null) @include('storefront.components.price', ['amount' => (int) $tax, 'currency' => $currency]) @else Calculated after address @endif
+
Total
@include('storefront.components.price', ['amount' => $total, 'currency' => $currency])
+
+
diff --git a/resources/views/storefront/components/pagination.blade.php b/resources/views/storefront/components/pagination.blade.php new file mode 100644 index 00000000..fc494b10 --- /dev/null +++ b/resources/views/storefront/components/pagination.blade.php @@ -0,0 +1,22 @@ +@if (isset($paginator) && method_exists($paginator, 'hasPages') && $paginator->hasPages()) + +@endif diff --git a/resources/views/storefront/components/price-filter.blade.php b/resources/views/storefront/components/price-filter.blade.php new file mode 100644 index 00000000..4dde1156 --- /dev/null +++ b/resources/views/storefront/components/price-filter.blade.php @@ -0,0 +1,15 @@ +@php + $filterIdPrefix = $idPrefix ?? 'storefront-price'; + $currencyCode = data_get($currentStore ?? null, 'currency', 'EUR'); + $currencySymbol = data_get($currentStore ?? null, 'currency_symbol', $currencyCode === 'EUR' ? '€' : $currencyCode); +@endphp +
+
+ +
+
+
+ +
+
+
diff --git a/resources/views/storefront/components/price.blade.php b/resources/views/storefront/components/price.blade.php new file mode 100644 index 00000000..4b39583a --- /dev/null +++ b/resources/views/storefront/components/price.blade.php @@ -0,0 +1,14 @@ +@php + $amount = (int) ($amount ?? 0); + $currency = $currency ?? data_get(isset($currentStore) ? $currentStore : null, 'currency', 'EUR'); + $compareAtAmount = isset($compareAtAmount) ? (int) $compareAtAmount : null; + $isSale = $compareAtAmount !== null && $compareAtAmount > $amount; + $formatAmount = static fn (int $value): string => number_format(abs($value) / 100, 2, '.', ','); +@endphp + + {{ $amount < 0 ? '-' : '' }}{{ $formatAmount($amount) }} {{ $currency }} + @if ($isSale) + {{ $formatAmount($compareAtAmount) }} {{ $currency }} + On sale + @endif + diff --git a/resources/views/storefront/components/product-card.blade.php b/resources/views/storefront/components/product-card.blade.php new file mode 100644 index 00000000..a0a19c48 --- /dev/null +++ b/resources/views/storefront/components/product-card.blade.php @@ -0,0 +1,59 @@ +@php + $productTitle = data_get($product, 'title', 'Product'); + $productHandle = data_get($product, 'handle', data_get($product, 'slug', '')); + $media = data_get($product, 'media', data_get($product, 'images', [])); + $primaryImage = data_get($media, '0.url', data_get($media, '0.src', data_get($product, 'image_url'))); + $secondaryImage = data_get($media, '1.url', data_get($media, '1.src')); + $priceAmount = (int) data_get($product, 'price_amount', data_get($product, 'price', 0)); + $compareAtAmount = data_get($product, 'compare_at_amount'); + $productCurrency = data_get($product, 'currency', data_get($currentStore ?? null, 'currency', 'EUR')); + $isSoldOut = (bool) data_get($product, 'sold_out', false); + $variants = data_get($product, 'variants', []); + $variantCount = is_countable($variants) ? count($variants) : 0; + $firstVariantId = data_get($variants, '0.id'); + $headingLevel = in_array($headingLevel ?? 'h3', ['h2', 'h3', 'h4'], true) ? ($headingLevel ?? 'h3') : 'h3'; +@endphp +
+
+ + @if ($primaryImage) + {{ data_get($media, '0.alt', $productTitle) }} + @if ($secondaryImage) + {{ data_get($media, '1.alt', $productTitle.' alternate view') }} + @endif + @else + + @include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-14']) + + @endif + +
+ @if ($compareAtAmount && $compareAtAmount > $priceAmount) + @include('storefront.components.badge', ['text' => 'Sale', 'variant' => 'sale']) + @endif + @if ($isSoldOut) + @include('storefront.components.badge', ['text' => 'Sold out', 'variant' => 'sold-out']) + @endif +
+
+
+ <{{ $headingLevel }} class="line-clamp-2 min-h-10 text-sm font-medium leading-5 text-stone-900 dark:text-stone-100"> + {{ $productTitle }} + +
+ @include('storefront.components.price', ['amount' => $priceAmount, 'currency' => $productCurrency, 'compareAtAmount' => $compareAtAmount]) +
+ @if (($showQuickAdd ?? true) && !$isSoldOut) +
+ @if ($variantCount > 1 || !$firstVariantId) + Choose options + @else + + @endif +
+ @endif +
+
diff --git a/resources/views/storefront/components/quantity-selector.blade.php b/resources/views/storefront/components/quantity-selector.blade.php new file mode 100644 index 00000000..08786136 --- /dev/null +++ b/resources/views/storefront/components/quantity-selector.blade.php @@ -0,0 +1,17 @@ +@php + $quantityValue = max((int) ($value ?? 1), (int) ($min ?? 1)); + $quantityMin = (int) ($min ?? 1); + $quantityMax = isset($max) ? (int) $max : null; + $isCompact = (bool) ($compact ?? false); + $buttonSize = $isCompact ? 'size-9' : 'size-11'; + $fieldSize = $isCompact ? 'h-9 w-11' : 'h-11 w-14'; +@endphp +
+ + + +
diff --git a/resources/views/storefront/components/search-modal.blade.php b/resources/views/storefront/components/search-modal.blade.php new file mode 100644 index 00000000..35ec9189 --- /dev/null +++ b/resources/views/storefront/components/search-modal.blade.php @@ -0,0 +1,42 @@ +
+@if ($isOpen ?? false) + +@endif +
diff --git a/resources/views/storefront/errors/404.blade.php b/resources/views/storefront/errors/404.blade.php new file mode 100644 index 00000000..11cfa180 --- /dev/null +++ b/resources/views/storefront/errors/404.blade.php @@ -0,0 +1,12 @@ +@extends('storefront.layouts.app') + +@section('title', 'Page not found') +@section('content') +
+ +

Page not found

+

The page you’re looking for doesn’t exist or may have moved.

+
+ Go to home page +
+@endsection diff --git a/resources/views/storefront/errors/503.blade.php b/resources/views/storefront/errors/503.blade.php new file mode 100644 index 00000000..311068bc --- /dev/null +++ b/resources/views/storefront/errors/503.blade.php @@ -0,0 +1,12 @@ +@extends('storefront.layouts.app') + +@section('title', 'We’ll be back soon') +@section('content') +
+ @php($storeName = data_get($currentStore ?? null, 'name', config('app.name', 'Store'))) + @if (data_get($currentStore ?? null, 'logo_url')){{ $storeName }}@else{{ $storeName }}@endif + @include('storefront.components.icon', ['name' => 'package', 'class' => 'size-7']) +

We’ll be back soon

+

We’re currently performing maintenance. Please check back shortly.

+
+@endsection diff --git a/resources/views/storefront/home.blade.php b/resources/views/storefront/home.blade.php new file mode 100644 index 00000000..2c3bf9b8 --- /dev/null +++ b/resources/views/storefront/home.blade.php @@ -0,0 +1,108 @@ + +@php + $homeSettings = data_get($settings ?? [], 'home', []); + $sectionOrder = data_get($settings ?? [], 'home_sections', ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text']); + $sectionOrder = is_array($sectionOrder) && count($sectionOrder) ? $sectionOrder : ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text']; + $collectionCount = max(2, min(4, (int) data_get($homeSettings, 'featured_collections.count', 4))); + $featuredProductCount = max(4, min(8, (int) data_get($homeSettings, 'featured_products.count', 8))); + $hasCollections = is_countable($collections ?? null) && count($collections) > 0; + $homeTitle = data_get($homeSettings, 'hero.heading', 'Make room for what matters.'); + $homeSubheading = data_get($homeSettings, 'hero.subheading', 'Considered essentials, made to be worn and loved every day.'); + $heroImage = data_get($homeSettings, 'hero.image_url'); + $heroCta = data_get($homeSettings, 'hero.cta_text', 'Explore the collection'); + $heroUrl = data_get($homeSettings, 'hero.cta_url', '/collections'); +@endphp + +@section('meta_description', data_get($settings ?? [], 'seo.home_description', $homeSubheading)) + +
+@foreach ($sectionOrder as $section) + @if (is_array($section) && (data_get($section, 'visible') === false || data_get($section, 'enabled') === false)) + @continue + @endif + @php($sectionName = str_replace('-', '_', is_array($section) ? data_get($section, 'type', '') : $section)) + @switch($sectionName) + @case('hero') + @if (data_get($homeSettings, 'hero.enabled', true)) +
+ @if ($heroImage)@endif +
+
+ @if (data_get($homeSettings, 'hero.eyebrow'))

{{ data_get($homeSettings, 'hero.eyebrow') }}

@endif +

{{ $homeTitle }}

+

{{ $homeSubheading }}

+ + {{ $heroCta }} @include('storefront.components.icon', ['name' => 'arrow-right', 'class' => 'size-4']) + +
+
+ @endif + @break + @case('featured_collections') + @if (data_get($homeSettings, 'featured_collections.enabled', true) && $hasCollections) +
+
+

Find your next favorite

+ +
+
+ @foreach (collect($collections)->take($collectionCount) as $collection) + @php($collectionTitle = data_get($collection, 'title', 'Collection')) + @php($collectionImage = data_get($collection, 'image_url', data_get($collection, 'image.src'))) + + @if ($collectionImage){{ data_get($collection, 'image_alt', $collectionTitle) }}@else@include('storefront.components.icon', ['name' => 'bag', 'class' => 'size-12'])@endif + + {{ $collectionTitle }}Shop now @include('storefront.components.icon', ['name' => 'arrow-right', 'class' => 'size-3.5']) + + @endforeach +
+ View all collections @include('storefront.components.icon', ['name' => 'arrow-right', 'class' => 'size-4']) +
+ @endif + @break + @case('featured_products') + @if (data_get($homeSettings, 'featured_products.enabled', true)) +
+
+
+

Made for everyday

+ +
+ @if (is_countable($featuredProducts ?? null) && count($featuredProducts) > 0) +
+ @foreach (collect($featuredProducts)->take($featuredProductCount) as $product) + @include('storefront.components.product-card', ['product' => $product, 'headingLevel' => 'h3']) + @endforeach +
+ @elseif (!isset($featuredProducts)) +
+ @for ($skeleton = 0; $skeleton < 4; $skeleton++)@endfor +
+ @else +

New favorites are on their way.

+ @endif +
+
+ @endif + @break + @case('newsletter') + @if (data_get($homeSettings, 'newsletter.enabled', true)) + @if (class_exists(\App\Livewire\Storefront\NewsletterSignup::class)) + @livewire('storefront.newsletter-signup') + @else +
+

{{ data_get($homeSettings, 'newsletter.heading', 'Stay in the loop') }}

{{ data_get($homeSettings, 'newsletter.subheading', 'Subscribe for thoughtful notes, new arrivals, and occasional offers.') }}

+

@error('email'){{ $message }}@enderror

{{ session('newsletter-success') }}

+
+
+ @endif + @endif + @break + @case('rich_text') + @if (data_get($homeSettings, 'rich_text.enabled', false) && data_get($homeSettings, 'rich_text.html')) +
{!! $safeRichText !!}
+ @endif + @break + @endswitch + @endforeach +
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php new file mode 100644 index 00000000..ef2d2bc5 --- /dev/null +++ b/resources/views/storefront/layouts/app.blade.php @@ -0,0 +1,141 @@ +@php + $store = isset($currentStore) ? $currentStore : null; + $themeSettings = $settings ?? data_get($store, 'theme_settings', []); + $storeName = data_get($store, 'name', config('app.name', 'Store')); + $darkMode = data_get($themeSettings, 'dark_mode', 'system'); + $logoUrl = data_get($themeSettings, 'logo_url', data_get($store, 'logo_url')); + $announcementEnabled = (bool) data_get($themeSettings, 'announcement.enabled', false); + $announcementText = data_get($themeSettings, 'announcement.text', 'Free shipping on orders over 75 EUR'); + $announcementUrl = data_get($themeSettings, 'announcement.url'); +@endphp + + $darkMode === 'dark'])> + + + + + + + @hasSection('title')@yield('title') · @endif{{ $storeName }} + @yield('social_meta') + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + + @yield('structured_data') + + + Skip to main content + + @if ($announcementEnabled) +
+
+

+ @if ($announcementUrl) + {{ $announcementText }} + @else + {{ $announcementText }} + @endif +

+ +
+
+ @endif + +
+
+ @livewire('storefront.navigation.menu', ['handle' => 'main-menu', 'presentation' => 'mobile']) + + + @if ($logoUrl) + {{ $storeName }} + @else + {{ $storeName }} + @endif + + + @livewire('storefront.navigation.menu', ['handle' => 'main-menu', 'presentation' => 'desktop']) + +
+ + + @livewire('storefront.cart-count') + +
+
+
+ +
+ @if (session('status') || session('success') || session('error')) +
+ @foreach (['status' => 'success', 'success' => 'success', 'error' => 'error'] as $flashKey => $flashType) + @if (session($flashKey)) +
{{ session($flashKey) }}
+ @endif + @endforeach +
+ @endif + @if (isset($slot)) + {{ $slot }} + @else + @yield('content') + @endif +
+ +
+ @if (class_exists(\App\Livewire\Storefront\NewsletterSignup::class)) + @livewire('storefront.newsletter-signup') + @endif +
+
+ @livewire('storefront.navigation.menu', ['handle' => 'footer-menu', 'presentation' => 'footer']) +
+

{{ $storeName }}

+ @if (data_get($store, 'address')) +

{{ data_get($store, 'address') }}

+ @endif + @if (data_get($store, 'email')) + {{ data_get($store, 'email') }} + @endif +
+ @foreach (['facebook' => 'Facebook', 'instagram' => 'Instagram', 'twitter' => 'X', 'tiktok' => 'TikTok', 'youtube' => 'YouTube'] as $socialKey => $socialLabel) + @if (data_get($themeSettings, 'social.'.$socialKey)) + {{ $socialLabel }} + @endif + @endforeach +
+
+
+
+

© {{ now()->year }} {{ $storeName }}. All rights reserved.

+
+ @foreach (data_get($themeSettings, 'payment_methods', ['Visa', 'Mastercard', 'PayPal']) as $paymentMethod) + {{ data_get($paymentMethod, 'label', $paymentMethod) }} + @endforeach +
+
+
+
+ + @livewire('storefront.cart-drawer') + @livewire('storefront.search.modal') + @livewireScripts + @stack('scripts') + + diff --git a/resources/views/storefront/page.blade.php b/resources/views/storefront/page.blade.php new file mode 100644 index 00000000..b1ec0c5d --- /dev/null +++ b/resources/views/storefront/page.blade.php @@ -0,0 +1,14 @@ + +@php + $pageTitle = data_get($page, 'title', 'Page'); + $pageDescription = \Illuminate\Support\Str::limit(trim(strip_tags($safeHtml)), 160); +@endphp + +@section('title', $pageTitle) +@section('meta_description', $pageDescription) +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'Home', 'url' => '/'], ['label' => $pageTitle]]]) +

{{ $pageTitle }}

{!! $safeHtml !!}
+
+
diff --git a/resources/views/storefront/product.blade.php b/resources/views/storefront/product.blade.php new file mode 100644 index 00000000..b95ac697 --- /dev/null +++ b/resources/views/storefront/product.blade.php @@ -0,0 +1,131 @@ + +@php + $productTitle = data_get($product, 'title', 'Product'); + $productDescription = data_get($product, 'description_html', data_get($product, 'description', '')); + $productMedia = data_get($product, 'media', data_get($product, 'images', [])); + $productPrice = (int) data_get($product, 'price_amount', data_get($product, 'price', 0)); + $productComparePrice = data_get($product, 'compare_at_amount'); + $productCurrency = data_get($product, 'currency', data_get($currentStore ?? null, 'currency', 'EUR')); + $productVariants = data_get($product, 'variants', []); + $productOptions = data_get($product, 'options', []); + $productImage = data_get($productMedia, '0.url', data_get($productMedia, '0.src')); + $productImageUrl = $productImage ? url($productImage) : null; + $selectedImageIndex = isset($selectedImageIndex) ? (int) $selectedImageIndex : 0; + $activeImage = data_get($productMedia, $selectedImageIndex.'.url', data_get($productMedia, $selectedImageIndex.'.src', $productImage)); + $activeImageAlt = data_get($productMedia, $selectedImageIndex.'.alt', $productTitle); + $productBreadcrumbs = [['label' => 'Home', 'url' => '/']]; + if (data_get($product, 'collection.title')) { + $productBreadcrumbs[] = ['label' => data_get($product, 'collection.title'), 'url' => url('/collections/'.data_get($product, 'collection.handle'))]; + } + $productBreadcrumbs[] = ['label' => $productTitle]; +@endphp + +@section('title', $productTitle) +@section('meta_description', \Illuminate\Support\Str::limit(trim(strip_tags($productDescription)), 160)) +@section('social_meta') + +@endsection +@section('structured_data') + +@endsection + +
+
+ @include('storefront.components.breadcrumbs', ['items' => $productBreadcrumbs]) +
+
+ + @if (count($productMedia) > 1) +
+ @foreach ($productMedia as $index => $media) +
{{ data_get($media, 'alt', $productTitle.' image '.($index + 1)) }}
+ @endforeach +
+
+ @foreach ($productMedia as $index => $media)@endforeach +
+ +

Image {{ $selectedImageIndex + 1 }} of {{ count($productMedia) }}

+ @elseif ($productImage) +
{{ data_get($productMedia, '0.alt', $productTitle) }}
+ @endif +
+ +
+ @if (data_get($product, 'vendor'))

{{ data_get($product, 'vendor') }}

@endif +

{{ $productTitle }}

+
+ @include('storefront.components.price', ['amount' => $productPrice, 'currency' => $productCurrency, 'compareAtAmount' => $productComparePrice, 'live' => true]) + @if ($productComparePrice && $productComparePrice > $productPrice)@include('storefront.components.badge', ['text' => 'Sale', 'variant' => 'sale'])@endif +
+ + @if (count($productOptions)) +
+ @foreach ($productOptions as $optionIndex => $option) + @php + $optionName = data_get($option, 'name', 'Option'); + $optionValues = data_get($option, 'values', []); + @endphp +
+ {{ $optionName }} {{ data_get($selectedOptions ?? [], $optionName) }} + @if (strtolower($optionName) === 'color') +
+ @foreach ($optionValues as $value) + @php + $colorValue = strtolower((string) $value); + $swatch = ['black' => '#111111', 'white' => '#ffffff', 'navy' => '#1e3a5f', 'blue' => '#2563eb', 'red' => '#dc2626', 'green' => '#15803d', 'beige' => '#d6c2a1'][$colorValue] ?? '#9ca3af'; + @endphp + + @endforeach +
+ @elseif (count($optionValues) <= 6) +
+ @foreach ($optionValues as $value) + + @endforeach +
+ @else + + @endif +
+ @endforeach +
+ @endif + + @php + $inventory = data_get($selectedVariant ?? null, 'inventory_quantity', data_get($product, 'inventory_quantity', null)); + $inventoryPolicy = data_get($selectedVariant ?? null, 'inventory_policy', data_get($product, 'inventory_policy', 'deny')); + $stockLabel = $inventory === null || $inventory > 10 ? 'In stock' : ($inventory > 0 ? 'Only '.$inventory.' left in stock' : ($inventoryPolicy === 'continue' ? 'Available on backorder' : 'Out of stock')); + $isAvailable = ($inventory === null || $inventory > 0 || $inventoryPolicy === 'continue') && !data_get($product, 'sold_out', false); + @endphp +

+ @include('storefront.components.icon', ['name' => $isAvailable ? 'check' : 'alert', 'class' => 'size-4']) {{ $stockLabel }} +

+ +
+ @include('storefront.components.quantity-selector', ['value' => $quantity ?? 1, 'min' => 1, 'max' => $inventoryPolicy === 'continue' ? null : $inventory, 'wireModel' => 'quantity', 'decreaseAction' => 'decreaseQuantity', 'increaseAction' => 'increaseQuantity']) + Quantity +
+ + @error('variant')

{{ $message }}

@enderror + + @if ($productDescription) +
{!! $productDescription !!}
+ @endif + @if (count(data_get($product, 'tags', []))) +
    @foreach (data_get($product, 'tags', []) as $tag)
  • {{ data_get($tag, 'name', $tag) }}
  • @endforeach
+ @endif +
+
+
+
diff --git a/resources/views/storefront/search.blade.php b/resources/views/storefront/search.blade.php new file mode 100644 index 00000000..dfcacb30 --- /dev/null +++ b/resources/views/storefront/search.blade.php @@ -0,0 +1,33 @@ + +@php + $searchCount = method_exists($products, 'total') ? $products->total() : (is_countable($products) ? count($products) : 0); + $searchQuery = $query ?? request('q', ''); +@endphp + +@section('title', 'Search results') +@section('meta_description', 'Search products and collections at '.data_get($currentStore ?? null, 'name', config('app.name')).'.') +
+
+ @include('storefront.components.breadcrumbs', ['items' => [['label' => 'Home', 'url' => '/'], ['label' => 'Search results']]]) +

Search the store

{{ $searchCount ? $searchCount.' results for “'.$searchQuery.'”' : 'Search results for “'.$searchQuery.'”' }}

+
+
+
+
{{ $searchCount }} {{ \Illuminate\Support\Str::plural('product', $searchCount) }}
+
+ +
+ @if ($searchCount) +
+ @foreach ($products as $product)
@include('storefront.components.product-card', ['product' => $product])
@endforeach +
+ @include('storefront.components.pagination', ['paginator' => $products]) + @else +
@include('storefront.components.icon', ['name' => 'search', 'class' => 'size-7'])

No results found for “{{ $searchQuery }}”

Try a different search term, or browse our collections.

Browse collections
+ @endif +
+
+ +
+
+
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..d4022d51 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,96 @@ +middleware(['store.resolve', 'throttle:api.storefront'])->name('api.storefront.')->group(function (): void { + Route::post('/carts', [StorefrontController::class, 'createCart'])->name('carts.create'); + Route::get('/carts/{cartId}', [StorefrontController::class, 'showCart'])->whereNumber('cartId')->name('carts.show'); + Route::post('/carts/{cartId}/lines', [StorefrontController::class, 'addCartLine'])->whereNumber('cartId')->name('carts.lines.add'); + Route::put('/carts/{cartId}/lines/{lineId}', [StorefrontController::class, 'updateCartLine'])->whereNumber('cartId')->whereNumber('lineId')->name('carts.lines.update'); + Route::delete('/carts/{cartId}/lines/{lineId}', [StorefrontController::class, 'deleteCartLine'])->whereNumber('cartId')->whereNumber('lineId')->name('carts.lines.delete'); + Route::post('/checkouts', [StorefrontController::class, 'createCheckout'])->middleware('throttle:checkout')->name('checkouts.create'); + Route::get('/checkouts/{checkoutId}', [StorefrontController::class, 'showCheckout'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.show'); + Route::put('/checkouts/{checkoutId}/address', [StorefrontController::class, 'updateAddress'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.address'); + Route::put('/checkouts/{checkoutId}/shipping-method', [StorefrontController::class, 'updateShipping'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.shipping'); + Route::put('/checkouts/{checkoutId}/payment-method', [StorefrontController::class, 'updatePayment'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.payment'); + Route::post('/checkouts/{checkoutId}/apply-discount', [StorefrontController::class, 'applyDiscount'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.discount.apply'); + Route::delete('/checkouts/{checkoutId}/discount', [StorefrontController::class, 'removeDiscount'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.discount.remove'); + Route::post('/checkouts/{checkoutId}/pay', [StorefrontController::class, 'pay'])->whereNumber('checkoutId')->middleware('throttle:checkout')->name('checkouts.pay'); + Route::get('/orders/{orderNumber}', [StorefrontController::class, 'showOrder'])->name('orders.show'); + Route::get('/search', [StorefrontController::class, 'search'])->middleware('throttle:search')->name('search'); + Route::get('/search/suggest', [StorefrontController::class, 'suggest'])->middleware('throttle:search')->name('search.suggest'); + Route::post('/analytics/events', [StorefrontController::class, 'recordAnalytics'])->middleware('throttle:analytics')->name('analytics.events'); +}); + +Route::prefix('admin/v1')->middleware(['api.token', 'store.resolve', 'throttle:api.admin'])->name('api.admin.')->group(function (): void { + Route::get('/stores/{storeId}/me', [AdminController::class, 'me'])->whereNumber('storeId')->middleware('api.token')->name('stores.me'); + Route::get('/stores/{storeId}/products', [AdminController::class, 'products'])->whereNumber('storeId')->middleware('api.token:read-products')->name('products.index'); + Route::post('/stores/{storeId}/products', [AdminController::class, 'createProduct'])->whereNumber('storeId')->middleware('api.token:write-products')->name('products.store'); + Route::get('/stores/{storeId}/products/{productId}', [AdminController::class, 'product'])->whereNumber('storeId')->whereNumber('productId')->middleware('api.token:read-products')->name('products.show'); + Route::put('/stores/{storeId}/products/{productId}', [AdminController::class, 'updateProduct'])->whereNumber('storeId')->whereNumber('productId')->middleware('api.token:write-products')->name('products.update'); + Route::delete('/stores/{storeId}/products/{productId}', [AdminController::class, 'deleteProduct'])->whereNumber('storeId')->whereNumber('productId')->middleware('api.token:write-products')->name('products.destroy'); + Route::post('/stores/{storeId}/products/{productId}/media/presign-upload', [ProductMediaUploadController::class, 'presign'])->whereNumber('storeId')->whereNumber('productId')->middleware('api.token:write-products')->name('products.media.presign'); + Route::get('/stores/{storeId}/collections', [AdminController::class, 'collections'])->whereNumber('storeId')->middleware('api.token:read-collections')->name('collections.index'); + Route::post('/stores/{storeId}/collections', [AdminController::class, 'createCollection'])->whereNumber('storeId')->middleware('api.token:write-collections')->name('collections.store'); + Route::put('/stores/{storeId}/collections/{collectionId}', [AdminController::class, 'updateCollection'])->whereNumber('storeId')->whereNumber('collectionId')->middleware('api.token:write-collections')->name('collections.update'); + Route::delete('/stores/{storeId}/collections/{collectionId}', [AdminController::class, 'deleteCollection'])->whereNumber('storeId')->whereNumber('collectionId')->middleware('api.token:write-collections')->name('collections.destroy'); + Route::get('/stores/{storeId}/orders', [AdminController::class, 'orders'])->whereNumber('storeId')->middleware('api.token:read-orders')->name('orders.index'); + Route::get('/stores/{storeId}/orders/{orderId}', [AdminController::class, 'order'])->whereNumber('storeId')->whereNumber('orderId')->middleware('api.token:read-orders')->name('orders.show'); + Route::post('/stores/{storeId}/orders/{orderId}/fulfillments', [AdminController::class, 'fulfill'])->whereNumber('storeId')->whereNumber('orderId')->middleware('api.token:write-orders')->name('orders.fulfillments.store'); + Route::post('/stores/{storeId}/orders/{orderId}/refunds', [AdminController::class, 'refund'])->whereNumber('storeId')->whereNumber('orderId')->middleware('api.token:write-orders')->name('orders.refunds.store'); + Route::get('/stores/{storeId}/discounts', [AdminController::class, 'discounts'])->whereNumber('storeId')->middleware('api.token:read-discounts')->name('discounts.index'); + Route::post('/stores/{storeId}/discounts', [AdminController::class, 'createDiscount'])->whereNumber('storeId')->middleware('api.token:write-discounts')->name('discounts.store'); + Route::put('/stores/{storeId}/discounts/{discountId}', [AdminController::class, 'updateDiscount'])->whereNumber('storeId')->whereNumber('discountId')->middleware('api.token:write-discounts')->name('discounts.update'); + Route::delete('/stores/{storeId}/discounts/{discountId}', [AdminController::class, 'deleteDiscount'])->whereNumber('storeId')->whereNumber('discountId')->middleware('api.token:write-discounts')->name('discounts.destroy'); + Route::get('/stores/{storeId}/shipping/zones', [StoreConfigurationController::class, 'shippingZones'])->whereNumber('storeId')->middleware('api.token:read-settings')->name('shipping.zones.index'); + Route::post('/stores/{storeId}/shipping/zones', [StoreConfigurationController::class, 'createShippingZone'])->whereNumber('storeId')->middleware('api.token:write-settings')->name('shipping.zones.store'); + Route::put('/stores/{storeId}/shipping/zones/{zoneId}', [StoreConfigurationController::class, 'updateShippingZone'])->whereNumber('storeId')->whereNumber('zoneId')->middleware('api.token:write-settings')->name('shipping.zones.update'); + Route::post('/stores/{storeId}/shipping/zones/{zoneId}/rates', [StoreConfigurationController::class, 'createShippingRate'])->whereNumber('storeId')->whereNumber('zoneId')->middleware('api.token:write-settings')->name('shipping.rates.store'); + Route::get('/stores/{storeId}/tax/settings', [AdminController::class, 'taxSettings'])->whereNumber('storeId')->middleware('api.token:read-settings')->name('tax.settings'); + Route::put('/stores/{storeId}/tax/settings', [AdminController::class, 'updateTaxSettings'])->whereNumber('storeId')->middleware('api.token:write-settings')->name('tax.settings.update'); + Route::post('/stores/{storeId}/themes', [StoreConfigurationController::class, 'createTheme'])->whereNumber('storeId')->middleware('api.token:write-themes')->name('themes.store'); + Route::post('/stores/{storeId}/themes/{themeId}/publish', [StoreConfigurationController::class, 'publishTheme'])->whereNumber('storeId')->whereNumber('themeId')->middleware('api.token:write-themes')->name('themes.publish'); + Route::put('/stores/{storeId}/themes/{themeId}/settings', [StoreConfigurationController::class, 'updateThemeSettings'])->whereNumber('storeId')->whereNumber('themeId')->middleware('api.token:write-themes')->name('themes.settings.update'); + Route::get('/stores/{storeId}/pages', [StoreConfigurationController::class, 'pages'])->whereNumber('storeId')->middleware('api.token:read-content')->name('pages.index'); + Route::post('/stores/{storeId}/pages', [StoreConfigurationController::class, 'createPage'])->whereNumber('storeId')->middleware('api.token:write-content')->name('pages.store'); + Route::put('/stores/{storeId}/pages/{pageId}', [StoreConfigurationController::class, 'updatePage'])->whereNumber('storeId')->whereNumber('pageId')->middleware('api.token:write-content')->name('pages.update'); + Route::delete('/stores/{storeId}/pages/{pageId}', [StoreConfigurationController::class, 'deletePage'])->whereNumber('storeId')->whereNumber('pageId')->middleware('api.token:write-content')->name('pages.destroy'); + Route::post('/stores/{storeId}/search/reindex', [AdminController::class, 'reindexSearch'])->whereNumber('storeId')->middleware('api.token:write-settings')->name('search.reindex'); + Route::get('/stores/{storeId}/search/status', [AdminController::class, 'searchStatus'])->whereNumber('storeId')->middleware('api.token:read-settings')->name('search.status'); + Route::get('/stores/{storeId}/analytics/summary', [AdminController::class, 'analyticsSummary'])->whereNumber('storeId')->middleware('api.token:read-analytics')->name('analytics.summary'); + Route::post('/stores/{storeId}/exports/orders', [OrderExportController::class, 'store'])->whereNumber('storeId')->middleware('api.token:read-orders')->name('exports.orders.store'); + Route::get('/stores/{storeId}/exports/{exportId}', [OrderExportController::class, 'show'])->whereNumber('storeId')->whereNumber('exportId')->middleware('api.token:read-orders')->name('exports.show'); +}); + +Route::get('/admin/v1/stores/{storeId}/exports/{exportId}/download', [OrderExportController::class, 'download']) + ->whereNumber('storeId') + ->whereNumber('exportId') + ->middleware('signed') + ->name('api.admin.exports.download'); + +Route::prefix('admin/v1/platform')->middleware(['api.token:manage-platform', 'throttle:api.admin'])->name('api.admin.platform.')->group(function (): void { + Route::post('/organizations', [PlatformController::class, 'createOrganization'])->name('organizations.store'); + Route::post('/stores', [PlatformController::class, 'createStore'])->name('stores.store'); +}); + +Route::post('/admin/v1/stores/{storeId}/invites', [PlatformController::class, 'createInvitation']) + ->whereNumber('storeId') + ->middleware(['api.token:manage-platform', 'throttle:api.admin']) + ->name('api.admin.stores.invites.store'); + +Route::put('/uploads/product-media/{mediaId}', [ProductMediaUploadController::class, 'upload']) + ->whereNumber('mediaId') + ->middleware('signed') + ->name('api.product-media.upload'); + +Route::prefix('apps/v1/stores/{storeId}')->whereNumber('storeId')->name('api.apps.')->group(function (): void { + Route::get('/products', fn () => response()->json(['message' => 'The app ecosystem is not implemented.'], 501))->name('products.index'); + Route::get('/orders', fn () => response()->json(['message' => 'The app ecosystem is not implemented.'], 501))->name('orders.index'); + Route::get('/customers', fn () => response()->json(['message' => 'The app ecosystem is not implemented.'], 501))->name('customers.index'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..d865c928 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,30 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new AggregateAnalytics) + ->dailyAt('01:00') + ->timezone('UTC') + ->withoutOverlapping(); + +Schedule::job(new CleanupAbandonedCarts) + ->daily() + ->withoutOverlapping(); + +Schedule::job(new ExpireAbandonedCheckouts) + ->everyFifteenMinutes() + ->withoutOverlapping(); + +Schedule::job(new CancelUnpaidBankTransferOrders) + ->daily() + ->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index f755f111..43279656 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,144 @@ name('home'); +Route::middleware('throttle:login')->group(function (): void { + Route::get('/admin/login', AdminLogin::class)->name('admin.login'); + Route::get('/admin/forgot-password', AdminForgotPassword::class)->name('admin.password.request'); + Route::get('/admin/reset-password/{token}', AdminResetPassword::class)->name('admin.password.reset'); + Route::get('/account/login', CustomerLogin::class)->middleware('store.resolve')->name('storefront.login'); + Route::get('/account/register', CustomerRegister::class)->middleware('store.resolve')->name('storefront.register'); + Route::get('/forgot-password', CustomerForgotPassword::class)->middleware('store.resolve')->name('storefront.password.request'); + Route::get('/reset-password/{token}', CustomerResetPassword::class)->middleware('store.resolve')->name('storefront.password.reset'); +}); -Route::view('dashboard', 'dashboard') - ->middleware(['auth', 'verified']) - ->name('dashboard'); +Route::post('/admin/logout', function (\Illuminate\Http\Request $request) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login')->withHeaders(['Cache-Control' => 'no-store']); +})->middleware('auth')->name('admin.logout'); + +Route::get('/admin/select-store', SelectStore::class) + ->middleware(['auth', 'verified', 'can:view-admin']) + ->name('admin.select-store'); + +Route::post('/account/logout', function (\Illuminate\Http\Request $request) { + Auth::guard('customer')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('storefront.login')->withHeaders(['Cache-Control' => 'no-store']); +})->middleware('auth:customer')->name('storefront.logout'); + +Route::get('/oauth/authorize', fn () => response()->json(['message' => 'OAuth is not implemented.'], 501))->name('oauth.authorize'); +Route::post('/oauth/token', fn () => response()->json(['message' => 'OAuth is not implemented.'], 501))->name('oauth.token'); + +Route::view('/dashboard', 'dashboard')->middleware(['auth', 'verified'])->name('dashboard'); + +Route::middleware(['auth', 'verified', 'store.resolve', 'can:view-admin'])->prefix('admin')->name('admin.')->group(function (): void { + Route::get('/', AdminDashboard::class)->name('dashboard'); + Route::get('/products', ProductIndex::class)->name('products'); + Route::get('/products/create', ProductForm::class)->name('products.create'); + Route::get('/products/{product}/edit', ProductForm::class)->whereNumber('product')->name('products.edit'); + Route::get('/inventory', InventoryIndex::class)->name('inventory.index'); + Route::get('/collections', CollectionIndex::class)->name('collections'); + Route::get('/collections/create', CollectionForm::class)->name('collections.create'); + Route::get('/collections/{collection}/edit', CollectionForm::class)->whereNumber('collection')->name('collections.edit'); + Route::get('/orders', OrderIndex::class)->name('orders'); + Route::get('/orders/{order}', OrderShow::class)->whereNumber('order')->name('orders.show'); + Route::get('/customers', CustomerIndex::class)->name('customers'); + Route::get('/customers/{customer}', CustomerShow::class)->whereNumber('customer')->name('customers.show'); + Route::get('/discounts', DiscountIndex::class)->name('discounts'); + Route::get('/discounts/create', DiscountForm::class)->name('discounts.create'); + Route::get('/discounts/{discount}/edit', DiscountForm::class)->whereNumber('discount')->name('discounts.edit'); + Route::get('/pages', PageIndex::class)->name('pages'); + Route::get('/pages/create', PageForm::class)->name('pages.create'); + Route::get('/pages/{page}/edit', PageForm::class)->whereNumber('page')->name('pages.edit'); + Route::get('/settings', GeneralSettings::class)->name('settings'); + Route::get('/settings/shipping', ShippingSettings::class)->name('settings.shipping'); + Route::get('/settings/taxes', TaxSettings::class)->name('settings.taxes'); + Route::get('/themes', ThemeIndex::class)->name('themes'); + Route::get('/themes/{theme}/editor', ThemeEditor::class)->whereNumber('theme')->name('themes.editor'); + Route::get('/analytics', AnalyticsIndex::class)->name('analytics'); + Route::get('/analytics/exports/{analyticsExport}/download', [AnalyticsExportController::class, 'download']) + ->whereNumber('analyticsExport') + ->middleware('can:view-analytics') + ->name('analytics.exports.download'); + Route::get('/developers', DevelopersIndex::class)->name('developers'); + Route::get('/navigation', NavigationIndex::class)->name('navigation'); + Route::get('/apps', AppsIndex::class)->name('apps'); + Route::get('/apps/{installation}', AppShow::class)->whereNumber('installation')->name('apps.show'); + Route::get('/search/settings', SearchSettings::class)->name('search.settings'); +}); + +Route::middleware('store.resolve')->group(function (): void { + Route::get('/', StorefrontHome::class)->name('home'); + Route::get('/collections', StorefrontCollectionIndex::class)->name('storefront.collections'); + Route::get('/collections/{handle}', StorefrontCollectionShow::class)->name('storefront.collection'); + Route::get('/products/{handle}', StorefrontProductShow::class)->name('storefront.product'); + Route::get('/cart', CartShow::class)->name('storefront.cart'); + Route::get('/search', StorefrontSearch::class)->middleware('throttle:search')->name('storefront.search'); + Route::get('/pages/{handle}', StorefrontPageShow::class)->name('storefront.page'); + Route::get('/checkout', CheckoutShow::class)->middleware('throttle:checkout')->name('storefront.checkout'); + Route::get('/checkout/{checkoutId}', CheckoutShow::class)->whereNumber('checkoutId')->middleware('throttle:checkout')->name('storefront.checkout.resume'); + Route::get('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->whereNumber('checkoutId')->middleware('signed')->name('storefront.confirmation'); + + Route::middleware('auth:customer')->prefix('account')->name('storefront.account.')->group(function (): void { + Route::get('/', CustomerDashboard::class)->name('dashboard'); + Route::get('/orders', CustomerOrderIndex::class)->name('orders'); + Route::get('/orders/{orderNumber}', CustomerOrderShow::class)->name('orders.show'); + Route::get('/addresses', AddressIndex::class)->name('addresses'); + }); +}); require __DIR__.'/settings.php'; diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..4942273e --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,212 @@ +# Shop implementation progress + +The implementation follows the build order in `09-IMPLEMENTATION-ROADMAP.md` and acceptance details in the other `specs/*.md` files. Each completed iteration is committed separately. + +## Phases + +- [x] Foundation: tenant schema, store resolution, authentication, authorization +- [x] Catalog: products, variants, inventory, collections, media +- [x] Themes, pages, navigation, storefront layout +- [x] Cart, checkout, discounts, shipping, taxes +- [x] Payments, orders, refunds, fulfillment +- [x] Customer accounts and addresses +- [x] Admin panel and settings +- [x] Search +- [x] Analytics +- [x] Apps, API tokens, and outbound webhooks +- [x] Accessibility, security, responsive polish, and error handling +- [x] Pest unit and feature coverage +- [x] Playwright acceptance review and independent second-agent audit + +The implementation phases and local acceptance review are complete. Root completed representative customer and admin journeys with the Playwright MCP, and a second agent reviewed the integrated changes and reran the full Pest suite. The Pest browser plugin and the 143 browser-test files listed in `08-PLAYWRIGHT-E2E-PLAN.md` are not installed/present, so the browser evidence comes from the MCP journeys recorded below. + +## Iterations + +### 2026-09-23 — Repository and requirements audit + +- Confirmed the working tree was clean and the app was the Laravel starter kit; no shop implementation is present in this checkout. +- Read the roadmap and project-local instructions; `.ai/rules` does not exist. +- Confirmed Laravel 12.51, Livewire 4.1, Flux UI 2.12, Tailwind 4, and Pest 4.3 are installed. The Pest browser plugin and Sanctum are not installed. +- Existing Pest baseline passed: 33 tests, 75 assertions. +- Resolved the Herd URL as `http://shop.test`; Playwright MCP is available for the acceptance review. +- Delegated independent spec acceptance mapping and browser infrastructure inspection. + +### 2026-09-23 — Initial shop foundation and storefront slice + +- Added the tenant-aware commerce schema, store resolver, store-scoped models, role policies, catalog/cart/checkout/payment/order/refund/fulfillment services, storefront and admin route/component skeletons, API token middleware, and two-store demo seeding. +- Added the responsive storefront and admin Blade surfaces. The storefront supports the catalog, product detail, cart, checkout, account, search, page, and confirmation views; admin screens are being wired to complete data/actions in the next iteration. +- Added a newsletter subscription table/action and HTML sanitizer for merchant-authored page/theme content. +- Enabled `RefreshDatabase` for feature tests, restored the starter settings routes, and fixed full-page Livewire view composition uncovered by the first migrated test run. +- Chose `payment_selected` as the checkout reservation state, normalized `country_code`/`country` and `zip`/`postal_code` address aliases, used signed timestamp plus body for webhook HMAC, and followed the per-product seed matrix count of 117 variants. +- Known work remaining: complete missing admin actions/routes and password reset/logout flows, finish API request/response/ability contracts, add webhook delivery and analytics aggregation, add comprehensive shop tests, and run customer/admin Playwright scenarios plus independent acceptance audit. + +**Checks this iteration:** fresh SQLite migration + demo seeding succeeded; initial Pest suite passed (33 tests, 75 assertions); Pint passed; admin Blade compilation and `npm run build` passed in the view agent's check. + +### 2026-09-23 — Tenant contracts, checkout/account flows, analytics and webhooks + +- Reconciled storefront API guest-cart access, cart-version requirements, discount allocation recalculation, checkout address aliases, digital-only checkout, payment retry, expiration, and API error codes with the route/business specs. +- Added store-scoped customer password reset through Laravel's password broker, a tenant-bound token repository/provider, generic reset-link acknowledgements, and admin/customer recovery pages. Removed Fortify's public registration and generic reset endpoints so they cannot bypass the shop-specific flows; made logout a CSRF-protected POST. +- Corrected tax rounding to calculate each item and taxable shipping line independently before summing, as required by the pricing rules. +- Added daily analytics aggregation and queued outbound webhook delivery with encrypted signing secrets, HMAC-SHA256 signatures, delivery history, retries, response snippets, and pause after five consecutive failures. Analytics schema now contains canonical event fields and completed-checkout counts. +- Added reusable model factories and focused feature/unit coverage for tenant resolution, catalog lifecycle, guest cart version/stock errors, checkout/payment/inventory transitions, customer registration/login/address ownership/reset isolation, admin API abilities, pricing/discount/tax rounding, HTML sanitization, analytics aggregation, and webhook delivery/signatures/retries. +- Progress phases remain unchecked until the remaining routes/admin actions, search/settings, media, scheduled cleanup, and browser acceptance work are finished. + +**Checks this iteration:** 39 focused shop tests passed (156 assertions); `vendor/bin/pint --dirty --format agent` passed; `npm run build` passed; route list confirmed the new shop recovery endpoints and absence of Fortify's generic recovery routes. + +### 2026-09-23 — Variant matrix, FTS, analytics ingestion, and maintenance + +- Added a reusable option-matrix reconciler that preserves matching variant pricing, SKU, inventory, and order history while creating/removing combinations safely. Added focused coverage for combinations, preservation, archived variants, and invalid matrices. +- Added a forward migration that rebuilds SQLite FTS5 with the required `product_type` field and reindexes existing products; product saves now keep that field synchronized. +- Analytics ingestion now writes canonical `properties_json` and `occurred_at` fields as well as compatibility fields, accepts product-view events, and drops duplicate client event IDs per store. +- Outbound webhook signatures now bind the Unix event timestamp and JSON body as `timestamp.body`, as required by the security spec. +- Added scheduled jobs for 14-day abandoned cart cleanup, expired checkout inventory release, and configurable overdue bank-transfer cancellation. Scheduled cancellations also fail pending payments and emit `OrderCancelled`. +- Customer login, registration, and password-reset pages now resolve their storefront tenant from the hostname. Static page rendering now uses the sanitizer output rather than the stored HTML. +- Updated scaffold auth tests to assert the shop-specific admin/customer auth contract instead of the removed generic Fortify registration/reset routes. +- Remaining work includes wiring the variant matrix into admin/API product editing, media processing, missing admin CRUD/routes, token and webhook management UI, completing event-driven webhooks and trusted analytics, and running browser acceptance plus the final independent review. + +**Checks this iteration:** full Pest suite passed (81 tests, 307 assertions); focused matrix/tenant/analytics/maintenance/auth regressions passed; Pint passed; `npm run build` passed; `php artisan schedule:list` shows all four documented schedules. + +### 2026-09-23 — API route coverage and signed media upload + +- Registered the collection and discount API write endpoints, applied checkout throttling to all checkout actions, and aligned API search/settings abilities with the token ability list. +- Added a signed product-media upload flow with configurable image/video size limits, extension/content-type matching, temporary upload URLs, streaming storage, one-time completion, and tenant/product authorization. +- Fixed FTS relevance retrieval to use SQLite's native `MATCH` query form; the synonym and stop-word search feature tests now pass. +- An independent API contract audit identified additional remaining API gaps. They are being addressed before final acceptance; the phase checklist remains open. + +**Checks this iteration:** signed media API Pest tests passed (3 tests, 16 assertions); search settings Pest tests passed (4 tests, 19 assertions); focused Pint and `git diff --check` passed. + +### 2026-09-23 — Discount application and fulfillment lifecycle + +- Checkout recalculates totals before payment, applies eligible automatic promotions alongside one code discount, and records one-per-customer redemptions safely. Refund amounts reduce recognized order revenue. +- Fulfillment actions now preserve partial quantities and tracking data, keep pending shipments from completing orders, and complete an order only after all required quantities are delivered. +- Added admin order detail controls for creating partial shipments and marking shipments shipped or delivered. + +**Checks this iteration:** focused discount, fulfillment, and pricing Pest tests passed (11 tests, 73 assertions); Pint passed on the changed PHP files. + +### 2026-09-23 — Admin API contracts, exports, and tiered shipping + +- Completed store-scoped product create/read/update/archive API behavior, including option matrices, variant positions, inventory, collections, partial updates, title/vendor/SKU search, and cross-store 404 isolation. +- Completed order list/detail, fulfillment, refund, tax configuration, search/analytics operations, and asynchronous order CSV exports. Export jobs run on the database queue, filter only the owning store's orders, store CSV files privately, and expose one-hour signed download URLs. +- Added shipping zone specificity and flat, weight-tier, and price-tier calculations. Physical-item weights exclude digital products, unavailable tiers are hidden, and checkout persists the selected calculated amount. +- Fixed variant-create position reconciliation so submitted positions map to stable variant IDs as the collection is reordered. +- Added regression coverage for export authorization/signature/filtering, full product and order API resources, tax provider settings, and shipping tier/zone calculations. + +**Checks this iteration:** the combined focused admin API, checkout API, cart API, shipping, and discount run passed (52 tests, 512 assertions); `vendor/bin/pint --dirty --format agent` passed. Full-suite and browser acceptance remain pending. + +### 2026-09-23 — Admin product, discount, inventory, and search screens + +- Expanded product editing to manage option matrices, per-variant pricing and stock, collections, safe formatted descriptions, and product status transitions. +- Added discount create/edit behavior for codes, automatic promotions, value types, schedules, eligibility, usage limits, and one-use-per-customer; the list now searches codes and filters active, scheduled, and expired promotions. +- Added store-scoped inventory search, stock filters, and permission-gated quantity/policy updates. Search settings now edit synonym groups and stop words and queue reindexing. +- Kept tax settings synchronized with the canonical tax configuration while preserving the admin form workflow. +- Added regression cases for XSS sanitization, compare-at pricing, discount search/status filters, inventory permissions, search settings, and variant preservation. + +**Checks this iteration:** admin product/discount/inventory/search Pest tests passed (20 tests, 127 assertions); `npm run build`, `php artisan view:cache`, and Pint passed. + +### 2026-09-23 — Checkout tax provider selection and fallback + +- Added the documented tax-provider contract and request/result value objects, with manual regional tax calculation behind the contract. +- Checkout now selects manual tax, no-tax provider mode, or the Stripe Tax stub from store settings. The stub follows each store's allow/block fallback, logs allow-mode failures, and records a checkout tax-provider snapshot. +- Added the missing nullable checkout snapshot column with a forward migration. + +**Checks this iteration:** provider, manual regional tax, and checkout regression Pest tests passed (12 tests, 47 assertions). + +### 2026-09-23 — Free-shipping discount restoration + +- Fixed checkout recalculation to resolve the selected shipping rate again, so removing a free-shipping discount restores the cart's selected shipping charge, including recalculated tier rates. +- Added a regression covering discount application, shipping selection, removal, and restored order total. + +**Checks this iteration:** discount and checkout flow Pest tests passed (10 tests, 53 assertions). + +### 2026-09-23 — Acceptance review fixes and remaining admin detail route + +- Made the resolved store persist across Livewire update requests. The browser review had exposed a `current_store` container error on storefront cart actions; the add-to-cart drawer and analytics requests now respond successfully. +- Applied additive forward migrations for store invitations, nested navigation, tax snapshots, queued analytics exports, theme file metadata, and canonical analytics fields without resetting the local demo database. +- Fixed a checkout view variable shadowing bug that kept customers on contact after the component advanced, then corrected order and checkout address accessors so saved addresses display in checkout, confirmation, and account order views. +- Added created-at datetime casts for payments and refunds so SQLite timestamps render in admin order details. Added an installed app detail route and page for scopes, store-scoped webhook subscriptions, install metadata, and usage, linked from the apps list. +- Fixed the demo seeder to use the renamed `stop_words_json` schema column; a clean migration plus demo seed now succeeds on an isolated SQLite file. +- Repaired three feature test files that lacked PHP opening tags, fixed the audit formatter logger type, and kept the test assertions aligned with structured audit log output. +- Added checkout progression/address, timestamp display, and app-detail tenant-isolation regressions. +- Independent browser review completed a customer add-to-cart and paid mock checkout, and exposed/followed up on checkout address, confirmation address, and admin payment timestamp display issues. Retesting those fixes and the rest of the customer/admin acceptance checklist is in progress. + +**Checks this iteration:** focused checkout/order/apps Pest tests passed (17 tests, 101 assertions); full Pest passed (215 tests, 1,424 assertions); clean isolated SQLite migration plus demo seed passed; `npm run build` passed; `php artisan route:list --except-vendor` shows 120 routes including app details; `vendor/bin/pint --dirty --format agent` and `git diff --check` passed. Final browser review remains in progress. + +### 2026-09-23 — Customer session rehydration across requests + +- Independent browser review found customer login redirected to `/account`, but the following request redirected back to `/login`. +- Route middleware ordering runs authentication before `store.resolve`. The tenant-scoped customer provider now resolves the storefront tenant from the request hostname when the container store has not been bound yet, then applies the same store ID constraint when restoring a session user. +- Added a regression that logs in through Livewire, clears the current store and resolved auth guards, and requests `/account` in a new HTTP request. +- Browser review had already verified product add-to-cart, a guest checkout/payment/confirmation path, admin order detail, navigation, themes, tax settings, analytics, and developers. Root's fresh-context Playwright MCP retest confirmed customer login remains authenticated after the redirect and a hard refresh. App detail data is covered by Pest because the demo seed does not include installed apps. + +**Checks this iteration:** `CustomerAccountTest` passed (8 tests, 43 assertions); full Pest passed (217 tests, 1,433 assertions); Pint, frontend build, and view cache checks passed. + +### 2026-09-23 — Final browser acceptance fixes + +- Replaced the static storefront cart badge with a Livewire counter that listens for `cart-updated`, so both its visible number and accessible label track cart mutations and reset after checkout. +- Added regression coverage for the tenant-host fallback during customer session restoration and for live cart-count updates. +- Completed a Playwright MCP customer journey: sign in and refresh account page, add two product units, verify drawer and header count, proceed through address/shipping/payment, complete mock card payment, and inspect the resulting order in admin. The confirmed order showed the entered shipping address, masked card, payment timestamp, and correct shipping/tax/total; checkout reset the cart count to zero. +- The browser agent independently reran the full Pest suite and reviewed the customer-session/cart-count/address fixes. Its Playwright MCP transport remained closed, so its browser verification is the root-run evidence recorded above. Full-page app details remain browser-unexercised because the demo seed has no app installations. +- OAuth providers and external PSP integrations remain deferred as described in the roadmap; the implemented checkout uses the specified mock PSP. + +**Checks this iteration:** full Pest passed (217 tests, 1,433 assertions); `vendor/bin/pint --dirty --format agent`, `npm run build`, `php artisan view:cache`, isolated SQLite migration plus demo seed, and `git diff --check` passed. The integrated-tree second-agent acceptance review is in progress. + +### 2026-09-23 — Spec audit repairs and final customer/admin checkout review + +- Closed the independent spec audit findings: staff can manage pages through the same scoped policy as the UI, page API writes pass through the HTML sanitizer, and platform-created stores attach their creator as the initial Owner. +- Added queued customer email notifications for order confirmation, refund, cancellation, and shipment events. Refund and fulfillment API calls honor `notify_customer`; order confirmation includes the order lines and bank-transfer instructions. +- Completed General Settings with persisted locale and timezone controls, and added Checkout and Notifications settings tabs. Updated the local dev queue listener to consume the `default` and `search` database queues used by exports and indexing. +- Ran a full Playwright MCP checkout with two T-shirt units, Berlin shipping address, Standard Shipping, and the mock card. Confirmation showed order #1018, correct tax-inclusive total of 54.97 EUR, address, masked payment, and empty cart; admin order #1018 showed paid/captured state, payment timestamp, items, total, and shipping address. The seed enables tax-inclusive pricing, so the displayed 8.77 EUR tax is included in the subtotal and is not added again. +- Verified General, Checkout, and Notifications settings pages in Playwright. No browser console errors or warnings were reported during these checks. +- Root full-suite pass is complete. An integrated-tree second-agent code review and rerun are still in progress. The listed 143 Pest browser test cases are not present and the Pest browser plugin is not installed; Playwright MCP supplies the representative end-to-end browser evidence above. + +**Checks this iteration:** full Pest passed (225 tests, 1,492 assertions); `vendor/bin/pint --dirty --format agent`, `composer validate --no-check-publish`, `npm run build`, `php artisan view:cache`, fresh isolated SQLite migration plus demo seed, `php artisan route:list --except-vendor` (120 routes), and `git diff --check` passed. Browser customer checkout, admin order detail, and settings tab review passed; no console errors or warnings. + +### 2026-09-23 — Customer login redirect hardening and second-agent sign-off + +- The independent integrated-tree review found an open redirect in customer login: a slash/backslash path can be parsed by browsers as an external authority, and Livewire's public `redirectTo` state can be changed before login. +- Customer login now accepts only a relative path with one leading slash, no backslashes/control characters, and no URL scheme, host, user, or password. The component applies the same validation to the client-controlled property immediately before redirecting. Non-string query values fall back safely to the account dashboard. +- Added regressions for slash/backslash query input, non-string query values, direct absolute-URL property tampering, and a valid local path with its query string. +- The second agent reviewed the fix, reran the complete Pest suite (228 tests, 1,499 assertions), and reported no remaining concrete blockers. Root reran the suite after adding the malformed-query regression (228 tests, 1,500 assertions). The Playwright MCP customer/admin acceptance review was completed by root; the reviewer's browser transport was unavailable. +- Restarted the browser review after the security fix: logged out, visited login with `redirect=%2F%5Cattacker.test%2F`, authenticated with the demo customer, and confirmed the browser landed on `/account`. Browser console remained free of errors and warnings. + +**Checks this iteration:** focused customer account tests passed (11 tests, 51 assertions); full Pest passed in the independent review and latest root run (228 tests, 1,499 and 1,500 assertions respectively); Pint passed. The earlier final build, isolated migration/seed, settings pages, paid mock checkout, admin order detail, and console checks also passed. + +## Verification log + +| Date | Check | Result | +|------|-------|--------| +| 2026-09-23 | Existing Pest suite (`php artisan test --compact`) | Passed: 33 tests, 75 assertions | +| 2026-09-23 | Fresh migration + demo seed (`php artisan migrate:fresh --seed --force --no-interaction`) | Passed on SQLite; 2 stores, 117 fashion variants, and 18 seeded orders | +| 2026-09-23 | Pest after tenant test setup and Livewire view fixes | Passed: 33 tests, 75 assertions | +| 2026-09-23 | `vendor/bin/pint --dirty --format agent` | Passed | +| 2026-09-23 | Admin Blade compilation and `npm run build` | Passed in the view agent's check | +| 2026-09-23 | Focused tenant, catalog, cart, checkout, account, API, pricing, analytics, and webhook Pest tests | Passed: 39 tests, 156 assertions | +| 2026-09-23 | `npm run build` after account recovery views | Passed | +| 2026-09-23 | `php artisan route:list --path=forgot` and `--path=reset` | Passed; both admin and customer shop routes registered, generic Fortify reset routes removed | +| 2026-09-23 | Full Pest suite after variant, FTS, analytics ingestion, maintenance, and auth updates | Passed: 81 tests, 307 assertions | +| 2026-09-23 | `php artisan schedule:list --no-interaction` | Passed; analytics, abandoned-cart cleanup, checkout expiration, and bank-transfer cancellation schedules registered | +| 2026-09-23 | Signed product-media upload Pest tests | Passed: 3 tests, 16 assertions | +| 2026-09-23 | Search settings Pest tests | Passed: 4 tests, 19 assertions | +| 2026-09-23 | Discount, fulfillment lifecycle, and pricing Pest tests | Passed: 11 tests, 73 assertions | +| 2026-09-23 | Admin API, order export, checkout/cart APIs, shipping, and discount Pest tests | Passed: 52 tests, 512 assertions | +| 2026-09-23 | Admin product, discount, inventory, and search screen Pest tests | Passed: 20 tests, 127 assertions | +| 2026-09-23 | `npm run build` and `php artisan view:cache` after admin screen changes | Passed | +| 2026-09-23 | Final current Pest suite after browser-discovered fixes | Passed: 215 tests, 1,424 assertions | +| 2026-09-23 | `npm run build` after final storefront/admin updates | Passed | +| 2026-09-23 | `php artisan route:list --except-vendor` after app detail route | Passed: 120 routes listed | +| 2026-09-23 | `vendor/bin/pint --dirty --format agent` and `git diff --check` | Passed | +| 2026-09-23 | Fresh isolated SQLite migration + demo seed after seeder schema fix | Passed: all migrations and `DemoShopSeeder` completed | +| 2026-09-23 | Customer session rehydration regression | Passed: `CustomerAccountTest` (8 tests, 43 assertions); fresh Playwright MCP login/refresh passed | +| 2026-09-23 | Final full Pest suite and second-agent rerun | Passed: 217 tests, 1,433 assertions | +| 2026-09-23 | Playwright MCP customer-to-paid-order and admin detail journey | Passed: refreshed customer session, live cart count/drawer, address/shipping/tax/payment, confirmation, and admin payment/address detail verified | +| 2026-09-23 | Final Pint, frontend build, cached view compilation, isolated migration/seed, and diff check | Passed | +| 2026-09-23 | Spec-audit regressions for page roles/sanitization, store-owner assignment, notification delivery/API flags, and General Settings | Passed in focused suites; included in the full suite | +| 2026-09-23 | Final integrated Pest suite after spec-audit repairs | Passed: 225 tests, 1,492 assertions | +| 2026-09-23 | Fresh isolated SQLite migration and `DemoShopSeeder` | Passed | +| 2026-09-23 | Playwright MCP final checkout, order confirmation, admin order detail, and settings tabs | Passed: order #1018 paid/captured at 54.97 EUR; address and timestamps visible; cart reset; no browser console errors or warnings | +| 2026-09-23 | Composer validation, frontend build, view cache compilation, 120-route inventory, Pint, diff check | Passed | +| 2026-09-23 | Customer open-redirect regressions | Passed: slash-backslash query rejected, tampered absolute URL falls back to account, safe local path preserved | +| 2026-09-23 | Full Pest suite after login redirect hardening | Passed in independent second-agent review: 228 tests, 1,499 assertions | +| 2026-09-23 | Latest full Pest suite after malformed-query regression | Passed: 228 tests, 1,500 assertions | +| 2026-09-23 | Independent second-agent review of integrated changes and final login redirect fix | Passed: no remaining concrete blockers; browser MCP unavailable in agent context | +| 2026-09-23 | Playwright MCP open-redirect regression journey | Passed: encoded slash/backslash return path stayed on `/account` after sign-in; no console errors or warnings | diff --git a/tests/Feature/AdminApiAccessTest.php b/tests/Feature/AdminApiAccessTest.php new file mode 100644 index 00000000..bb7cce03 --- /dev/null +++ b/tests/Feature/AdminApiAccessTest.php @@ -0,0 +1,88 @@ +tokens()->create([ + 'store_id' => $store?->id, + 'name' => 'acceptance-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => $expiresAt, + ]); + + return $plainTextToken; +} + +it('returns 401 when the admin API request has no valid token', function () { + $store = shopStore(); + + $this->getJson("/api/admin/v1/stores/{$store->id}/products") + ->assertUnauthorized(); +}); + +it('allows product reads for a matching ability and rejects writes without it', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + Product::factory()->create(['store_id' => $store->id]); + $token = adminApiToken($user, $store, ['read-products']); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/products") + ->assertOk() + ->assertJsonPath('meta.total', 1); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/products", [ + 'title' => 'Not created', + 'variants' => [['price_amount' => 1000]], + ])->assertForbidden(); + + expect(Product::query()->where('title', 'Not created')->exists())->toBeFalse(); +}); + +it('rejects expired tokens and users without membership in the requested store', function () { + $store = shopStore(); + $user = User::factory()->create(); + $expiredToken = adminApiToken($user, $store, ['read-products'], now()->subMinute()); + + $this->withToken($expiredToken)->getJson("/api/admin/v1/stores/{$store->id}/products") + ->assertUnauthorized(); + + $activeToken = adminApiToken($user, $store, ['read-products']); + $this->withToken($activeToken)->getJson("/api/admin/v1/stores/{$store->id}/products") + ->assertForbidden(); +}); + +it('rejects a store token when its owner requests another store', function () { + $firstStore = shopStore(); + $secondStore = shopStore('api-token-second.test'); + $user = User::factory()->create(); + $user->stores()->attach([$firstStore->id => ['role' => 'owner'], $secondStore->id => ['role' => 'owner']]); + $token = adminApiToken($user, $firstStore, ['read-products']); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$firstStore->id}/products") + ->assertOk(); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$secondStore->id}/products") + ->assertForbidden() + ->assertJsonPath('message', 'This token is not authorized for the requested store.'); +}); + +it('allows an explicit platform token to reach member stores', function () { + $firstStore = shopStore(); + $secondStore = shopStore('platform-token-second.test'); + $user = User::factory()->create(); + $user->stores()->attach([$firstStore->id => ['role' => 'owner'], $secondStore->id => ['role' => 'owner']]); + $token = adminApiToken($user, null, ['manage-platform', 'read-products']); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$firstStore->id}/products") + ->assertOk(); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$secondStore->id}/products") + ->assertOk(); +}); diff --git a/tests/Feature/AdminAppsTest.php b/tests/Feature/AdminAppsTest.php new file mode 100644 index 00000000..6edcce2b --- /dev/null +++ b/tests/Feature/AdminAppsTest.php @@ -0,0 +1,141 @@ +create(); + $user->stores()->attach($store->id, ['role' => $role]); + + return $user; +} + +function createAppInstallation(Store $store, string $name, string $handle, string $status = 'active'): int +{ + $now = now(); + $appId = DB::table('apps')->insertGetId([ + 'name' => $name, + 'handle' => $handle, + 'description' => 'A test app.', + 'scopes_json' => json_encode(['read-products']), + 'is_available' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('app_installations')->insert([ + 'store_id' => $store->id, + 'app_id' => $appId, + 'status' => $status, + 'settings_json' => '{}', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $appId; +} + +it('shows the apps empty state when the current store has no installations', function () { + $store = shopStore('apps-empty.test'); + $otherStore = shopStore('apps-other.test'); + $user = appsAdmin($store); + createAppInstallation($otherStore, 'Other store app', 'other-store-app'); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get(route('admin.apps')) + ->assertOk() + ->assertSee('Apps') + ->assertSee('No apps installed') + ->assertDontSee('Other store app'); +}); + +it('lists and uninstalls only apps installed for the current store', function () { + $store = shopStore('apps-installed.test'); + $otherStore = shopStore('apps-installed-other.test'); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + app()->instance('current_store', $store); + $localAppId = createAppInstallation($store, 'Store reporting', 'store-reporting'); + $foreignAppId = createAppInstallation($otherStore, 'Private analytics', 'private-analytics'); + + Livewire::actingAs($user)->test(AppsIndex::class) + ->assertSee('Store reporting') + ->assertDontSee('Private analytics') + ->call('uninstallApp', $foreignAppId) + ->assertStatus(404); + + expect(DB::table('app_installations')->where('store_id', $otherStore->id)->where('app_id', $foreignAppId)->exists())->toBeTrue(); + + $localComponent = Livewire::actingAs($user)->test(AppsIndex::class); + app()->forgetInstance('current_store'); + + $localComponent->call('uninstallApp', $localAppId) + ->assertHasNoErrors(); + + expect(DB::table('app_installations')->where('store_id', $store->id)->where('app_id', $localAppId)->exists())->toBeFalse() + ->and(DB::table('app_installations')->where('store_id', $otherStore->id)->where('app_id', $foreignAppId)->exists())->toBeTrue(); +}); + +it('restricts apps management to store owners and admins', function () { + $store = shopStore('apps-security.test'); + $staff = appsAdmin($store, 'staff'); + + $this->actingAs($staff) + ->withSession(['current_store_id' => $store->id]) + ->get(route('admin.apps')) + ->assertForbidden(); +}); + +it('shows installation scopes and only its store-scoped webhook subscriptions', function () { + $store = shopStore('apps-detail.test'); + $otherStore = shopStore('apps-detail-other.test'); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + app()->instance('current_store', $store); + $appId = createAppInstallation($store, 'Order alerts', 'order-alerts'); + $otherAppId = createAppInstallation($otherStore, 'Private alerts', 'private-alerts'); + $installationId = (int) DB::table('app_installations')->where('store_id', $store->id)->where('app_id', $appId)->value('id'); + $otherInstallationId = (int) DB::table('app_installations')->where('store_id', $otherStore->id)->where('app_id', $otherAppId)->value('id'); + $now = now(); + DB::table('webhook_subscriptions')->insert([ + [ + 'store_id' => $store->id, + 'app_installation_id' => $installationId, + 'event_type' => 'orders.created', + 'target_url' => 'https://hooks.example.test/orders', + 'signing_secret_encrypted' => 'encrypted-test-secret', + 'status' => 'active', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'store_id' => $otherStore->id, + 'app_installation_id' => $otherInstallationId, + 'event_type' => 'orders.refunded', + 'target_url' => 'https://private.example.test/orders', + 'signing_secret_encrypted' => 'encrypted-private-secret', + 'status' => 'active', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + + Livewire::actingAs($user) + ->test(AppShow::class, ['installation' => $installationId]) + ->assertSee('read-products') + ->assertSee('orders.created') + ->assertSee('https://hooks.example.test/orders') + ->assertSee('No API calls recorded') + ->assertDontSee('orders.refunded') + ->assertDontSee('private.example.test'); + + Livewire::actingAs($user) + ->test(AppShow::class, ['installation' => $otherInstallationId]) + ->assertStatus(404); +}); diff --git a/tests/Feature/AdminCollectionsDiscountsApiTest.php b/tests/Feature/AdminCollectionsDiscountsApiTest.php new file mode 100644 index 00000000..e0d3f04a --- /dev/null +++ b/tests/Feature/AdminCollectionsDiscountsApiTest.php @@ -0,0 +1,391 @@ +setUserResolver(fn (): ?User => auth()->user()); + app()->instance('request', $request); + + return app()->call([app(AdminController::class), $action], [ + 'request' => $request, + 'storeId' => $storeId, + ...$routeParameters, + ]); +} + +function attachStoreRole(User $user, Store $store, string $role = 'owner'): void +{ + $user->stores()->attach($store->getKey(), ['role' => $role]); + app()->instance('current_store', $store); + test()->actingAs($user); +} + +function adminApiCrudToken(User $user, Store $store, array $abilities): string +{ + $plainTextToken = Str::random(48); + $user->tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => 'collections-discounts-api-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + ]); + + return $plainTextToken; +} + +it('lists only the selected store collections with supported filters and response metadata', function () { + $store = shopStore(); + $otherStore = shopStore('other-shop.test'); + $user = User::factory()->create(); + attachStoreRole($user, $store, 'support'); + $product = shopProduct($store)['product']; + $collection = Collection::query()->create([ + 'store_id' => $store->id, + 'title' => 'Summer Picks', + 'handle' => 'summer-picks', + 'type' => 'manual', + 'status' => 'active', + ]); + $collection->products()->attach($product->id); + Collection::query()->create([ + 'store_id' => $store->id, + 'title' => 'Draft Summer', + 'handle' => 'draft-summer', + 'type' => 'manual', + 'status' => 'draft', + ]); + Collection::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'title' => 'Summer Other Store', + 'handle' => 'summer-other-store', + 'type' => 'manual', + 'status' => 'active', + ]); + + $response = invokeCollectionsDiscountsAdminAction('collections', $store->id, input: [ + 'query' => 'Summer', + 'status' => 'active', + 'per_page' => 1, + ], method: 'GET'); + + expect($response->getData(true)['data'][0])->toMatchArray([ + 'id' => $collection->id, + 'store_id' => $store->id, + 'title' => 'Summer Picks', + 'handle' => 'summer-picks', + 'description_html' => null, + 'type' => 'manual', + 'status' => 'active', + 'products_count' => 1, + ])->and($response->getData(true)['meta'])->toBe(['current_page' => 1, 'per_page' => 1, 'total' => 1, 'last_page' => 1]) + ->and(array_keys($response->getData(true)['data'][0]))->toBe([ + 'id', 'store_id', 'title', 'handle', 'description_html', 'type', 'status', 'products_count', 'created_at', 'updated_at', + ]); +}); + +it('creates updates and deletes collections while validating tenant-owned product associations', function () { + $store = shopStore(); + $otherStore = shopStore('other-shop.test'); + $user = User::factory()->create(); + attachStoreRole($user, $store); + $firstProduct = shopProduct($store)['product']; + $secondProduct = shopProduct($store)['product']; + $otherProduct = shopProduct($otherStore)['product']; + + $created = invokeCollectionsDiscountsAdminAction('createCollection', $store->id, input: [ + 'title' => 'Summer Collection', + 'description_html' => '

Bright basics.

', + 'type' => 'manual', + 'product_ids' => [$firstProduct->id], + ]); + $collectionData = $created->getData(true); + $collection = Collection::query()->findOrFail($collectionData['id']); + + expect($created->getStatusCode())->toBe(201) + ->and($collectionData)->toMatchArray([ + 'store_id' => $store->id, + 'title' => 'Summer Collection', + 'handle' => 'summer-collection', + 'type' => 'manual', + 'status' => 'active', + 'products_count' => 1, + ]) + ->and($collection->products()->pluck('products.id')->all())->toBe([$firstProduct->id]); + + $updated = invokeCollectionsDiscountsAdminAction('updateCollection', $store->id, ['collectionId' => $collection->id], [ + 'title' => 'Updated Summer', + 'add_product_ids' => [$secondProduct->id], + 'remove_product_ids' => [$firstProduct->id], + ], 'PUT'); + + expect($updated->getStatusCode())->toBe(200) + ->and($updated->getData(true)['title'])->toBe('Updated Summer') + ->and($updated->getData(true)['products_count'])->toBe(1) + ->and($collection->fresh()->products()->pluck('products.id')->all())->toBe([$secondProduct->id]); + + expect(fn () => invokeCollectionsDiscountsAdminAction('updateCollection', $store->id, ['collectionId' => $collection->id], [ + 'product_ids' => [$firstProduct->id], + 'add_product_ids' => [$secondProduct->id], + ], 'PUT'))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('updateCollection', $store->id, ['collectionId' => $collection->id], [ + 'product_ids' => [$otherProduct->id], + ], 'PUT'))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('updateCollection', $store->id, ['collectionId' => $collection->id + 1000], [], 'PUT')) + ->toThrow(ModelNotFoundException::class); + + $deleted = invokeCollectionsDiscountsAdminAction('deleteCollection', $store->id, ['collectionId' => $collection->id], [], 'DELETE'); + expect($deleted->getStatusCode())->toBe(200) + ->and($deleted->getData(true))->toBe(['message' => 'Collection deleted']) + ->and(Collection::query()->whereKey($collection->id)->exists())->toBeFalse(); +}); + +it('creates filters updates and deletes discounts using the documented API resource contract', function () { + $store = shopStore(); + $otherStore = shopStore('other-shop.test'); + $user = User::factory()->create(); + attachStoreRole($user, $store); + $product = shopProduct($store)['product']; + $collection = Collection::query()->create([ + 'store_id' => $store->id, + 'title' => 'Eligible Collection', + 'handle' => 'eligible-collection', + 'type' => 'manual', + 'status' => 'active', + ]); + $otherProduct = shopProduct($otherStore)['product']; + + $created = invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => ' welcome10 ', + 'value_type' => 'percent', + 'value_amount' => 10, + 'usage_limit' => 50, + 'rules_json' => [ + 'minimum_purchase_amount' => 1500, + 'applicable_product_ids' => [$product->id], + 'applicable_collection_ids' => [$collection->id], + 'customer_eligibility' => 'all', + 'once_per_customer' => true, + ], + ]); + $discountData = $created->getData(true); + $discount = Discount::query()->findOrFail($discountData['id']); + + expect($created->getStatusCode())->toBe(201) + ->and($discountData)->toMatchArray([ + 'store_id' => $store->id, + 'type' => 'code', + 'code' => 'WELCOME10', + 'value_type' => 'percent', + 'value_amount' => 10, + 'usage_limit' => 50, + 'usage_count' => 0, + 'rules_json' => [ + 'minimum_purchase_amount' => 1500, + 'applicable_product_ids' => [$product->id], + 'applicable_collection_ids' => [$collection->id], + 'customer_eligibility' => 'all', + 'once_per_customer' => true, + ], + ]) + ->and($discount->type)->toBe('percentage') + ->and($discount->minimum_subtotal_amount)->toBe(1500) + ->and($discount->rules_json['product_ids'])->toBe([$product->id]) + ->and($discount->rules_json['collection_ids'])->toBe([$collection->id]) + ->and($discount->rules_json['one_per_customer'])->toBeTrue(); + + $automatic = invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'automatic', + 'value_type' => 'free_shipping', + 'value_amount' => 0, + 'starts_at' => now()->addDay()->toISOString(), + ]); + expect($automatic->getStatusCode())->toBe(201) + ->and($automatic->getData(true)['type'])->toBe('automatic') + ->and($automatic->getData(true)['code'])->toBeNull() + ->and($automatic->getData(true)['value_type'])->toBe('free_shipping') + ->and($automatic->getData(true)['value_amount'])->toBe(0); + + $expired = invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'EXPIRED10', + 'value_type' => 'fixed', + 'value_amount' => 100, + 'starts_at' => now()->subDays(3)->toISOString(), + 'ends_at' => now()->subDay()->toISOString(), + ]); + $largeFixed = invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'LARGEFIXED', + 'value_type' => 'fixed', + 'value_amount' => 500, + 'starts_at' => now()->addDays(5)->toISOString(), + ]); + + expect(invokeCollectionsDiscountsAdminAction('discounts', $store->id, input: ['type' => 'automatic', 'status' => 'scheduled'], method: 'GET')->getData(true)['data'][0]['id']) + ->toBe($automatic->getData(true)['id']) + ->and(invokeCollectionsDiscountsAdminAction('discounts', $store->id, input: ['status' => 'expired'], method: 'GET')->getData(true)['data'][0]['id']) + ->toBe($expired->getData(true)['id']); + + expect(fn () => invokeCollectionsDiscountsAdminAction('updateDiscount', $store->id, ['discountId' => $largeFixed->getData(true)['id']], [ + 'value_type' => 'percent', + ], 'PUT'))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'TOO-MUCH', + 'value_type' => 'percent', + 'value_amount' => 101, + ]))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'BAD-DATES', + 'value_type' => 'fixed', + 'value_amount' => 100, + 'starts_at' => now()->addDay()->toISOString(), + 'ends_at' => now()->toISOString(), + ]))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'BAD-RULES', + 'value_type' => 'fixed', + 'value_amount' => 100, + 'rules_json' => ['unsupported' => true], + ]))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'WELCOME10', + 'value_type' => 'fixed', + 'value_amount' => 500, + ]))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'code', + 'code' => 'CROSS-TENANT', + 'value_type' => 'fixed', + 'value_amount' => 500, + 'rules_json' => ['applicable_product_ids' => [$otherProduct->id]], + ]))->toThrow(ValidationException::class); + + $listed = invokeCollectionsDiscountsAdminAction('discounts', $store->id, input: [ + 'type' => 'code', + 'status' => 'active', + 'per_page' => 1, + ], method: 'GET')->getData(true); + expect($listed['data'])->toHaveCount(1) + ->and($listed['data'][0]['id'])->toBe($discount->id) + ->and($listed['meta'])->toBe(['current_page' => 1, 'per_page' => 1, 'total' => 1, 'last_page' => 1]); + + $updated = invokeCollectionsDiscountsAdminAction('updateDiscount', $store->id, ['discountId' => $discount->id], [ + 'rules_json' => ['minimum_purchase_amount' => 2500], + 'usage_limit' => null, + ], 'PUT'); + expect($updated->getStatusCode())->toBe(200) + ->and($updated->getData(true)['rules_json']['minimum_purchase_amount'])->toBe(2500) + ->and($updated->getData(true)['rules_json']['applicable_product_ids'])->toBe([$product->id]) + ->and($updated->getData(true)['usage_limit'])->toBeNull() + ->and($discount->fresh()->minimum_subtotal_amount)->toBe(2500); + + expect(fn () => invokeCollectionsDiscountsAdminAction('updateDiscount', $store->id, ['discountId' => $discount->id], [ + 'code' => 'CHANGED', + ], 'PUT'))->toThrow(ValidationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('updateDiscount', $store->id, ['discountId' => $discount->id + 1000], [], 'PUT')) + ->toThrow(ModelNotFoundException::class); + + $deleted = invokeCollectionsDiscountsAdminAction('deleteDiscount', $store->id, ['discountId' => $discount->id], [], 'DELETE'); + expect($deleted->getStatusCode())->toBe(200) + ->and($deleted->getData(true))->toBe(['message' => 'Discount deleted']) + ->and(Discount::query()->whereKey($discount->id)->exists())->toBeFalse(); +}); + +it('allows support reads but rejects collection and discount writes', function () { + $store = shopStore(); + $user = User::factory()->create(); + attachStoreRole($user, $store, 'support'); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createCollection', $store->id, input: [ + 'title' => 'Forbidden collection', + 'type' => 'manual', + ]))->toThrow(AuthorizationException::class); + + expect(fn () => invokeCollectionsDiscountsAdminAction('createDiscount', $store->id, input: [ + 'type' => 'automatic', + 'value_type' => 'percent', + 'value_amount' => 10, + ]))->toThrow(AuthorizationException::class); +}); + +it('enforces the documented API token abilities on collection and discount routes', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $readToken = adminApiCrudToken($user, $store, ['read-collections', 'read-discounts']); + $collectionWriteToken = adminApiCrudToken($user, $store, ['write-collections']); + $discountWriteToken = adminApiCrudToken($user, $store, ['write-discounts']); + $basePath = "/api/admin/v1/stores/{$store->id}"; + + $this->withToken($readToken)->getJson("{$basePath}/collections") + ->assertOk() + ->assertJsonPath('data', []) + ->assertJsonPath('meta', ['current_page' => 1, 'per_page' => 25, 'total' => 0, 'last_page' => 1]); + $this->withToken($readToken)->getJson("{$basePath}/discounts") + ->assertOk() + ->assertJsonPath('data', []) + ->assertJsonPath('meta.per_page', 25); + $this->withToken($readToken)->postJson("{$basePath}/collections", [ + 'title' => 'Read only', + 'type' => 'manual', + ])->assertForbidden(); + $this->withToken($collectionWriteToken)->getJson("{$basePath}/collections")->assertForbidden(); + + $collection = $this->withToken($collectionWriteToken)->postJson("{$basePath}/collections", [ + 'title' => 'Route tested collection', + 'type' => 'manual', + ])->assertCreated() + ->assertJsonPath('title', 'Route tested collection') + ->assertJsonPath('handle', 'route-tested-collection') + ->json(); + $this->withToken($collectionWriteToken)->putJson("{$basePath}/collections/{$collection['id']}", [ + 'status' => 'archived', + ])->assertOk()->assertJsonPath('status', 'archived'); + $this->withToken($collectionWriteToken)->deleteJson("{$basePath}/collections/{$collection['id']}") + ->assertOk()->assertJsonPath('message', 'Collection deleted'); + + $this->withToken($collectionWriteToken)->postJson("{$basePath}/discounts", [ + 'type' => 'automatic', + 'value_type' => 'percent', + 'value_amount' => 10, + ])->assertForbidden(); + $discount = $this->withToken($discountWriteToken)->postJson("{$basePath}/discounts", [ + 'type' => 'code', + 'code' => 'ROUTE10', + 'value_type' => 'percent', + 'value_amount' => 10, + ])->assertCreated() + ->assertJsonPath('type', 'code') + ->assertJsonPath('code', 'ROUTE10') + ->json(); + $this->withToken($discountWriteToken)->putJson("{$basePath}/discounts/{$discount['id']}", [ + 'usage_limit' => 10, + ])->assertOk()->assertJsonPath('usage_limit', 10); + $this->withToken($discountWriteToken)->deleteJson("{$basePath}/discounts/{$discount['id']}") + ->assertOk()->assertJsonPath('message', 'Discount deleted'); +}); diff --git a/tests/Feature/AdminDevelopersTest.php b/tests/Feature/AdminDevelopersTest.php new file mode 100644 index 00000000..4aea38bb --- /dev/null +++ b/tests/Feature/AdminDevelopersTest.php @@ -0,0 +1,202 @@ +create(); + $user->stores()->attach($store->id, ['role' => $role]); + + return $user; +} + +it('renders the developers page in the admin navigation context', function () { + $store = shopStore(); + $user = developersAdmin($store); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get(route('admin.developers')) + ->assertOk() + ->assertSee('API tokens') + ->assertSee('Webhooks') + ->assertSee('Developers'); +}); + +it('creates a one-time hashed token scoped to the current store', function () { + $store = shopStore(); + $otherStore = shopStore('developers-other.test'); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $otherStoreToken = app(ApiTokenService::class)->create($user, $otherStore, 'Other store key', ['read-orders']); + + $component = Livewire::actingAs($user)->test(Index::class) + ->assertDontSee('Other store key') + ->set('newTokenName', 'Store integration') + ->set('tokenAbilities', ['read-products', 'read-orders']) + ->call('generateToken') + ->assertHasNoErrors(); + + $plainTextToken = $component->get('generatedToken'); + $token = PersonalAccessToken::query()->where('store_id', $store->id)->where('name', 'Store integration')->firstOrFail(); + + expect($plainTextToken)->toStartWith('shop_') + ->and($token->token)->toBe(hash('sha256', $plainTextToken)) + ->and($token->abilities)->toBe(['read-products', 'read-orders']) + ->and($token->store_id)->toBe($store->id) + ->and($otherStoreToken['token']->store_id)->toBe($otherStore->id); + + $component->assertSee($plainTextToken) + ->call('dismissGeneratedToken') + ->assertDontSee($plainTextToken); +}); + +it('rejects empty or unsupported API permissions before issuing a token', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = developersAdmin($store); + + Livewire::actingAs($user)->test(Index::class) + ->set('newTokenName', 'Invalid permissions') + ->set('tokenAbilities', ['manage-platform']) + ->call('generateToken') + ->assertHasErrors('tokenAbilities.0'); + + expect(PersonalAccessToken::query()->count())->toBe(0); +}); + +it('revokes only a token scoped to the current store', function () { + $store = shopStore(); + $otherStore = shopStore('developers-revoke-other.test'); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $tokenService = app(ApiTokenService::class); + $localToken = $tokenService->create($user, $store, 'Local API key', ['read-products'])['token']; + $foreignToken = $tokenService->create($user, $otherStore, 'Foreign API key', ['read-products'])['token']; + + expect(fn () => Livewire::actingAs($user)->test(Index::class)->call('revokeToken', $foreignToken->id)) + ->toThrow(ModelNotFoundException::class); + + $this->assertModelExists($foreignToken); + + Livewire::actingAs($user)->test(Index::class) + ->call('revokeToken', $localToken->id) + ->assertHasNoErrors(); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $localToken->id]); + $this->assertModelExists($foreignToken); +}); + +it('creates updates and deletes encrypted store-scoped webhook subscriptions', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = developersAdmin($store); + + $component = Livewire::actingAs($user)->test(Index::class) + ->call('openWebhookModal') + ->set('webhookEventType', 'order.created') + ->set('webhookUrl', 'https://hooks.example.test/orders') + ->set('webhookStatus', 'active') + ->call('saveWebhook') + ->assertHasNoErrors() + ->assertSee('order.created') + ->assertSee('https://hooks.example.test/orders'); + + $webhook = WebhookSubscription::withoutGlobalScopes()->where('store_id', $store->id)->firstOrFail(); + $rawSecret = DB::table('webhook_subscriptions')->where('id', $webhook->id)->value('signing_secret_encrypted'); + $originalSecret = $webhook->signing_secret_encrypted; + + expect($webhook->event_type)->toBe('order.created') + ->and($webhook->status)->toBe('active') + ->and($originalSecret)->toHaveLength(64) + ->and($rawSecret)->not->toBe($originalSecret); + + $component->call('openWebhookModal', $webhook->id) + ->set('webhookEventType', 'order.paid') + ->set('webhookUrl', 'https://hooks.example.test/updated-orders') + ->set('webhookStatus', 'paused') + ->call('saveWebhook') + ->assertHasNoErrors(); + + $webhook->refresh(); + expect($webhook->event_type)->toBe('order.paid') + ->and($webhook->target_url)->toBe('https://hooks.example.test/updated-orders') + ->and($webhook->status)->toBe('paused') + ->and($webhook->signing_secret_encrypted)->toBe($originalSecret); + + $component->call('deleteWebhook', $webhook->id)->assertHasNoErrors(); + $this->assertDatabaseMissing('webhook_subscriptions', ['id' => $webhook->id]); +}); + +it('rejects unsupported webhook events and non-http endpoint URLs', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = developersAdmin($store); + + Livewire::actingAs($user)->test(Index::class) + ->call('openWebhookModal') + ->set('webhookEventType', 'customer.deleted') + ->set('webhookUrl', 'ftp://hooks.example.test/events') + ->set('webhookStatus', 'active') + ->call('saveWebhook') + ->assertHasErrors(['webhookEventType', 'webhookUrl']); + + expect(WebhookSubscription::withoutGlobalScopes()->count())->toBe(0); +}); + +it('does not reveal or delete another stores webhook by identifier', function () { + $store = shopStore(); + $otherStore = shopStore('developers-webhook-other.test'); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $foreignWebhook = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'event_type' => 'order.created', + 'target_url' => 'https://foreign.example.test/hook', + 'signing_secret_encrypted' => bin2hex(random_bytes(32)), + 'status' => 'active', + ]); + + Livewire::actingAs($user)->test(Index::class) + ->assertDontSee('foreign.example.test'); + + expect(fn () => Livewire::actingAs($user)->test(Index::class)->call('openWebhookModal', $foreignWebhook->id)) + ->toThrow(ModelNotFoundException::class); + + $this->assertModelExists($foreignWebhook); +}); + +it('authorizes developers settings for owners and admins only', function () { + $store = shopStore(); + app()->instance('current_store', $store); + + foreach (['owner', 'admin'] as $role) { + $user = developersAdmin($store, $role); + expect(Gate::forUser($user)->allows('manage-developers'))->toBeTrue(); + } + + foreach (['staff', 'support'] as $role) { + $user = developersAdmin($store, $role); + expect(Gate::forUser($user)->allows('manage-developers'))->toBeFalse(); + } +}); + +it('forbids staff from opening developer settings', function () { + $store = shopStore(); + $staff = developersAdmin($store, 'staff'); + app()->instance('current_store', $store); + + Livewire::actingAs($staff)->test(Index::class)->assertForbidden(); +}); diff --git a/tests/Feature/AdminGeneralSettingsTest.php b/tests/Feature/AdminGeneralSettingsTest.php new file mode 100644 index 00000000..a3cd06d5 --- /dev/null +++ b/tests/Feature/AdminGeneralSettingsTest.php @@ -0,0 +1,76 @@ +create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + + return $user; +} + +it('saves locale and timezone defaults while showing the immutable store handle', function () { + $store = shopStore('general-settings.test'); + $user = generalSettingsOwner($store); + + Livewire::actingAs($user)->test(General::class) + ->assertSet('storeHandle', $store->handle) + ->assertSet('defaultLocale', 'en') + ->assertSeeHtml('id="settings-store-handle"') + ->assertSeeHtml('disabled') + ->assertSee('The store handle cannot be changed after creation.') + ->set('storeName', 'Updated Store') + ->set('contactEmail', 'support@example.test') + ->set('defaultCurrency', 'USD') + ->set('defaultLocale', 'de') + ->set('timezone', 'America/New_York') + ->call('save') + ->assertHasNoErrors(); + + expect($store->fresh()->name)->toBe('Updated Store') + ->and($store->fresh()->handle)->toBe($store->handle) + ->and($store->fresh()->default_currency)->toBe('USD') + ->and($store->fresh()->default_locale)->toBe('de') + ->and($store->fresh()->timezone)->toBe('America/New_York'); +}); + +it('rejects invalid locale and timezone values', function () { + $store = shopStore('general-settings-invalid.test'); + $user = generalSettingsOwner($store); + + Livewire::actingAs($user)->test(General::class) + ->set('contactEmail', 'support@example.test') + ->set('defaultLocale', 'zzzzzzz') + ->set('timezone', 'Mars/Nope') + ->call('save') + ->assertHasErrors(['defaultLocale', 'timezone']); + + expect($store->fresh()->default_locale)->toBe('en') + ->and($store->fresh()->timezone)->toBe('Europe/Berlin'); +}); + +it('shows navigable checkout and notifications settings tabs', function () { + $store = shopStore('general-settings-tabs.test'); + $user = generalSettingsOwner($store); + + Livewire::withQueryParams(['tab' => 'checkout']) + ->actingAs($user) + ->test(General::class) + ->assertSee('Checkout settings') + ->assertSee('Manage shipping') + ->assertSee('Manage taxes') + ->assertSee('Notifications') + ->assertSee('tab=notifications'); + + Livewire::withQueryParams(['tab' => 'notifications']) + ->actingAs($user) + ->test(General::class) + ->assertSee('Notification settings') + ->assertSee('No additional notification preferences are available for this store yet.') + ->assertSee('General'); +}); diff --git a/tests/Feature/AdminOperationsApiTest.php b/tests/Feature/AdminOperationsApiTest.php new file mode 100644 index 00000000..8f65b713 --- /dev/null +++ b/tests/Feature/AdminOperationsApiTest.php @@ -0,0 +1,142 @@ +tokens()->create([ + 'store_id' => $storeId, + 'name' => 'admin-operations-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +function attachAdminRole(User $user, \App\Models\Store $store, string $role = 'owner'): void +{ + $user->stores()->attach($store->id, ['role' => $role]); +} + +it('returns the authenticated store role and role-derived permission resource', function () { + $store = shopStore(); + $user = User::factory()->create(); + attachAdminRole($user, $store, 'staff'); + $token = adminOperationsToken($user, $store->id, ['read-settings']); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/me") + ->assertOk() + ->assertJsonPath('data.user_id', $user->id) + ->assertJsonPath('data.store_id', $store->id) + ->assertJsonPath('data.role', 'staff') + ->assertJsonPath('data.permissions.0', 'read-products') + ->assertJsonFragment(['read-analytics']); +}); + +it('queues store-scoped search reindexing and reports the documented index state', function () { + Queue::fake(); + $store = shopStore(); + Product::factory()->create(['store_id' => $store->id]); + $user = User::factory()->create(); + attachAdminRole($user, $store); + $token = adminOperationsToken($user, $store->id, ['write-settings', 'read-settings']); + $basePath = "/api/admin/v1/stores/{$store->id}/search"; + + $this->withToken($token)->postJson("{$basePath}/reindex") + ->assertAccepted() + ->assertJsonPath('message', 'Reindex job queued.') + ->assertJsonPath('status', 'queued') + ->assertJsonStructure(['job_id']); + Queue::assertPushed(ReindexStoreProducts::class, fn (ReindexStoreProducts $job): bool => $job->storeId === $store->id); + + $this->withToken($token)->postJson("{$basePath}/reindex") + ->assertConflict(); + + $this->withToken($token)->getJson("{$basePath}/status") + ->assertOk() + ->assertJsonPath('data.store_id', $store->id) + ->assertJsonPath('data.index_status', 'queued') + ->assertJsonPath('data.pending_updates', 1); +}); + +it('returns bounded analytics summaries, grouped periods, and top selling products', function () { + $store = shopStore(); + $product = shopProduct($store); + $user = User::factory()->create(); + attachAdminRole($user, $store); + $token = adminOperationsToken($user, $store->id, ['read-analytics']); + + DB::table('analytics_daily')->insert([ + [ + 'store_id' => $store->id, + 'date' => '2026-09-21', + 'orders_count' => 1, + 'revenue_amount' => 2500, + 'aov_amount' => 2500, + 'visits_count' => 10, + 'add_to_cart_count' => 4, + 'checkout_started_count' => 2, + 'checkout_completed_count' => 1, + ], + [ + 'store_id' => $store->id, + 'date' => '2026-09-22', + 'orders_count' => 1, + 'revenue_amount' => 2500, + 'aov_amount' => 2500, + 'visits_count' => 20, + 'add_to_cart_count' => 3, + 'checkout_started_count' => 2, + 'checkout_completed_count' => 1, + ], + ]); + $orderId = DB::table('orders')->insertGetId([ + 'store_id' => $store->id, + 'order_number' => 'API-ANALYTICS-1', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'EUR', + 'total_amount' => 2500, + 'placed_at' => '2026-09-21 12:00:00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('order_lines')->insert([ + 'order_id' => $orderId, + 'product_id' => $product['product']->id, + 'variant_id' => $product['variant']->id, + 'title_snapshot' => $product['product']->title, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + 'tax_lines_json' => '[]', + 'discount_allocations_json' => '[]', + ]); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/analytics/summary?from=2026-09-21&to=2026-09-22&granularity=week") + ->assertOk() + ->assertJsonPath('data.period.from', '2026-09-21') + ->assertJsonPath('data.summary.orders_count', 2) + ->assertJsonPath('data.summary.revenue_amount', 5000) + ->assertJsonPath('data.summary.aov_amount', 2500) + ->assertJsonPath('data.summary.conversion_rate', 0.0667) + ->assertJsonPath('data.summary.currency', 'EUR') + ->assertJsonPath('data.daily.0.date', '2026-09-21') + ->assertJsonPath('data.daily.0.visits_count', 30) + ->assertJsonPath('data.top_products.0.product_id', $product['product']->id) + ->assertJsonPath('data.top_products.0.units_sold', 1); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/analytics/summary?from=2025-09-22&to=2026-09-23") + ->assertUnprocessable() + ->assertJsonValidationErrors('to'); +}); diff --git a/tests/Feature/AdminOrderExportApiTest.php b/tests/Feature/AdminOrderExportApiTest.php new file mode 100644 index 00000000..3f3d93fe --- /dev/null +++ b/tests/Feature/AdminOrderExportApiTest.php @@ -0,0 +1,145 @@ +tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => 'order-export-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +function createOrderExportOrder(Store $store, string $number, string $email, string $status = 'paid'): Order +{ + $customer = Customer::factory()->create(['store_id' => $store->getKey(), 'email' => $email]); + + return Order::withoutGlobalScopes()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'order_number' => $number, + 'payment_method' => 'credit_card', + 'status' => $status, + 'financial_status' => $status === 'pending' ? 'pending' : 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => $store->default_currency, + 'subtotal_amount' => 5000, + 'total_amount' => 5000, + 'email' => $email, + 'placed_at' => now()->subDay(), + ]); +} + +it('queues store-scoped CSV exports and returns signed downloads when complete', function () { + Queue::fake(); + Storage::fake('local'); + $store = shopStore(); + $otherStore = shopStore('export-other.test'); + createOrderExportOrder($store, '#EXP-PAID-01', 'paid@example.test'); + createOrderExportOrder($store, '#EXP-PENDING-01', 'pending@example.test', 'pending'); + createOrderExportOrder($otherStore, '#EXP-FOREIGN-01', 'foreign@example.test'); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $token = orderExportApiToken($user, $store); + $basePath = "/api/admin/v1/stores/{$store->id}/exports"; + + $queued = $this->withToken($token)->postJson("{$basePath}/orders", [ + 'filters' => ['status' => 'paid', 'query' => 'paid@example.test'], + ])->assertAccepted() + ->assertJsonPath('status', 'queued') + ->assertJsonPath('export_id', fn ($id) => is_int($id)); + + $exportId = $queued->json('export_id'); + $export = OrderExport::query()->findOrFail($exportId); + expect($export->filters_json)->toBe(['status' => 'paid', 'query' => 'paid@example.test']); + Queue::assertPushed(GenerateOrderExport::class, fn (GenerateOrderExport $job): bool => $job->exportId === $exportId && $job->connection === 'database'); + + (new GenerateOrderExport($exportId))->handle(); + $export->refresh(); + Storage::disk('local')->assertExists($export->storage_key); + $csv = Storage::disk('local')->get($export->storage_key); + expect($export->status)->toBe('completed') + ->and($export->row_count)->toBe(1) + ->and($csv)->toContain('#EXP-PAID-01') + ->and($csv)->not->toContain('#EXP-PENDING-01') + ->and($csv)->not->toContain('#EXP-FOREIGN-01'); + + $csvLines = explode("\n", $csv); + expect(str_getcsv($csvLines[0]))->toBe([ + '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', + ]); + + $status = $this->withToken($token)->getJson("{$basePath}/{$exportId}") + ->assertOk() + ->assertJsonPath('data.status', 'completed') + ->assertJsonPath('data.row_count', 1) + ->assertJsonPath('data.format', 'csv'); + $downloadUrl = $status->json('data.download_url'); + expect($downloadUrl)->toBeString()->and($status->json('data.download_expires_at'))->not->toBeNull(); + + $download = $this->get($downloadUrl)->assertOk(); + expect($download->streamedContent())->toContain('#EXP-PAID-01') + ->and($download->headers->get('content-disposition'))->toContain("orders-export-{$exportId}.csv"); + $this->get($downloadUrl.'&tampered=1')->assertForbidden(); + + $foreignExport = OrderExport::factory()->create(['store_id' => $otherStore->id]); + $this->withToken($token)->getJson("{$basePath}/{$foreignExport->id}")->assertNotFound(); +}); + +it('validates export format, object filters, dates, and store-owned customers', function () { + Queue::fake(); + $store = shopStore(); + $otherStore = shopStore('export-validation-other.test'); + $otherCustomer = Customer::factory()->create(['store_id' => $otherStore->id]); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $token = orderExportApiToken($user, $store); + $url = "/api/admin/v1/stores/{$store->id}/exports/orders"; + + $this->withToken($token)->postJson($url, ['format' => 'xlsx'])->assertUnprocessable()->assertInvalid(['format']); + $this->withToken($token)->postJson($url, ['filters' => []])->assertUnprocessable()->assertInvalid(['filters']); + $this->withToken($token)->postJson($url, ['filters' => ['customer_id' => $otherCustomer->id]]) + ->assertUnprocessable() + ->assertInvalid(['filters.customer_id']); + $this->withToken($token)->postJson($url, ['filters' => ['created_after' => '2026-02-02', 'created_before' => '2026-02-01']]) + ->assertUnprocessable() + ->assertInvalid(['filters.created_before']); + + Queue::assertNothingPushed(); +}); + +it('requires read-orders access and only provides download links for completed exports', function () { + Queue::fake(); + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $readToken = orderExportApiToken($user, $store); + $writeOnlyToken = orderExportApiToken($user, $store, ['write-orders']); + $url = "/api/admin/v1/stores/{$store->id}/exports/orders"; + + $this->withToken($writeOnlyToken)->postJson($url, [])->assertForbidden(); + $queued = $this->withToken($readToken)->postJson($url, [])->assertAccepted(); + $exportId = $queued->json('export_id'); + + $this->withToken($readToken)->getJson("/api/admin/v1/stores/{$store->id}/exports/{$exportId}") + ->assertOk() + ->assertJsonPath('data.status', 'queued') + ->assertJsonPath('data.download_url', null) + ->assertJsonPath('data.download_expires_at', null); +}); diff --git a/tests/Feature/AdminOrdersApiTest.php b/tests/Feature/AdminOrdersApiTest.php new file mode 100644 index 00000000..a25af7f9 --- /dev/null +++ b/tests/Feature/AdminOrdersApiTest.php @@ -0,0 +1,158 @@ +tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => 'orders-api-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['read-orders', 'write-orders'], + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +/** @return array{order: Order, customer: Customer, line: OrderLine} */ +function makeOrdersApiOrder(Store $store, string $number, string $email, string $status = 'paid'): array +{ + $customer = Customer::factory()->create(['store_id' => $store->getKey(), 'email' => $email]); + $order = Order::query()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'order_number' => $number, + 'payment_method' => 'credit_card', + 'status' => $status, + 'financial_status' => $status === 'pending' ? 'pending' : 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => $store->default_currency, + 'subtotal_amount' => 5000, + 'total_amount' => 5000, + 'email' => $email, + 'billing_address_json' => ['first_name' => 'Jane', 'country_code' => 'DE'], + 'shipping_address_json' => ['first_name' => 'Jane', 'country_code' => 'DE'], + 'placed_at' => now()->subDay(), + ]); + $line = $order->lines()->create([ + 'title_snapshot' => 'Cotton shirt', + 'sku_snapshot' => 'COTTON-01', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + 'tax_lines_json' => [['rate' => 1900, 'amount' => 950, 'jurisdiction' => 'DE']], + 'discount_allocations_json' => [['code' => 'HELLO', 'amount' => 500]], + ]); + + return compact('order', 'customer', 'line'); +} + +it('filters and returns documented order list and detail resources', function () { + $store = shopStore(); + $orderData = makeOrdersApiOrder($store, '#API-1001', 'jane@example.test'); + makeOrdersApiOrder($store, '#API-1002', 'other@example.test', 'pending'); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = ordersApiToken($user, $store); + $baseUrl = "/api/admin/v1/stores/{$store->id}/orders"; + + $this->withToken($token)->getJson("{$baseUrl}?status=paid&financial_status=paid&fulfillment_status=unfulfilled&customer_id={$orderData['customer']->id}&query=jane@example.test&sort=total_desc&page=1&per_page=1") + ->assertOk() + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.per_page', 1) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.order_number', '#API-1001') + ->assertJsonPath('data.0.customer.email', 'jane@example.test') + ->assertJsonPath('data.0.line_count', 1); + + $this->withToken($token)->getJson("{$baseUrl}/{$orderData['order']->id}") + ->assertOk() + ->assertJsonPath('data.id', $orderData['order']->id) + ->assertJsonPath('data.lines.0.sku_snapshot', 'COTTON-01') + ->assertJsonPath('data.lines.0.tax_lines_json.0.rate', 1900) + ->assertJsonPath('data.customer.id', $orderData['customer']->id) + ->assertJsonPath('data.payments', []) + ->assertJsonPath('data.fulfillments', []) + ->assertJsonPath('data.refunds', []); +}); + +it('creates a shipped partial fulfillment and a documented partial refund', function () { + $store = shopStore(); + $orderData = makeOrdersApiOrder($store, '#API-FULFILL-1', 'fulfill@example.test'); + Payment::query()->create([ + 'order_id' => $orderData['order']->id, + 'provider' => 'mock', + 'method' => 'credit_card', + 'provider_payment_id' => 'mock-api-payment', + 'status' => 'captured', + 'amount' => 5000, + 'currency' => 'EUR', + 'created_at' => now(), + ]); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = ordersApiToken($user, $store); + $baseUrl = "/api/admin/v1/stores/{$store->id}/orders/{$orderData['order']->id}"; + + $this->withToken($token)->postJson("{$baseUrl}/fulfillments", [ + 'tracking_company' => 'DHL', + 'tracking_number' => 'TRACK-123', + 'tracking_url' => 'https://www.dhl.com/track/TRACK-123', + 'line_items' => [['order_line_id' => $orderData['line']->id, 'quantity' => 1]], + 'notify_customer' => false, + ]) + ->assertCreated() + ->assertJsonPath('data.order_id', $orderData['order']->id) + ->assertJsonPath('data.status', 'shipped') + ->assertJsonPath('data.line_items.0.order_line_id', $orderData['line']->id) + ->assertJsonPath('data.line_items.0.quantity', 1); + + $this->withToken($token)->postJson("{$baseUrl}/refunds", [ + 'amount' => 2500, + 'reason' => 'Return one item', + 'line_items' => [['order_line_id' => $orderData['line']->id, 'quantity' => 1]], + 'notify_customer' => false, + ]) + ->assertCreated() + ->assertJsonPath('data.order_id', $orderData['order']->id) + ->assertJsonPath('data.amount', 2500) + ->assertJsonPath('data.status', 'completed'); + + $this->withToken($token)->getJson($baseUrl) + ->assertOk() + ->assertJsonPath('data.fulfillments.0.status', 'shipped') + ->assertJsonPath('data.refunds.0.status', 'completed') + ->assertJsonPath('data.refunds.0.line_items.0.quantity', 1) + ->assertJsonPath('data.payments.0.status', 'partially_refunded'); + + $this->withToken($token)->postJson("{$baseUrl}/refunds", [ + 'amount' => 3000, + 'reason' => 'Too much', + ])->assertUnprocessable()->assertInvalid(['amount']); +}); + +it('rejects foreign order lines and returns conflict for an unpayable fulfillment', function () { + $store = shopStore(); + $pending = makeOrdersApiOrder($store, '#API-PENDING-1', 'pending@example.test', 'pending'); + $other = makeOrdersApiOrder($store, '#API-OTHER-1', 'other-line@example.test'); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = ordersApiToken($user, $store); + $baseUrl = "/api/admin/v1/stores/{$store->id}/orders/{$pending['order']->id}/fulfillments"; + + $this->withToken($token)->postJson($baseUrl, [ + 'line_items' => [['order_line_id' => $other['line']->id, 'quantity' => 1]], + ])->assertUnprocessable()->assertInvalid(['line_items.0.order_line_id']); + + $this->withToken($token)->postJson($baseUrl, [ + 'line_items' => [['order_line_id' => $pending['line']->id, 'quantity' => 1]], + ])->assertConflict(); +}); diff --git a/tests/Feature/AdminPagesAuthorizationTest.php b/tests/Feature/AdminPagesAuthorizationTest.php new file mode 100644 index 00000000..2168c3c4 --- /dev/null +++ b/tests/Feature/AdminPagesAuthorizationTest.php @@ -0,0 +1,37 @@ +create(); + $staff->stores()->attach($store->id, ['role' => 'staff']); + app()->instance('current_store', $store); + + Livewire::actingAs($staff)->test(PageIndex::class)->assertOk(); + + Livewire::actingAs($staff)->test(PageForm::class) + ->set('title', 'Staff-created page') + ->set('bodyHtml', '

Page content

') + ->set('status', 'published') + ->call('save') + ->assertHasNoErrors(); + + $page = Page::query()->where('title', 'Staff-created page')->firstOrFail(); + Livewire::actingAs($staff)->test(PageForm::class, ['page' => $page->id]) + ->set('title', 'Staff-updated page') + ->call('save') + ->assertHasNoErrors(); + + expect($page->fresh()->title)->toBe('Staff-updated page') + ->and(Gate::forUser($staff)->allows('delete', $page->fresh()))->toBeFalse(); + + Livewire::actingAs($staff)->test(PageIndex::class) + ->call('delete', $page->id) + ->assertForbidden(); +}); diff --git a/tests/Feature/AdminProductApiTest.php b/tests/Feature/AdminProductApiTest.php new file mode 100644 index 00000000..462b1030 --- /dev/null +++ b/tests/Feature/AdminProductApiTest.php @@ -0,0 +1,235 @@ +tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => 'product-api-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['read-products', 'write-products'], + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +it('creates a complete product resource with options, variants, inventory, and collections', function () { + $store = shopStore(); + $collection = Collection::query()->create([ + 'store_id' => $store->id, + 'title' => 'Summer', + 'handle' => 'summer', + 'type' => 'manual', + 'status' => 'active', + ]); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = productApiToken($user, $store); + + $response = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/products", [ + 'title' => 'Classic Tee', + 'handle' => 'classic-tee', + 'vendor' => 'Acme', + 'product_type' => 'Apparel', + 'status' => 'active', + 'tags' => ['organic', 'cotton'], + 'options' => [ + ['name' => 'Color', 'position' => 1], + ['name' => 'Size', 'position' => 2], + ], + 'variants' => [ + [ + 'sku' => 'TEE-BLU-S', + 'barcode' => '123456', + 'price_amount' => 2500, + 'compare_at_amount' => 3500, + 'currency' => 'EUR', + 'position' => 2, + 'weight_g' => 200, + 'requires_shipping' => true, + 'is_default' => true, + 'option_values' => [ + ['option_name' => 'Color', 'value' => 'Blue'], + ['option_name' => 'Size', 'value' => 'Small'], + ], + 'inventory' => ['quantity_on_hand' => 50, 'policy' => 'deny'], + ], + [ + 'sku' => 'TEE-RED-S', + 'price_amount' => 2600, + 'position' => 1, + 'option_values' => [ + ['option_name' => 'Color', 'value' => 'Red'], + ['option_name' => 'Size', 'value' => 'Small'], + ], + 'inventory' => ['quantity_on_hand' => 12, 'policy' => 'continue'], + ], + ], + 'collections' => [$collection->id], + ])->assertCreated() + ->assertJsonPath('data.title', 'Classic Tee') + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.options.0.name', 'Color') + ->assertJsonPath('data.options.0.values.1.value', 'Red') + ->assertJsonPath('data.variants.0.sku', 'TEE-RED-S') + ->assertJsonPath('data.variants.0.position', 1) + ->assertJsonPath('data.variants.0.inventory.policy', 'continue') + ->assertJsonPath('data.variants.1.sku', 'TEE-BLU-S') + ->assertJsonPath('data.variants.1.position', 2) + ->assertJsonPath('data.variants.1.option_values.1.value', 'Small') + ->assertJsonPath('data.variants.1.inventory.quantity_on_hand', 50) + ->assertJsonPath('data.collections.0.id', $collection->id); + + expect(Product::query()->where('store_id', $store->id)->where('handle', 'classic-tee')->exists())->toBeTrue(); + expect($response->json('data.variants'))->toHaveCount(2); +}); + +it('filters and paginates products by title, vendor, sku, collection, and requested sort', function () { + $store = shopStore(); + $collection = Collection::query()->create([ + 'store_id' => $store->id, + 'title' => 'Summer', + 'handle' => 'summer', + 'type' => 'manual', + 'status' => 'active', + ]); + $product = Product::factory()->create(['store_id' => $store->id, 'title' => 'Cotton Tee', 'vendor' => 'Acme']); + $variant = ProductVariant::factory()->for($product)->create(['sku' => 'COTTON-TEE-RED']); + $variant->inventoryItem()->create(['store_id' => $store->id, 'quantity_on_hand' => 8, 'policy' => 'deny']); + $product->collections()->attach($collection->id); + Product::factory()->create(['store_id' => $store->id, 'title' => 'Linen Pants', 'vendor' => 'Other']); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = productApiToken($user, $store); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/products?query=COTTON-TEE-RED&collection_id={$collection->id}&sort=title_asc&page=1&per_page=1") + ->assertOk() + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.per_page', 1) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.title', 'Cotton Tee') + ->assertJsonPath('data.0.variants_count', 1) + ->assertJsonPath('data.0.total_inventory', 8); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/products?query=Cotton") + ->assertOk() + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.title', 'Cotton Tee'); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/products?query=Acme") + ->assertOk() + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.vendor', 'Acme'); +}); + +it('updates variant inventory and archives a product through the documented delete endpoint', function () { + $store = shopStore(); + $productData = shopProduct($store, price: 2500, stock: 10); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = productApiToken($user, $store); + $baseUrl = "/api/admin/v1/stores/{$store->id}/products/{$productData['product']->id}"; + + $this->withToken($token)->putJson($baseUrl, [ + 'title' => 'Updated Shirt', + 'variants' => [[ + 'id' => $productData['variant']->id, + 'sku' => 'UPDATED-SKU', + 'price_amount' => 2700, + 'compare_at_amount' => 3500, + 'inventory' => ['quantity_on_hand' => 16, 'policy' => 'continue'], + ]], + ]) + ->assertOk() + ->assertJsonPath('data.title', 'Updated Shirt') + ->assertJsonPath('data.variants.0.sku', 'UPDATED-SKU') + ->assertJsonPath('data.variants.0.price_amount', 2700) + ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 16) + ->assertJsonPath('data.variants.0.inventory.policy', 'continue'); + + $this->withToken($token)->putJson($baseUrl, [ + 'variants' => [[ + 'id' => $productData['variant']->id, + 'inventory' => ['quantity_on_hand' => 18], + ]], + ]) + ->assertOk() + ->assertJsonPath('data.variants.0.sku', 'UPDATED-SKU') + ->assertJsonPath('data.variants.0.price_amount', 2700) + ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 18) + ->assertJsonPath('data.variants.0.inventory.policy', 'continue'); + + $this->withToken($token)->putJson($baseUrl, [ + 'variants' => [[ + 'id' => $productData['variant']->id, + 'price_amount' => 3600, + 'compare_at_amount' => null, + ]], + ]) + ->assertOk() + ->assertJsonPath('data.variants.0.price_amount', 3600) + ->assertJsonPath('data.variants.0.compare_at_amount', null); + + $this->withToken($token)->deleteJson($baseUrl) + ->assertOk() + ->assertJsonPath('data.id', $productData['product']->id) + ->assertJsonPath('data.status', 'archived'); +}); + +it('returns validation errors for malformed variants and hides products owned by other stores', function () { + $store = shopStore(); + $otherStore = shopStore('other-products-api.test'); + $otherProduct = Product::factory()->create(['store_id' => $otherStore->id]); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $token = productApiToken($user, $store); + $baseUrl = "/api/admin/v1/stores/{$store->id}/products"; + + $this->withToken($token)->postJson($baseUrl, ['title' => 'Bad', 'variants' => 'not-an-array']) + ->assertUnprocessable() + ->assertInvalid(['variants']); + + $this->withToken($token)->getJson("{$baseUrl}/{$otherProduct->id}")->assertNotFound(); + $this->withToken($token)->putJson("{$baseUrl}/{$otherProduct->id}", ['title' => 'Changed'])->assertNotFound(); + $this->withToken($token)->deleteJson("{$baseUrl}/{$otherProduct->id}")->assertNotFound(); + expect($otherProduct->refresh()->title)->not->toBe('Changed'); +}); + +it('rejects duplicate store skus, invalid collections, and invalid compare-at prices', function () { + $store = shopStore(); + $otherStore = shopStore('other-products.test'); + $existing = shopProduct($store); + $foreignCollection = Collection::query()->create([ + 'store_id' => $otherStore->id, + 'title' => 'Foreign', + 'handle' => 'foreign', + 'type' => 'manual', + 'status' => 'active', + ]); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $token = productApiToken($user, $store); + $url = "/api/admin/v1/stores/{$store->id}/products"; + $payload = [ + 'title' => 'New Shirt', + 'variants' => [['sku' => $existing['variant']->sku, 'price_amount' => 2500, 'is_default' => true]], + ]; + + $this->withToken($token)->postJson($url, $payload)->assertUnprocessable()->assertInvalid(['variants.sku']); + + $payload['variants'][0]['sku'] = 'NEW-SKU'; + $payload['variants'][0]['compare_at_amount'] = 2000; + $payload['collections'] = [$foreignCollection->id]; + $this->withToken($token)->postJson($url, $payload)->assertUnprocessable()->assertInvalid(['collections.0']); + + unset($payload['collections']); + $this->withToken($token)->postJson($url, $payload)->assertUnprocessable()->assertInvalid(['variants.0.compare_at_amount']); +}); diff --git a/tests/Feature/AdminProductFormTest.php b/tests/Feature/AdminProductFormTest.php new file mode 100644 index 00000000..d90d72bb --- /dev/null +++ b/tests/Feature/AdminProductFormTest.php @@ -0,0 +1,154 @@ +create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + + return $user; +} + +it('creates all option combinations and assigns the configured default variant values', function () { + $store = shopStore(); + $user = productFormOwner($store); + + Livewire::actingAs($user)->test(Form::class) + ->set('title', 'Everyday Cotton Tee') + ->set('handle', 'everyday-cotton-tee') + ->set('priceAmount', 3200) + ->set('compareAtAmount', 4000) + ->set('quantityOnHand', 12) + ->set('sku', 'TEE-DEFAULT') + ->set('options', [ + ['name' => 'Size', 'values' => 'S, M'], + ['name' => 'Color', 'values' => 'Black, White'], + ]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::query()->where('handle', 'everyday-cotton-tee')->with('variants.inventoryItem', 'variants.optionValues.option')->firstOrFail(); + + expect($product->variants)->toHaveCount(4) + ->and($product->options)->toHaveCount(2) + ->and($product->variants->firstWhere('is_default', true)->sku)->toBe('TEE-DEFAULT') + ->and($product->variants->first()->price_amount)->toBe(3200) + ->and($product->variants->first()->inventoryItem->quantity_on_hand)->toBe(12) + ->and($product->variants->where('is_default', false)->every(fn ($variant): bool => $variant->inventoryItem->quantity_on_hand === 0))->toBeTrue(); +}); + +it('rejects a SKU already used by another product in the current store', function () { + $store = shopStore(); + $user = productFormOwner($store); + $products = app(ProductService::class); + $first = $products->create($store, ['title' => 'First item', 'variants' => [['sku' => 'STORE-SKU-1', 'price_amount' => 2500]]]); + $products->create($store, ['title' => 'Second item', 'variants' => [['sku' => 'STORE-SKU-2', 'price_amount' => 2700]]]); + + Livewire::actingAs($user)->test(Form::class, ['product' => $first->id]) + ->set('variants.0.sku', 'STORE-SKU-2') + ->call('save') + ->assertHasErrors('variants'); + + expect($first->variants()->firstOrFail()->sku)->toBe('STORE-SKU-1'); +}); + +it('dispatches one product updated event when only a variant price and inventory are changed', function () { + $store = shopStore(); + $user = productFormOwner($store); + $product = app(ProductService::class)->create($store, [ + 'title' => 'Variant Edit Product', + 'variants' => [['price_amount' => 2500, 'quantity_on_hand' => 3]], + ]); + $variant = $product->variants()->firstOrFail(); + Event::fake([\App\Events\ProductUpdated::class]); + + Livewire::actingAs($user)->test(Form::class, ['product' => $product->id]) + ->set('variants.0.price_amount', 3100) + ->set('variants.0.quantity_on_hand', 8) + ->call('save') + ->assertHasNoErrors(); + + Event::assertDispatchedTimes(\App\Events\ProductUpdated::class, 1); + expect($variant->refresh()->price_amount)->toBe(3100) + ->and($variant->inventoryItem()->firstOrFail()->quantity_on_hand)->toBe(8); +}); + +it('rejects product option values without at least one non-empty value', function () { + $store = shopStore(); + $user = productFormOwner($store); + + Livewire::actingAs($user)->test(Form::class) + ->set('title', 'Invalid Option Product') + ->set('priceAmount', 1000) + ->set('options', [['name' => 'Size', 'values' => ', ,']]) + ->call('save') + ->assertHasErrors('options.0.values'); + + expect(Product::query()->where('title', 'Invalid Option Product')->exists())->toBeFalse(); +}); + +it('preserves allowed product formatting, removes unsafe markup, and validates compare-at pricing', function () { + $store = shopStore(); + $user = productFormOwner($store); + $form = Livewire::actingAs($user)->test(Form::class) + ->set('title', 'Formatted Product') + ->set('priceAmount', 2500) + ->set('compareAtAmount', 2500) + ->set('descriptionHtml', '

Soft cotton

') + ->call('save') + ->assertHasErrors('compareAtAmount'); + + expect(Product::query()->where('title', 'Formatted Product')->exists())->toBeFalse(); + + $form->set('compareAtAmount', 3000)->call('save')->assertHasNoErrors(); + $product = Product::query()->where('title', 'Formatted Product')->firstOrFail(); + + expect($product->description_html)->toContain('

') + ->and($product->description_html)->toContain('cotton') + ->and($product->description_html)->not->toContain('and($product->description_html)->not->toContain('onerror'); +}); + +it('uploads, labels, reorders, and deletes product media from the product editor', function () { + Storage::fake('public'); + Queue::fake(); + $store = shopStore(); + $user = productFormOwner($store); + $product = app(ProductService::class)->create($store, ['title' => 'Media Product', 'variants' => [['price_amount' => 1500]]]); + $component = Livewire::actingAs($user)->test(Form::class, ['product' => $product->id]) + ->set('uploads', [ + UploadedFile::fake()->image('front.png', 300, 300), + UploadedFile::fake()->image('back.png', 300, 300), + ]) + ->call('uploadMedia') + ->assertHasNoErrors(); + + $media = $product->media()->orderBy('position')->get(); + expect($media)->toHaveCount(2) + ->and($media->every(fn ($image): bool => $image->status === 'processing'))->toBeTrue(); + Queue::assertPushed(ProcessMediaUpload::class, 2); + + $first = $media[0]; + $second = $media[1]; + $component->call('updateMediaAlt', $first->id, 'Front view') + ->call('moveMedia', $second->id, -1) + ->assertHasNoErrors(); + + expect($product->media()->orderBy('position')->pluck('id')->all())->toBe([$second->id, $first->id]) + ->and($first->fresh()->alt_text)->toBe('Front view'); + + $component->call('deleteMedia', $second->id)->assertHasNoErrors(); + expect($product->media()->count())->toBe(1) + ->and(Storage::disk('public')->exists($second->storage_key))->toBeFalse(); +}); diff --git a/tests/Feature/AdminShippingSettingsTest.php b/tests/Feature/AdminShippingSettingsTest.php new file mode 100644 index 00000000..81d8b177 --- /dev/null +++ b/tests/Feature/AdminShippingSettingsTest.php @@ -0,0 +1,90 @@ +create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + + return $user; +} + +it('manages shipping zones, tiered rates, and address tests for the current store', function () { + $store = shopStore(); + $user = shippingSettingsOwner($store); + $component = Livewire::actingAs($user)->test(Shipping::class) + ->set('zoneName', 'Berlin') + ->set('countries', 'de') + ->set('regions', 'DE-BE') + ->call('saveZone') + ->assertHasNoErrors(); + $zone = ShippingZone::query()->where('name', 'Berlin')->firstOrFail(); + + expect($zone->countries)->toBe(['DE'])->and($zone->regions)->toBe(['DE-BE']); + + $component->set('zoneId', $zone->id) + ->set('rateName', 'Parcel') + ->set('rateType', 'weight') + ->set('weightTiers', [ + ['min_weight_g' => 0, 'max_weight_g' => 500, 'price_amount' => 499], + ['min_weight_g' => 501, 'max_weight_g' => '', 'price_amount' => 799], + ]) + ->call('saveRate') + ->assertHasNoErrors(); + + $rate = $zone->rates()->firstOrFail(); + expect($rate->type)->toBe('weight') + ->and($rate->config_json['tiers'])->toBe([ + ['min_weight_g' => 0, 'max_weight_g' => 500, 'price_amount' => 499], + ['min_weight_g' => 501, 'max_weight_g' => null, 'price_amount' => 799], + ]); + + $component->set('testCountryCode', 'DE') + ->set('testRegionCode', 'BE') + ->set('testCity', 'Berlin') + ->set('testPostalCode', '10115') + ->set('testWeightGrams', 250) + ->call('testShippingAddress') + ->assertHasNoErrors() + ->assertSet('testResult.zone', 'Berlin') + ->assertSet('testResult.rates.0.price_amount', 499); + + $component->set('testCountryCode', 'US') + ->call('testShippingAddress') + ->assertSet('testResult', null) + ->assertSet('hasTestedAddress', true); +}); + +it('edits and deletes rates while preventing access to rates in another store', function () { + $store = shopStore(); + $otherStore = shopStore('shipping-foreign.test'); + $user = shippingSettingsOwner($store); + $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'name' => 'Local']); + $foreignZone = ShippingZone::factory()->create(['store_id' => $otherStore->id, 'name' => 'Foreign']); + $rate = $zone->rates()->create(['name' => 'Economy', 'type' => 'flat', 'price_amount' => 500]); + $foreignRate = $foreignZone->rates()->create(['name' => 'Foreign', 'type' => 'flat', 'price_amount' => 400]); + expect(fn () => Livewire::actingAs($user)->test(Shipping::class)->call('editRate', $foreignRate->id)) + ->toThrow(ModelNotFoundException::class); + + $component = Livewire::actingAs($user)->test(Shipping::class); + $component->call('editRate', $rate->id) + ->set('rateName', 'Priority') + ->set('rateAmount', 900) + ->call('saveRate') + ->assertHasNoErrors(); + + expect($rate->fresh()->name)->toBe('Priority') + ->and($rate->fresh()->price_amount)->toBe(900); + + $component->call('toggleRate', $rate->id)->assertHasNoErrors(); + expect($rate->fresh()->is_active)->toBeFalse(); + $component->call('deleteRate', $rate->id)->assertHasNoErrors(); + expect($zone->rates()->exists())->toBeFalse() + ->and($foreignZone->rates()->count())->toBe(1); +}); diff --git a/tests/Feature/AdminStoreConfigurationApiTest.php b/tests/Feature/AdminStoreConfigurationApiTest.php new file mode 100644 index 00000000..e054c802 --- /dev/null +++ b/tests/Feature/AdminStoreConfigurationApiTest.php @@ -0,0 +1,345 @@ +tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => 'store-configuration-api-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +/** @param array $manifest + * @return array{file: UploadedFile, path: string} + */ +function themeZipUpload(array $manifest, array $files = []): array +{ + $path = tempnam(sys_get_temp_dir(), 'shop-theme-'); + $zip = new \ZipArchive; + $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('theme.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + + foreach ($files as $filePath => $content) { + $zip->addFromString($filePath, $content); + } + + $zip->close(); + + return [ + 'file' => new UploadedFile($path, 'theme.zip', 'application/zip', UPLOAD_ERR_OK, true), + 'path' => $path, + ]; +} + +it('creates filters updates and deletes store pages with tenant-safe handles and metadata', function () { + $store = shopStore(); + $otherStore = shopStore('other-pages.test'); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = storeConfigurationApiToken($user, $store, ['read-content', 'write-content']); + $otherPage = Page::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'title' => 'Other Store Page', + 'handle' => 'other-store-page', + 'status' => 'published', + ]); + + $created = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/pages", [ + 'title' => 'Shipping Policy', + 'body_html' => '

Shipping

Details

', + 'status' => 'published', + ])->assertCreated() + ->assertJsonPath('data.title', 'Shipping Policy') + ->assertJsonPath('data.handle', 'shipping-policy') + ->assertJsonPath('data.status', 'published'); + + $pageId = $created->json('data.id'); + $page = Page::query()->findOrFail($pageId); + expect($created->json('data.published_at'))->not->toBeNull() + ->and($page->body_html)->toBe('

Shipping

Details

'); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/pages", [ + 'title' => 'Duplicate Handle', + 'handle' => 'shipping-policy', + ])->assertUnprocessable()->assertJsonValidationErrors('handle'); + + $duplicateTitle = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/pages", [ + 'title' => 'Shipping Policy', + ])->assertCreated(); + expect($duplicateTitle->json('data.handle'))->toBe('shipping-policy-1'); + + Page::query()->create(['store_id' => $store->id, 'title' => 'Draft', 'handle' => 'draft', 'status' => 'draft']); + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/pages?status=published&per_page=1") + ->assertOk() + ->assertJsonPath('data.0.id', $pageId) + ->assertJsonPath('meta.per_page', 1) + ->assertJsonPath('meta.total', 1); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/pages/{$pageId}", [ + 'title' => 'Shipping and Returns', + 'status' => 'draft', + ])->assertOk() + ->assertJsonPath('data.title', 'Shipping and Returns') + ->assertJsonPath('data.status', 'draft') + ->assertJsonPath('data.published_at', null); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/pages/{$otherPage->id}", [ + 'title' => 'Cross Tenant Update', + ])->assertNotFound(); + + $this->withToken($token)->deleteJson("/api/admin/v1/stores/{$store->id}/pages/{$pageId}") + ->assertOk() + ->assertExactJson(['message' => 'Page deleted']); + expect(Page::query()->whereKey($pageId)->exists())->toBeFalse(); +}); + +it('allows staff to list create and update pages while reserving deletion to owners and admins', function () { + $store = shopStore(); + $staff = User::factory()->create(); + $staff->stores()->attach($store->id, ['role' => 'staff']); + $token = storeConfigurationApiToken($staff, $store, ['read-content', 'write-content']); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/pages") + ->assertOk(); + + $created = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/pages", [ + 'title' => 'Staff Page', + 'status' => 'draft', + ])->assertCreated(); + $pageId = $created->json('data.id'); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/pages/{$pageId}", [ + 'title' => 'Updated by Staff', + ])->assertOk()->assertJsonPath('data.title', 'Updated by Staff'); + + $this->withToken($token)->deleteJson("/api/admin/v1/stores/{$store->id}/pages/{$pageId}") + ->assertForbidden(); + + expect(Page::query()->whereKey($pageId)->exists())->toBeTrue(); +}); + +it('sanitizes page html before the API persists create and update payloads', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = storeConfigurationApiToken($user, $store, ['read-content', 'write-content']); + + $created = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/pages", [ + 'title' => 'Sanitized Page', + 'body_html' => '

Safe title

Safe copy

', + ])->assertCreated(); + $page = Page::query()->findOrFail($created->json('data.id')); + expect($page->body_html)->toBe('

Safe title

Safe copy

'); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/pages/{$page->id}", [ + 'body_html' => '

Updated

', + ])->assertOk(); + + expect($page->fresh()->body_html)->toBe('

Updated

'); +}); + +it('writes non-overlapping shipping zones and type-specific rates for the selected store', function () { + $store = shopStore(); + $otherStore = shopStore('other-shipping.test'); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = storeConfigurationApiToken($user, $store, ['read-settings', 'write-settings']); + $foreignZone = ShippingZone::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'name' => 'Foreign zone', + 'countries' => ['FR'], + ]); + ShippingZone::query()->create(['store_id' => $store->id, 'name' => 'France', 'countries' => ['FR']]); + + $created = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones", [ + 'name' => 'Germany', + 'countries_json' => ['de'], + 'regions_json' => ['BE', 'BY'], + ])->assertCreated() + ->assertJsonPath('data.name', 'Germany') + ->assertJsonPath('data.countries_json.0', 'DE') + ->assertJsonPath('data.regions_json.1', 'BY'); + + $zoneId = $created->json('data.id'); + expect(ShippingZone::query()->findOrFail($zoneId)->countries)->toBe(['DE']); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones", [ + 'name' => 'Duplicate Germany', + 'countries_json' => ['DE'], + ])->assertUnprocessable()->assertJsonValidationErrors('countries_json'); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones", [ + 'name' => 'Unknown country', + 'countries_json' => ['ZZ'], + ])->assertUnprocessable()->assertJsonValidationErrors('countries_json.0'); + + $flatRate = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones/{$zoneId}/rates", [ + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['price_amount' => 500, 'currency' => 'EUR'], + ])->assertCreated()->assertJsonPath('data.config_json.price_amount', 500); + + $weightRate = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones/{$zoneId}/rates", [ + 'name' => 'Heavy parcel', + 'type' => 'weight', + 'config_json' => [ + 'currency' => 'EUR', + 'tiers' => [['min_weight_g' => 0, 'max_weight_g' => 1000, 'price_amount' => 800]], + ], + ])->assertCreated()->assertJsonPath('data.type', 'weight'); + + expect(ShippingRate::query()->findOrFail($flatRate->json('data.id'))->price_amount)->toBe(500) + ->and(ShippingRate::query()->findOrFail($weightRate->json('data.id'))->price_amount)->toBe(800); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/shipping/zones/{$zoneId}", [ + 'name' => 'Germany South', + 'countries_json' => ['DE'], + 'regions_json' => ['BW'], + ])->assertOk()->assertJsonPath('data.name', 'Germany South'); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/shipping/zones/{$zoneId}", [ + 'name' => 'Overlapping Zone', + 'countries_json' => ['FR'], + ])->assertUnprocessable()->assertJsonValidationErrors('countries_json'); + + $this->withToken($token)->getJson("/api/admin/v1/stores/{$store->id}/shipping/zones") + ->assertOk() + ->assertJsonPath('data.1.name', 'Germany South') + ->assertJsonPath('data.1.rates.0.name', 'Standard'); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones/{$foreignZone->id}/rates", [ + 'name' => 'Should not save', + 'type' => 'flat', + 'config_json' => ['price_amount' => 100], + ])->assertNotFound(); +}); + +it('installs validates configures and publishes a store-scoped theme archive', function () { + $store = shopStore(); + $otherStore = shopStore('other-themes.test'); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = storeConfigurationApiToken($user, $store, ['write-themes']); + $manifest = [ + 'name' => 'Aurora', + 'version' => '1.2.3', + 'templates' => ['templates/home.html', 'templates/product.html'], + 'settings_schema' => [ + 'type' => 'object', + 'required' => ['colors'], + 'properties' => [ + 'colors' => [ + 'type' => 'object', + 'required' => ['primary'], + 'properties' => ['primary' => ['type' => 'string']], + ], + ], + 'additionalProperties' => false, + ], + ]; + $archive = themeZipUpload($manifest, [ + 'templates/home.html' => '
Home
', + 'templates/product.html' => '
Product
', + 'assets/theme.css' => 'body { color: #222; }', + ]); + $invalidArchive = themeZipUpload($manifest, ['templates/home.html' => '
Home
']); + + try { + $created = $this->withToken($token)->post("/api/admin/v1/stores/{$store->id}/themes", [ + 'file' => $archive['file'], + ], ['Accept' => 'application/json']) + ->assertCreated() + ->assertJsonPath('data.name', 'Aurora') + ->assertJsonPath('data.version', '1.2.3') + ->assertJsonPath('data.status', 'draft') + ->assertJsonPath('data.published_at', null); + + $themeId = $created->json('data.id'); + $theme = Theme::query()->findOrFail($themeId); + $themeFiles = DB::table('theme_files')->where('theme_id', $themeId)->get(); + expect($theme->store_id)->toBe($store->id) + ->and($theme->version)->toBe('1.2.3') + ->and($themeFiles)->toHaveCount(4); + + foreach ($themeFiles as $themeFile) { + expect($themeFile->storage_key)->toBe('themes/'.$themeId.'/'.$themeFile->path) + ->and($themeFile->sha256)->toBe(hash('sha256', $themeFile->content)) + ->and((int) $themeFile->byte_size)->toBe(strlen($themeFile->content)); + } + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/themes/{$themeId}/settings", [ + 'settings_json' => ['colors' => ['primary' => '#1a73e8']], + ])->assertOk()->assertJsonPath('data.settings_json.colors.primary', '#1a73e8'); + + $this->withToken($token)->putJson("/api/admin/v1/stores/{$store->id}/themes/{$themeId}/settings", [ + 'settings_json' => ['colors' => ['primary' => 7]], + ])->assertUnprocessable()->assertJsonValidationErrors('settings_json.colors.primary'); + + $this->withToken($token)->post("/api/admin/v1/stores/{$store->id}/themes", [ + 'file' => $invalidArchive['file'], + ], ['Accept' => 'application/json'])->assertUnprocessable()->assertJsonValidationErrors('file'); + + $previousTheme = Theme::query()->create([ + 'store_id' => $store->id, + 'name' => 'Previous theme', + 'status' => 'published', + 'is_active' => true, + ]); + $otherTheme = Theme::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'name' => 'Private Theme', + 'status' => 'draft', + ]); + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/themes/{$otherTheme->id}/publish") + ->assertNotFound(); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/themes/{$themeId}/publish") + ->assertOk() + ->assertJsonPath('data.id', $themeId) + ->assertJsonPath('data.status', 'published'); + + expect($theme->fresh()->status)->toBe('published') + ->and($theme->fresh()->is_active)->toBeTrue() + ->and($theme->fresh()->published_at)->not->toBeNull() + ->and($previousTheme->fresh()->status)->toBe('draft') + ->and($previousTheme->fresh()->is_active)->toBeFalse(); + } finally { + @unlink($archive['path']); + @unlink($invalidArchive['path']); + } +}); + +it('returns 403 when support users attempt page and store configuration writes', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'support']); + $token = storeConfigurationApiToken($user, $store, ['write-content', 'write-settings', 'write-themes']); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/pages", ['title' => 'Blocked']) + ->assertForbidden(); + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/shipping/zones", [ + 'name' => 'Blocked', + 'countries_json' => ['DE'], + ])->assertForbidden(); + $this->withToken($token)->post("/api/admin/v1/stores/{$store->id}/themes", [], ['Accept' => 'application/json']) + ->assertForbidden(); + + expect(Page::query()->exists())->toBeFalse() + ->and(ShippingZone::query()->exists())->toBeFalse() + ->and(Theme::query()->exists())->toBeFalse(); +}); diff --git a/tests/Feature/AdminTaxSettingsApiTest.php b/tests/Feature/AdminTaxSettingsApiTest.php new file mode 100644 index 00000000..f6a2866c --- /dev/null +++ b/tests/Feature/AdminTaxSettingsApiTest.php @@ -0,0 +1,89 @@ +tokens()->create([ + 'store_id' => $storeId, + 'name' => 'tax-settings-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['read-settings', 'write-settings'], + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +it('returns the documented tax settings resource and saves provider configuration', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = taxSettingsToken($user, $store->id); + $url = "/api/admin/v1/stores/{$store->id}/tax/settings"; + + $this->withToken($token)->getJson($url) + ->assertOk() + ->assertJsonPath('data.store_id', $store->id) + ->assertJsonPath('data.mode', 'manual') + ->assertJsonPath('data.provider', 'none') + ->assertJsonPath('data.config_json.default_tax_rate', 0) + ->assertJsonPath('data.config_json.tax_rates', []); + + $this->withToken($token)->putJson($url, [ + 'mode' => 'provider', + 'provider' => 'stripe_tax', + 'prices_include_tax' => false, + 'config_json' => [ + 'stripe_tax_settings_id' => 'txr_shop_123', + 'fallback' => 'block', + 'default_tax_rate' => 1900, + 'tax_rates' => [ + ['country_code' => 'DE', 'rate' => 1900, 'name' => 'MwSt', 'shipping_taxed' => true], + ['country_code' => 'AT', 'rate' => 2000], + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.mode', 'provider') + ->assertJsonPath('data.provider', 'stripe_tax') + ->assertJsonPath('data.config_json.fallback', 'block') + ->assertJsonPath('data.config_json.tax_rates.0.country_code', 'DE'); + + $this->withToken($token)->getJson($url) + ->assertOk() + ->assertJsonPath('data.config_json.default_tax_rate', 1900) + ->assertJsonPath('data.config_json.tax_rates.1.rate', 2000); + + $settings = TaxSetting::query()->findOrFail($store->id); + expect($settings->taxRatesForCalculator())->toBe(['DE' => 1900, 'AT' => 2000]) + ->and($settings->defaultTaxRateForCalculator())->toBe(1900); +}); + +it('validates tax mode, provider, rate bounds, and object-shaped configuration', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = taxSettingsToken($user, $store->id); + $url = "/api/admin/v1/stores/{$store->id}/tax/settings"; + + $this->withToken($token)->putJson($url, [ + 'mode' => 'provider', + 'prices_include_tax' => true, + 'config_json' => ['default_tax_rate' => 10001], + ])->assertUnprocessable()->assertInvalid(['provider', 'config_json.default_tax_rate']); + + $this->withToken($token)->call('PUT', $url, [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer '.$token, + ], json_encode([ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => [], + ], JSON_THROW_ON_ERROR))->assertUnprocessable()->assertInvalid(['config_json']); +}); diff --git a/tests/Feature/AdminTaxSettingsTest.php b/tests/Feature/AdminTaxSettingsTest.php new file mode 100644 index 00000000..84745c8c --- /dev/null +++ b/tests/Feature/AdminTaxSettingsTest.php @@ -0,0 +1,96 @@ +create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + + return $user; +} + +it('saves multiple country and region tax rates as integer basis points', function () { + $store = shopStore(); + $user = taxSettingsOwner($store); + + Livewire::actingAs($user)->test(Taxes::class) + ->set('mode', 'manual') + ->set('pricesIncludeTax', false) + ->set('defaultRatePercentage', '20.00') + ->set('manualRates', [ + ['zone_name' => 'DE', 'rate_percentage' => '19.00'], + ['zone_name' => 'US-CA', 'rate_percentage' => '7.25'], + ]) + ->call('save') + ->assertHasNoErrors(); + + $settings = TaxSetting::query()->where('store_id', $store->id)->firstOrFail(); + expect($settings->mode)->toBe('manual') + ->and($settings->defaultTaxRateForCalculator())->toBe(2000) + ->and($settings->prices_include_tax)->toBeFalse() + ->and($settings->config_json['tax_rates'])->toBe([ + ['country_code' => 'DE', 'rate' => 1900], + ['country_code' => 'US', 'province_code' => 'CA', 'rate' => 725], + ]) + ->and(app(TaxCalculator::class)->rateForAddress($settings->taxRatesForCalculator(), 'US', 'CA'))->toBe(725) + ->and(app(TaxCalculator::class)->rateForAddress($settings->taxRatesForCalculator(), 'DE'))->toBe(1900); +}); + +it('encrypts provider credentials and preserves them when the key field is left blank', function () { + $store = shopStore(); + $user = taxSettingsOwner($store); + $plainTextKey = 'sk_test_tax_secret_value'; + + Livewire::actingAs($user)->test(Taxes::class) + ->set('mode', 'provider') + ->set('provider', 'stripe_tax') + ->set('providerApiKey', $plainTextKey) + ->set('fallback', 'block') + ->call('save') + ->assertHasNoErrors(); + + $settings = TaxSetting::query()->where('store_id', $store->id)->firstOrFail(); + $cipherText = $settings->config_json['provider_api_key_encrypted']; + expect($settings->mode)->toBe('provider') + ->and($settings->provider)->toBe('stripe_tax') + ->and($cipherText)->not->toBe($plainTextKey) + ->and(Crypt::decryptString($cipherText))->toBe($plainTextKey) + ->and($settings->config_json['fallback'])->toBe('block'); + + Livewire::actingAs($user)->test(Taxes::class) + ->assertSet('hasSavedProviderKey', true) + ->assertSet('providerApiKey', '') + ->set('mode', 'manual') + ->set('manualRates', [['zone_name' => 'DE', 'rate_percentage' => '19.00']]) + ->call('save') + ->assertHasNoErrors(); + + expect(TaxSetting::query()->where('store_id', $store->id)->firstOrFail()->config_json['provider_api_key_encrypted'])->toBe($cipherText); +}); + +it('rejects duplicate or malformed tax zones and requires credentials for a new provider setup', function () { + $store = shopStore(); + $user = taxSettingsOwner($store); + $component = Livewire::actingAs($user)->test(Taxes::class) + ->set('manualRates', [ + ['zone_name' => 'DE', 'rate_percentage' => '19.00'], + ['zone_name' => 'de', 'rate_percentage' => '20.00'], + ]) + ->call('save'); + + expect(TaxSetting::query()->where('store_id', $store->id)->exists())->toBeFalse(); + $component->assertHasErrors('manualRates.1.zone_name'); + + Livewire::actingAs($user)->test(Taxes::class) + ->set('mode', 'provider') + ->set('provider', 'stripe_tax') + ->call('save') + ->assertHasErrors('providerApiKey'); +}); diff --git a/tests/Feature/AdminThemesTest.php b/tests/Feature/AdminThemesTest.php new file mode 100644 index 00000000..f8c867e9 --- /dev/null +++ b/tests/Feature/AdminThemesTest.php @@ -0,0 +1,202 @@ +create(); + $user->stores()->attach($store->getKey(), ['role' => $role]); + app()->instance('current_store', $store); + + return $user; +} + +function createAdminTheme(Store $store, string $name, string $status = 'draft', bool $active = false): Theme +{ + return Theme::query()->create([ + 'store_id' => $store->getKey(), + 'name' => $name, + 'version' => '1.0.0', + 'status' => $status, + 'is_active' => $active, + ]); +} + +it('lists only current-store themes and keeps theme management behind its role gate', function () { + $store = shopStore('theme-list.test'); + $otherStore = shopStore('theme-list-other.test'); + $owner = themeAdminUser($store); + createAdminTheme($store, 'Store theme', 'published', true); + createAdminTheme($otherStore, 'Private theme', 'draft'); + + Livewire::actingAs($owner)->test(Index::class) + ->assertSee('Store theme') + ->assertDontSee('Private theme') + ->assertSee('Customize'); + + $staff = themeAdminUser($store, 'staff'); + + $this->actingAs($staff) + ->withSession(['current_store_id' => $store->getKey()]) + ->get(route('admin.themes')) + ->assertForbidden(); +}); + +it('publishes, duplicates, and deletes themes without crossing store boundaries', function () { + $store = shopStore('theme-actions.test'); + $otherStore = shopStore('theme-actions-other.test'); + $owner = themeAdminUser($store); + $activeTheme = createAdminTheme($store, 'Current theme', 'published', true); + $draftTheme = createAdminTheme($store, 'New theme'); + $foreignTheme = createAdminTheme($otherStore, 'Foreign theme', 'published', true); + $draftTheme->settings()->create([ + 'settings_json' => ['colors' => ['primary' => '#123456']], + 'updated_at' => now(), + ]); + + $content = '{"name":"Fixture theme"}'; + DB::table('theme_files')->insert([ + 'theme_id' => $draftTheme->getKey(), + 'path' => 'theme.json', + 'content' => $content, + 'storage_key' => 'themes/'.$draftTheme->getKey().'/theme.json', + 'sha256' => hash('sha256', $content), + 'byte_size' => strlen($content), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(fn () => Livewire::actingAs($owner)->test(Index::class) + ->call('publishTheme', $foreignTheme->getKey())) + ->toThrow(ModelNotFoundException::class); + + expect($foreignTheme->fresh()->is_active)->toBeTrue(); + + Livewire::actingAs($owner)->test(Index::class) + ->call('publishTheme', $draftTheme->getKey()) + ->assertHasNoErrors(); + + expect($draftTheme->fresh()->status)->toBe('published') + ->and($draftTheme->fresh()->is_active)->toBeTrue() + ->and($draftTheme->fresh()->published_at)->not->toBeNull() + ->and($activeTheme->fresh()->status)->toBe('draft') + ->and($activeTheme->fresh()->is_active)->toBeFalse() + ->and($foreignTheme->fresh()->is_active)->toBeTrue(); + + Livewire::actingAs($owner)->test(Index::class) + ->call('duplicateTheme', $draftTheme->getKey()) + ->assertHasNoErrors(); + + $copy = Theme::query()->where('store_id', $store->getKey())->where('name', 'New theme (Copy)')->firstOrFail(); + $copyFile = DB::table('theme_files')->where('theme_id', $copy->getKey())->where('path', 'theme.json')->first(); + + expect($copy->status)->toBe('draft') + ->and($copy->is_active)->toBeFalse() + ->and($copy->published_at)->toBeNull() + ->and($copy->settings->settings_json)->toBe(['colors' => ['primary' => '#123456']]) + ->and($copyFile->content)->toBe($content) + ->and($copyFile->storage_key)->toBe('themes/'.$copy->getKey().'/theme.json') + ->and($copyFile->sha256)->toBe(hash('sha256', $content)) + ->and((int) $copyFile->byte_size)->toBe(strlen($content)); + + Livewire::actingAs($owner)->test(Index::class) + ->call('deleteTheme', $copy->getKey()) + ->assertHasNoErrors(); + + expect(Theme::query()->whereKey($copy->getKey())->exists())->toBeFalse() + ->and(DB::table('theme_files')->where('theme_id', $copy->getKey())->exists())->toBeFalse(); + + expect(fn () => Livewire::actingAs($owner)->test(Index::class) + ->call('deleteTheme', $foreignTheme->getKey())) + ->toThrow(ModelNotFoundException::class); + + expect(Theme::withoutGlobalScopes()->whereKey($foreignTheme->getKey())->exists())->toBeTrue(); +}); + +it('edits settings from a theme schema and rejects values with the wrong type', function () { + $store = shopStore('theme-editor.test'); + $owner = themeAdminUser($store); + $theme = createAdminTheme($store, 'Schema theme'); + $manifest = json_encode([ + 'name' => 'Schema theme', + 'version' => '1.0.0', + 'templates' => ['templates/home.blade.php'], + 'settings_schema' => [ + 'type' => 'object', + 'required' => ['header'], + 'properties' => [ + 'header' => [ + 'type' => 'object', + 'title' => 'Header', + 'required' => ['store_name'], + 'properties' => [ + 'store_name' => ['type' => 'string', 'title' => 'Store name', 'default' => 'My store'], + 'background' => ['type' => 'string', 'format' => 'color', 'title' => 'Background color', 'default' => '#112233'], + ], + ], + ], + ], + ], JSON_THROW_ON_ERROR); + + DB::table('theme_files')->insert([ + 'theme_id' => $theme->getKey(), + 'path' => 'theme.json', + 'content' => $manifest, + 'storage_key' => 'themes/'.$theme->getKey().'/theme.json', + 'sha256' => hash('sha256', $manifest), + 'byte_size' => strlen($manifest), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $theme->settings()->create([ + 'settings_json' => ['header' => ['store_name' => 'Old name', 'background' => '#112233'], 'legacy' => 'keep'], + 'updated_at' => now(), + ]); + + $component = Livewire::actingAs($owner)->test(Editor::class, ['theme' => $theme]); + $component->assertSee('Header') + ->assertSee('Store name') + ->assertSet('selectedSection', 'header') + ->assertSet('sectionSettings.setting_header_store_name', 'Old name') + ->set('sectionSettings.setting_header_store_name', 'Updated shop') + ->assertSet('sectionSettings.setting_header_store_name', 'Updated shop') + ->call('save') + ->assertHasNoErrors(); + + expect($theme->fresh()->settings->settings_json)->toBe([ + 'header' => ['store_name' => 'Updated shop', 'background' => '#112233'], + 'legacy' => 'keep', + ]); + + $component->call('saveAndPublish')->assertHasNoErrors(); + expect($theme->fresh()->status)->toBe('published') + ->and($theme->fresh()->is_active)->toBeTrue() + ->and($theme->fresh()->published_at)->not->toBeNull(); + + $component->set('sectionSettings.setting_header_background', 'invalid') + ->call('save') + ->assertHasErrors('settings.header.background'); + + expect($theme->fresh()->settings->settings_json['header']['background'])->toBe('#112233'); +}); + +it('renders the full-page editor route for an authorized store manager', function () { + $store = shopStore('theme-editor-route.test'); + $owner = themeAdminUser($store); + $theme = createAdminTheme($store, 'Route theme'); + + $this->actingAs($owner) + ->withSession(['current_store_id' => $store->getKey()]) + ->get(route('admin.themes.editor', ['theme' => $theme->getKey()])) + ->assertOk() + ->assertSee('Back to themes') + ->assertSee('Live preview') + ->assertSee('Save and publish'); +}); diff --git a/tests/Feature/AnalyticsAggregationTest.php b/tests/Feature/AnalyticsAggregationTest.php new file mode 100644 index 00000000..8457a99b --- /dev/null +++ b/tests/Feature/AnalyticsAggregationTest.php @@ -0,0 +1,88 @@ + 'page_view', 'session_id' => 'session-a'], + ['type' => 'page_view', 'session_id' => 'session-a'], + ['type' => 'page_view', 'session_id' => 'session-b'], + ['type' => 'add_to_cart', 'session_id' => 'session-a'], + ['type' => 'add_to_cart', 'session_id' => 'session-b'], + ['type' => 'add_to_cart', 'session_id' => 'session-c'], + ['type' => 'checkout_started', 'session_id' => 'session-a'], + ['type' => 'checkout_completed', 'session_id' => 'session-a', 'properties_json' => ['order_total_amount' => 1299]], + ['type' => 'checkout_completed', 'session_id' => 'session-b', 'properties_json' => ['total_amount' => 2500]], + ['type' => 'checkout_completed', 'session_id' => 'attacker', 'properties_json' => ['order_total_amount' => 999999999]], + ]; + + foreach ($events as $index => $event) { + AnalyticsEvent::create([ + 'store_id' => $firstStore->id, + 'client_event_id' => 'first-'.$index, + 'occurred_at' => $date.' 12:00:00', + 'created_at' => $date.' 12:00:00', + ...$event, + ]); + } + + foreach ([1299, 2500] as $index => $amount) { + Order::withoutGlobalScopes()->create([ + 'store_id' => $firstStore->id, + 'order_number' => '#AN-'.($index + 1), + 'payment_method' => 'card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'currency' => 'EUR', + 'total_amount' => $amount, + 'placed_at' => $date.' 12:00:00', + ]); + } + AnalyticsEvent::create([ + 'store_id' => $secondStore->id, + 'client_event_id' => 'second-event', + 'type' => 'page_view', + 'session_id' => 'other-session', + 'occurred_at' => $date.' 12:00:00', + 'created_at' => $date.' 12:00:00', + ]); + + expect(app(AnalyticsService::class)->aggregateForDate($date))->toBe(2); + + $firstDaily = AnalyticsDaily::query()->where('store_id', $firstStore->id)->firstOrFail(); + $secondDaily = AnalyticsDaily::query()->where('store_id', $secondStore->id)->firstOrFail(); + expect($firstDaily->orders_count)->toBe(2) + ->and($firstDaily->revenue_amount)->toBe(3799) + ->and($firstDaily->aov_amount)->toBe(1899) + ->and($firstDaily->visits_count)->toBe(2) + ->and($firstDaily->add_to_cart_count)->toBe(3) + ->and($firstDaily->checkout_started_count)->toBe(1) + ->and($firstDaily->checkout_completed_count)->toBe(2) + ->and($secondDaily->orders_count)->toBe(0) + ->and($secondDaily->visits_count)->toBe(1); +}); + +it('updates an existing day aggregate when it is recalculated', function () { + $store = shopStore(); + DB::table('analytics_daily')->insert([ + 'store_id' => $store->id, + 'date' => '2026-04-11', + 'orders_count' => 19, + 'revenue_amount' => 19000, + 'aov_amount' => 1000, + ]); + + app(AnalyticsService::class)->aggregateForDate('2026-04-11'); + + $daily = DB::table('analytics_daily')->where('store_id', $store->id)->where('date', '2026-04-11')->first(); + expect(DB::table('analytics_daily')->where('store_id', $store->id)->where('date', '2026-04-11')->count())->toBe(1) + ->and($daily->orders_count)->toBe(0) + ->and($daily->revenue_amount)->toBe(0); +}); diff --git a/tests/Feature/AnalyticsExportFeatureTest.php b/tests/Feature/AnalyticsExportFeatureTest.php new file mode 100644 index 00000000..34a07e65 --- /dev/null +++ b/tests/Feature/AnalyticsExportFeatureTest.php @@ -0,0 +1,122 @@ +create(); + $owner->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + app()->instance('current_store', $store); + Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'order_number' => '#AN-EXPORT-1', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'currency' => 'EUR', + 'total_amount' => 1299, + 'placed_at' => '2026-09-23 10:00:00', + ]); + Order::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'order_number' => '#AN-EXPORT-FOREIGN', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'currency' => 'EUR', + 'total_amount' => 900000, + 'placed_at' => '2026-09-23 10:00:00', + ]); + + $component = Livewire::actingAs($owner)->test(Index::class) + ->set('dateRange', 'custom') + ->set('customStartDate', '2026-09-23') + ->set('customEndDate', '2026-09-23') + ->call('exportCsv') + ->assertSet('isExporting', true) + ->assertSet('exportUrl', null); + + $export = AnalyticsExport::query()->where('store_id', $store->id)->firstOrFail(); + expect($export->status)->toBe('queued'); + Queue::assertPushed(GenerateAnalyticsExport::class, fn (GenerateAnalyticsExport $job): bool => $job->exportId === $export->id + && $job->connection === 'database'); + + (new GenerateAnalyticsExport((int) $export->id))->handle(app(AnalyticsReportingService::class)); + $export->refresh(); + Storage::disk('local')->assertExists($export->storage_key); + expect($export->status)->toBe('completed') + ->and(Storage::disk('local')->get($export->storage_key))->toContain('2026-09-23,1299,EUR,1') + ->and(Storage::disk('local')->get($export->storage_key))->not->toContain('900000'); + + $downloadUrl = route('admin.analytics.exports.download', ['analyticsExport' => $export->id]); + $component->call('pollExport') + ->assertSet('isExporting', false) + ->assertSet('exportUrl', $downloadUrl) + ->assertSee('Download CSV'); + + $download = $this->withSession(['current_store_id' => $store->id])->actingAs($owner)->get($downloadUrl)->assertOk(); + expect($download->streamedContent())->toContain('2026-09-23,1299,EUR,1') + ->and($download->headers->get('content-type'))->toContain('text/csv'); +}); + +it('denies analytics exports to support staff and hides exports from another selected store', function () { + Storage::fake('local'); + $store = shopStore(); + $otherStore = shopStore('analytics-export-foreign.test'); + $owner = User::factory()->create(); + $support = User::factory()->create(); + $owner->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + $support->stores()->attach($store->id, ['role' => 'support']); + app()->instance('current_store', $store); + $foreignExport = AnalyticsExport::factory()->create([ + 'store_id' => $otherStore->id, + 'status' => 'completed', + 'storage_key' => 'analytics-exports/'.$otherStore->id.'/1.csv', + ]); + Storage::disk('local')->put($foreignExport->storage_key, "private\n"); + + Livewire::actingAs($support)->test(Index::class)->assertForbidden(); + + $this->withSession(['current_store_id' => $store->id])->actingAs($owner)->get(route('admin.analytics.exports.download', ['analyticsExport' => $foreignExport->id])) + ->assertNotFound(); +}); + +it('ends the export polling state and shows an error after a failed job', function () { + $store = shopStore(); + $owner = User::factory()->create(); + $owner->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + $export = AnalyticsExport::factory()->create([ + 'store_id' => $store->id, + 'status' => 'failed', + 'error_message' => 'storage unavailable', + ]); + + Livewire::actingAs($owner)->test(Index::class) + ->set('exportId', $export->id) + ->set('isExporting', true) + ->call('pollExport') + ->assertSet('isExporting', false) + ->assertSet('exportError', 'The analytics export could not be generated. Try again.') + ->assertSee('The analytics export could not be generated. Try again.'); +}); diff --git a/tests/Feature/AnalyticsReportingTest.php b/tests/Feature/AnalyticsReportingTest.php new file mode 100644 index 00000000..9aeeff1a --- /dev/null +++ b/tests/Feature/AnalyticsReportingTest.php @@ -0,0 +1,143 @@ +create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'order_number' => '#AN-1', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'currency' => 'EUR', + 'total_amount' => 1299, + 'placed_at' => '2026-09-23 10:00:00', + ]); + $order->lines()->create([ + 'title_snapshot' => 'Main book', + 'quantity' => 2, + 'unit_price_amount' => 650, + 'total_amount' => 1299, + ]); + Order::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'order_number' => '#OTHER-1', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'currency' => 'EUR', + 'total_amount' => 900000, + 'placed_at' => '2026-09-23 10:00:00', + ]); + + foreach ([ + ['page_view', 'session-a', ['referrer' => 'https://search.example.test/', 'channel' => 'storefront', 'device' => 'desktop']], + ['page_view', 'session-a', ['referrer' => 'https://search.example.test/', 'channel' => 'storefront', 'device' => 'desktop']], + ['add_to_cart', 'session-a', ['channel' => 'storefront', 'device' => 'desktop']], + ['checkout_started', 'session-a', ['channel' => 'storefront', 'device' => 'desktop']], + ['checkout_completed', 'session-a', ['order_id' => $order->id, 'channel' => 'storefront', 'device' => 'desktop']], + ] as $index => [$type, $sessionId, $properties]) { + AnalyticsEvent::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'client_event_id' => 'analytics-'.$index, + 'properties_json' => $properties, + 'occurred_at' => '2026-09-23 11:00:00', + 'created_at' => '2026-09-23 11:00:00', + ]); + } + AnalyticsEvent::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'type' => 'page_view', + 'session_id' => 'foreign-session', + 'client_event_id' => 'foreign-event', + 'properties_json' => ['referrer' => 'https://foreign.example.test/'], + 'occurred_at' => '2026-09-23 11:00:00', + 'created_at' => '2026-09-23 11:00:00', + ]); + + $report = app(AnalyticsReportingService::class)->report( + $store, + CarbonImmutable::parse('2026-09-23 00:00:00', 'UTC'), + CarbonImmutable::parse('2026-09-23 00:00:00', 'UTC'), + ); + + expect($report['top_referrers'][0])->toMatchArray([ + 'source' => 'https://search.example.test/', + 'sessions' => 1, + 'orders' => 1, + 'conversion_rate' => 100.0, + ]); + + Livewire::actingAs($user)->test(Index::class) + ->set('dateRange', 'custom') + ->set('customStartDate', '2026-09-23') + ->set('customEndDate', '2026-09-23') + ->assertSee('12.99 EUR') + ->assertSee('Main book') + ->assertSee('Rank') + ->assertSee('https://search.example.test/') + ->assertSee('Conversion rate') + ->assertSee('100.00%') + ->assertDontSee('Foreign'); +}); + +it('applies device filters to storefront sessions and attributed orders', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + app()->instance('current_store', $store); + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'order_number' => '#AN-2', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'currency' => 'EUR', + 'total_amount' => 2500, + 'placed_at' => '2026-09-23 10:00:00', + ]); + + foreach ([ + ['page_view', 'session-desktop', ['device' => 'desktop', 'channel' => 'storefront']], + ['checkout_completed', 'session-desktop', ['order_id' => $order->id, 'device' => 'desktop', 'channel' => 'storefront']], + ] as $index => [$type, $sessionId, $properties]) { + AnalyticsEvent::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'client_event_id' => 'filter-'.$index, + 'properties_json' => $properties, + 'occurred_at' => '2026-09-23 11:00:00', + 'created_at' => '2026-09-23 11:00:00', + ]); + } + + Livewire::actingAs($user)->test(Index::class) + ->set('dateRange', 'custom') + ->set('customStartDate', '2026-09-23') + ->set('customEndDate', '2026-09-23') + ->set('deviceFilter', 'mobile') + ->assertSee('No sales in this period') + ->assertSee('0.00 EUR'); +}); diff --git a/tests/Feature/ApiTokenServiceTest.php b/tests/Feature/ApiTokenServiceTest.php new file mode 100644 index 00000000..7a4098e2 --- /dev/null +++ b/tests/Feature/ApiTokenServiceTest.php @@ -0,0 +1,105 @@ +travelTo(Carbon::parse('2026-01-01 12:00:00')); + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $documentedAbilities = [ + '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', + 'manage-platform', + ]; + + $result = app(ApiTokenService::class)->create($user, $store, 'Integration token', $documentedAbilities); + + expect(substr($result['plain_text_token'], 0, 5))->toBe('shop_') + ->and($result['token']->token)->toBe(hash('sha256', $result['plain_text_token'])) + ->and($result['token']->abilities)->toBe($documentedAbilities) + ->and($result['token']->store_id)->toBe($store->id) + ->and($result['token']->expires_at->toDateTimeString())->toBe('2027-01-01 12:00:00'); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'id' => $result['token']->id, + 'token' => hash('sha256', $result['plain_text_token']), + 'store_id' => $store->id, + ]); +}); + +it('keeps an explicitly requested token expiry', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $expiresAt = new DateTimeImmutable('2027-02-03 04:05:06+00:00'); + + $result = app(ApiTokenService::class)->create($user, $store, 'Temporary token', ['read-orders'], $expiresAt); + + expect($result['token']->expires_at->toDateTimeString())->toBe('2027-02-03 04:05:06'); +}); + +it('rejects undocumented token abilities without creating a token', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + + expect(fn () => app(ApiTokenService::class)->create($user, $store, 'Invalid token', ['read-products', 'manage-search'])) + ->toThrow(\InvalidArgumentException::class); + + expect(PersonalAccessToken::query()->count())->toBe(0); +}); + +it('revokes a token scoped to the requested store', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $result = app(ApiTokenService::class)->create($user, $store, 'Revocable token', ['read-products']); + + app(ApiTokenService::class)->revoke($result['token'], $store); + + $this->assertDatabaseMissing('personal_access_tokens', ['id' => $result['token']->id]); +}); + +it('does not let another store revoke the token', function () { + $store = shopStore(); + $otherStore = shopStore('another-token-store.test'); + $owner = User::factory()->create(); + $owner->stores()->attach($store->id, ['role' => 'owner']); + $result = app(ApiTokenService::class)->create($owner, $store, 'Owned token', ['read-products']); + + expect(fn () => app(ApiTokenService::class)->revoke($result['token'], $otherStore)) + ->toThrow(HttpException::class); + + $this->assertDatabaseHas('personal_access_tokens', ['id' => $result['token']->id]); +}); + +it('requires the token owner to belong to the requested store', function () { + $store = Store::factory()->create(); + $user = User::factory()->create(); + + expect(fn () => app(ApiTokenService::class)->create($user, $store, 'Unscoped token', ['read-products'])) + ->toThrow(HttpException::class); + + expect(PersonalAccessToken::query()->count())->toBe(0); +}); diff --git a/tests/Feature/AuditLoggingTest.php b/tests/Feature/AuditLoggingTest.php new file mode 100644 index 00000000..359c73cb --- /dev/null +++ b/tests/Feature/AuditLoggingTest.php @@ -0,0 +1,62 @@ +instance('current_store', $store); + $basePath = storage_path('logs/audit-test-'.Str::uuid().'.log'); + $dailyPath = $basePath; + config()->set('logging.channels.audit.driver', 'single'); + config()->set('logging.channels.audit.path', $basePath); + Log::forgetChannel('audit'); + + try { + $product = Product::factory()->create(['store_id' => $store->id, 'title' => 'Before title']); + $product->update(['title' => 'After title']); + Event::dispatch(new Failed('web', App\Models\User::class, [ + 'email' => 'failed@example.test', + 'password' => 'never-log-this', + ])); + app(AuditLogger::class)->log('audit.example', extra: ['password' => 'secret', 'details' => ['api_token' => 'secret-token']]); + + $entries = collect(file($dailyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) + ->map(function (string $line): array { + $entry = json_decode($line, true, flags: JSON_THROW_ON_ERROR); + + return is_array($entry['context'] ?? null) ? $entry['context'] : $entry; + }); + $productUpdate = $entries->firstWhere('event', 'product.updated'); + $failedLogin = $entries->firstWhere('event', 'auth.failed_login'); + $example = $entries->firstWhere('event', 'audit.example'); + + expect($productUpdate['store_id'])->toBe($store->id) + ->and($productUpdate['resource_type'])->toBe('product') + ->and($productUpdate['resource_id'])->toBe($product->id) + ->and($productUpdate['changes']['title'])->toBe(['Before title', 'After title']) + ->and($failedLogin['email'])->toBe('failed@example.test') + ->and(json_encode($failedLogin))->not->toContain('never-log-this') + ->and($example['password'])->toBe('[redacted]') + ->and($example['details']['api_token'])->toBe('[redacted]') + ->and($example['timestamp'])->toBeString() + ->and($example)->toHaveKeys(['ip', 'user_agent']); + } finally { + if (is_file($dailyPath)) { + unlink($dailyPath); + } + + Log::forgetChannel('audit'); + } +}); + +it('configures a daily audit channel with ninety day retention', function () { + expect(config('logging.channels.audit.driver'))->toBe('daily') + ->and(config('logging.channels.audit.path'))->toBe(storage_path('logs/audit.log')) + ->and(config('logging.channels.audit.level'))->toBe('info') + ->and(config('logging.channels.audit.days'))->toBe(90); +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index bea78251..9cbe11d1 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -3,59 +3,46 @@ use App\Models\User; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Password; +use Livewire\Livewire; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); -test('reset password link screen can be rendered', function () { - $response = $this->get(route('password.request')); +test('admin reset password link screen can be rendered', function () { + $response = $this->get(route('admin.password.request')); $response->assertOk(); }); -test('reset password link can be requested', function () { +test('admin reset password link can be requested without exposing account existence', function () { Notification::fake(); $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + Livewire::test(\App\Livewire\Admin\Auth\ForgotPassword::class) + ->set('email', $user->email) + ->call('sendResetLink') + ->assertSet('statusMessage', 'If an account with that email exists, we sent a password reset link.'); Notification::assertSentTo($user, ResetPassword::class); }); -test('reset password screen can be rendered', function () { - Notification::fake(); - - $user = User::factory()->create(); +test('admin reset password form can be rendered', function () { + $response = $this->get(route('admin.password.reset', ['token' => 'test-reset-token'])); - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get(route('password.reset', $notification->token)); - $response->assertOk(); - - return true; - }); + $response->assertOk(); }); -test('password can be reset with valid token', function () { - Notification::fake(); - +test('admin password can be reset with a valid broker token', function () { $user = User::factory()->create(); + $token = Password::broker('users')->createToken($user); - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post(route('password.update'), [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); + Livewire::withQueryParams(['email' => $user->email]) + ->test(\App\Livewire\Admin\Auth\ResetPassword::class, ['token' => $token]) + ->set('password', 'new-password-123') + ->set('password_confirmation', 'new-password-123') + ->call('resetPassword') + ->assertRedirect(route('admin.login')); - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login', absolute: false)); - - return true; - }); -}); \ No newline at end of file + expect(Password::broker('users')->tokenExists($user->fresh(), $token))->toBeFalse(); +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index c22ea5e1..514468de 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,23 +1,31 @@ get(route('register')); +test('global administrator self-registration remains disabled', function () { + $response = $this->get('/register'); - $response->assertOk(); + $response->assertNotFound(); }); -test('new users can register', function () { - $response = $this->post(route('register.store'), [ - 'name' => 'John Doe', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); +test('customers register through the current store account flow', function () { + $store = shopStore(); + app()->instance('current_store', $store); - $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); + Livewire::test(\App\Livewire\Storefront\Account\Auth\Register::class) + ->set('name', 'John Doe') + ->set('email', 'test@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register') + ->assertRedirect(route('storefront.account.dashboard')); - $this->assertAuthenticated(); -}); \ No newline at end of file + $customer = Customer::withoutGlobalScopes()->where('email', 'test@example.com')->firstOrFail(); + $this->assertAuthenticatedAs($customer, 'customer'); + expect($customer->store_id)->toBe($store->id) + ->and(Hash::check('password', $customer->password))->toBeTrue(); +}); diff --git a/tests/Feature/CartApiTest.php b/tests/Feature/CartApiTest.php new file mode 100644 index 00000000..674d7b26 --- /dev/null +++ b/tests/Feature/CartApiTest.php @@ -0,0 +1,95 @@ +postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR']) + ->assertCreated() + ->assertJsonPath('store_id', $store->id) + ->assertJsonPath('cart_version', 1) + ->assertJsonPath('totals.total', 0); + $cartId = $cartResponse->json('id'); + + $lineResponse = $this->postJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $product['variant']->id, + 'quantity' => 2, + ]) + ->assertCreated() + ->assertJsonPath('cart_version', 2) + ->assertJsonPath('totals.subtotal', 5000) + ->assertJsonPath('totals.item_count', 2); + + $lineId = $lineResponse->json('lines.0.id'); + + $this->getJson("http://shop.test/api/storefront/v1/carts/{$cartId}") + ->assertOk() + ->assertJsonPath('lines.0.variant_id', $product['variant']->id) + ->assertJsonPath('lines.0.available_quantity', 10); + + $this->putJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines/{$lineId}", [ + 'quantity' => 3, + 'cart_version' => 2, + ]) + ->assertOk() + ->assertJsonPath('cart_version', 3) + ->assertJsonPath('totals.subtotal', 7500); + + $this->assertDatabaseHas('cart_lines', ['cart_id' => $cartId, 'quantity' => 3]); +}); + +it('rejects a currency code that is not assigned in ISO 4217', function () { + shopStore(); + + $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'ABC']) + ->assertUnprocessable() + ->assertJsonValidationErrors('currency'); +}); + +it('returns 409 when a cart mutation uses a stale version', function () { + $store = shopStore(); + $product = shopProduct($store); + $cart = \App\Models\Cart::factory()->create(['store_id' => $store->id]); + $line = $cart->lines()->create([ + 'variant_id' => $product['variant']->id, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]); + $cart->update(['cart_version' => 4]); + + $this->putJson("http://shop.test/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}", [ + 'quantity' => 2, + 'cart_version' => 3, + ]) + ->assertConflict() + ->assertJsonPath('current_version', 4); + + expect($line->refresh()->quantity)->toBe(1); +}); + +it('rejects a guest cart from a different tenant', function () { + $firstStore = shopStore('shop.test'); + $cart = \App\Models\Cart::factory()->create(['store_id' => $firstStore->id]); + shopStore('second.test'); + + $this->getJson("http://second.test/api/storefront/v1/carts/{$cart->id}") + ->assertNotFound(); +}); + +it('rejects a cart quantity that exceeds available deny-policy stock', function () { + $store = shopStore(); + $product = shopProduct($store, stock: 2); + $cart = \App\Models\Cart::factory()->create(['store_id' => $store->id]); + + $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart->id}/lines", [ + 'variant_id' => $product['variant']->id, + 'quantity' => 3, + ]) + ->assertUnprocessable() + ->assertJsonPath('error_code', 'insufficient_inventory'); + + expect($cart->lines()->count())->toBe(0); +}); diff --git a/tests/Feature/CatalogLifecycleTest.php b/tests/Feature/CatalogLifecycleTest.php new file mode 100644 index 00000000..11b11a2b --- /dev/null +++ b/tests/Feature/CatalogLifecycleTest.php @@ -0,0 +1,98 @@ +create($store, [ + 'title' => 'Summer Shirt', + 'variants' => [['price_amount' => 2500, 'quantity_on_hand' => 4]], + ]); + $second = $products->create($store, [ + 'title' => 'Summer Shirt', + 'variants' => [['price_amount' => 2900, 'quantity_on_hand' => 3]], + ]); + + expect($first->status)->toBe('draft') + ->and($first->handle)->toBe('summer-shirt') + ->and($second->handle)->toBe('summer-shirt-1') + ->and($first->variants)->toHaveCount(1) + ->and($first->variants->first()->inventoryItem->quantity_on_hand)->toBe(4); +}); + +it('publishes a priced product and emits its status change', function () { + $store = shopStore(); + $product = app(ProductService::class)->create($store, [ + 'title' => 'Cotton Shirt', + 'variants' => [['price_amount' => 2500]], + ]); + Event::fake([ProductStatusChanged::class]); + + app(ProductService::class)->transitionStatus($product, ProductStatus::Active); + + expect($product->refresh()->status)->toBe('active') + ->and($product->published_at)->not->toBeNull(); + Event::assertDispatched(ProductStatusChanged::class); +}); + +it('rejects publishing a product without a priced variant', function () { + $store = shopStore(); + $product = app(ProductService::class)->create($store, [ + 'title' => 'Free Draft', + 'variants' => [['price_amount' => 0]], + ]); + + expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active)) + ->toThrow(InvalidProductTransitionException::class); + expect($product->refresh()->status)->toBe('draft'); +}); + +it('rejects a duplicate SKU inside one store but permits it in another', function () { + $store = shopStore(); + $products = app(ProductService::class); + $products->create($store, ['title' => 'First', 'variants' => [['sku' => 'SHIRT-1', 'price_amount' => 2500]]]); + + expect(fn () => $products->create($store, ['title' => 'Duplicate', 'variants' => [['sku' => 'SHIRT-1', 'price_amount' => 2600]]])) + ->toThrow(ValidationException::class); + + $otherStore = shopStore('other-shop.test'); + expect(fn () => $products->create($otherStore, ['title' => 'Other', 'variants' => [['sku' => 'SHIRT-1', 'price_amount' => 2600]]])) + ->not->toThrow(ValidationException::class); +}); + +it('blocks product deletion after the product is published', function () { + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id, 'status' => 'active']); + + expect(fn () => app(ProductService::class)->delete($product)) + ->toThrow(InvalidProductTransitionException::class); +}); + +it('indexes product type in the store-scoped FTS table', function () { + $store = shopStore(); + $product = app(ProductService::class)->create($store, [ + 'title' => 'Lightweight Rain Jacket', + 'product_type' => 'Outerwear', + 'description_html' => '

Waterproof shell

', + 'vendor' => 'North Wind', + 'tags' => ['rain', 'jacket'], + 'variants' => [['price_amount' => 9900]], + ]); + + $indexedProduct = DB::table('products_fts')->where('product_id', (string) $product->id)->first(); + $matchedProductId = DB::selectOne( + 'SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ?', + ['Outerwear', (string) $store->id], + )?->product_id; + + expect($indexedProduct?->product_type)->toBe('Outerwear') + ->and((int) $matchedProductId)->toBe($product->id); +}); diff --git a/tests/Feature/CheckoutApiTest.php b/tests/Feature/CheckoutApiTest.php new file mode 100644 index 00000000..99789178 --- /dev/null +++ b/tests/Feature/CheckoutApiTest.php @@ -0,0 +1,60 @@ +create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 1); + $checkout = app(CheckoutService::class)->start($cart); + $shippingAddress = [ + 'first_name' => 'Alex', + 'last_name' => 'Buyer', + 'address1' => '1 Test Street', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ]; + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout->id}/address", [ + 'email' => 'buyer@example.test', + 'shipping_address' => $shippingAddress, + 'billing_address' => [ + 'first_name' => 'Different', + 'last_name' => 'Billing', + 'address1' => '2 Another Road', + 'city' => 'Hamburg', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '20095', + ], + ])->assertOk(); + + expect($checkout->refresh()->billing_address_json)->toBe($checkout->refresh()->shipping_address_json); +}); + +it('rejects unknown ISO country codes in checkout addresses', function () { + $store = shopStore(); + $product = shopProduct($store); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 1); + $checkout = app(CheckoutService::class)->start($cart); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout->id}/address", [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Alex', + 'last_name' => 'Buyer', + 'address1' => '1 Test Street', + 'city' => 'Berlin', + 'country' => 'Nowhere', + 'country_code' => 'ZZ', + 'postal_code' => '10115', + ], + ])->assertUnprocessable() + ->assertJsonValidationErrors('shipping_address.country_code'); +}); diff --git a/tests/Feature/CheckoutFlowTest.php b/tests/Feature/CheckoutFlowTest.php new file mode 100644 index 00000000..83ecfc88 --- /dev/null +++ b/tests/Feature/CheckoutFlowTest.php @@ -0,0 +1,233 @@ +create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 2); + $zone = ShippingZone::factory()->create(['store_id' => $store->id]); + $rate = ShippingRate::factory()->create(['shipping_zone_id' => $zone->id, 'price_amount' => 500]); + TaxSetting::factory()->create(['store_id' => $store->id, 'default_rate' => 0, 'rates_json' => []]); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', [ + 'first_name' => 'Alex', 'last_name' => 'Buyer', 'address1' => '1 Test Street', + 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'postal_code' => '10115', + ]); + $checkout = $checkouts->selectShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + expect($product['inventory']->refresh()->quantity_reserved)->toBe(2); + expect(fn () => $checkouts->pay($checkout, ['card_number' => '4000000000000002'])) + ->toThrow(PaymentDeclinedException::class); + expect($product['inventory']->refresh()->quantity_reserved)->toBe(0) + ->and($checkout->refresh()->status)->toBe('shipping_selected'); + + $checkout = $checkouts->selectPaymentMethod($checkout->refresh(), PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, ['card_number' => '4242424242424242']); + $duplicateAttempt = $checkouts->pay($checkout->refresh(), ['card_number' => '4242424242424242']); + + expect($order->id)->toBe($duplicateAttempt->id) + ->and($order->status)->toBe('paid') + ->and($order->total_amount)->toBe(5500) + ->and($product['inventory']->refresh()->quantity_on_hand)->toBe(3) + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(0) + ->and($cart->refresh()->status)->toBe('converted'); +}); + +it('holds inventory for a bank transfer until it is confirmed', function () { + $store = shopStore(); + $product = shopProduct($store, price: 3200, stock: 4); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 1); + $zone = ShippingZone::factory()->create(['store_id' => $store->id]); + $rate = ShippingRate::factory()->create(['shipping_zone_id' => $zone->id]); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', [ + 'first_name' => 'Alex', 'last_name' => 'Buyer', 'address1' => '1 Test Street', + 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'zip' => '10115', + ]); + $checkout = $checkouts->selectShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::BankTransfer); + $order = $checkouts->pay($checkout); + + expect($order->financial_status)->toBe('pending') + ->and($product['inventory']->refresh()->quantity_on_hand)->toBe(4) + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(1); + + $confirmedOrder = app(OrderService::class)->confirmBankTransfer($order); + + expect($confirmedOrder->financial_status)->toBe('paid') + ->and($product['inventory']->refresh()->quantity_on_hand)->toBe(3) + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(0); +}); + +it('skips shipping for a digital product and automatically fulfills it after payment', function () { + $store = shopStore(); + $product = shopProduct($store, price: 1500, stock: 3, requiresShipping: false); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 1); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', []); + $checkout = $checkouts->selectShippingMethod($checkout); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::PayPal); + $order = $checkouts->pay($checkout); + + expect($checkout->refresh()->shipping_method_id)->toBeNull() + ->and($order->fulfillment_status)->toBe('fulfilled') + ->and($order->fulfillments)->toHaveCount(1) + ->and($product['inventory']->refresh()->quantity_on_hand)->toBe(2); +}); + +it('calculates weight and price shipping tiers and excludes rates outside their ranges', function () { + $store = shopStore(); + $product = shopProduct($store, price: 2000); + $product['variant']->update(['weight_g' => 600]); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 2); + $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries' => ['DE']]); + $weightRate = ShippingRate::factory()->create([ + 'shipping_zone_id' => $zone->id, + 'type' => 'weight', + 'config_json' => ['tiers' => [ + ['min_weight_g' => 0, 'max_weight_g' => 999, 'price_amount' => 600], + ['min_weight_g' => 1000, 'price_amount' => 900], + ]], + ]); + $priceRate = ShippingRate::factory()->create([ + 'shipping_zone_id' => $zone->id, + 'type' => 'price', + 'config_json' => ['tiers' => [ + ['min_order_amount' => 0, 'max_order_amount' => 3999, 'price_amount' => 700], + ['min_order_amount' => 4000, 'price_amount' => 0], + ]], + ]); + $unavailableRate = ShippingRate::factory()->create([ + 'shipping_zone_id' => $zone->id, + 'type' => 'weight', + 'config_json' => ['tiers' => [['min_weight_g' => 2000, 'price_amount' => 1200]]], + ]); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', [ + 'first_name' => 'Alex', 'last_name' => 'Buyer', 'address1' => '1 Test Street', + 'city' => 'Berlin', 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => '10115', + ]); + + $rates = $checkouts->availableShippingRates($checkout)->keyBy('id'); + expect($rates->keys()->all())->toContain($weightRate->id, $priceRate->id) + ->and($rates->keys()->all())->not->toContain($unavailableRate->id) + ->and($rates[$weightRate->id]->price_amount)->toBe(900) + ->and($rates[$priceRate->id]->price_amount)->toBe(0); + + $checkout = $checkouts->selectShippingMethod($checkout, $weightRate->id); + expect($checkout->shipping_amount)->toBe(900); +}); + +it('uses the most specific shipping zone and weighs only physical items', function () { + $store = shopStore(); + $physical = shopProduct($store, price: 1500); + $physical['variant']->update(['weight_g' => 750]); + $digital = shopProduct($store, price: 500, requiresShipping: false); + $digital['variant']->update(['weight_g' => 10000]); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $physical['variant'], 1); + app(CartService::class)->add($cart, $digital['variant'], 3); + $countryZone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries' => ['DE'], 'regions' => []]); + $regionZone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries' => ['DE'], 'regions' => ['DE-BE']]); + ShippingRate::factory()->create([ + 'shipping_zone_id' => $countryZone->id, + 'config_json' => ['price_amount' => 300], + 'price_amount' => 300, + ]); + $regionalRate = ShippingRate::factory()->create([ + 'shipping_zone_id' => $regionZone->id, + 'config_json' => ['price_amount' => 800], + 'price_amount' => 800, + ]); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', [ + 'first_name' => 'Alex', 'last_name' => 'Buyer', 'address1' => '1 Test Street', + 'city' => 'Berlin', 'province_code' => 'BE', 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => '10115', + ]); + + $rates = $checkouts->availableShippingRates($checkout); + expect($rates->modelKeys())->toBe([$regionalRate->id]) + ->and($rates->first()->price_amount)->toBe(800); + $checkout = $checkouts->selectShippingMethod($checkout, $regionalRate->id); + expect($checkout->shipping_amount)->toBe(800); +}); + +it('expires a checkout and releases its reserved inventory exactly once', function () { + $store = shopStore(); + $product = shopProduct($store, stock: 3); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 2); + $zone = ShippingZone::factory()->create(['store_id' => $store->id]); + $rate = ShippingRate::factory()->create(['shipping_zone_id' => $zone->id]); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', [ + 'first_name' => 'Alex', 'last_name' => 'Buyer', 'address1' => '1 Test Street', + 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'zip' => '10115', + ]); + $checkout = $checkouts->selectShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + $checkouts->expire($checkout); + $checkouts->expire($checkout->refresh()); + + expect($checkout->refresh()->status)->toBe('expired') + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(0); +}); + +it('renders each checkout step from the Livewire component state', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $product = shopProduct($store); + $cart = Cart::factory()->create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 1); + session()->put('cart_id', $cart->id); + $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries' => ['DE']]); + $rate = ShippingRate::factory()->create(['shipping_zone_id' => $zone->id]); + + $checkout = Livewire::test(CheckoutShow::class) + ->set('email', 'buyer@example.test') + ->call('continueToAddress') + ->assertSet('stepOverride', 2) + ->assertSee('First name'); + + $checkout->set('shipping', [ + 'first_name' => 'Alex', + 'last_name' => 'Buyer', + 'address_line_1' => '1 Test Street', + 'address_line_2' => '', + 'city' => 'Berlin', + 'state' => '', + 'country' => 'DE', + 'postal_code' => '10115', + 'phone' => '', + ])->call('continueToShippingMethod') + ->assertSee('Choose a shipping method') + ->assertSee('Alex Buyer') + ->assertSee('1 Test Street') + ->set('shippingRateId', $rate->id) + ->call('continueToPayment') + ->assertSee('Select a payment method') + ->assertSee('Card number'); +}); diff --git a/tests/Feature/CustomerAccountTest.php b/tests/Feature/CustomerAccountTest.php new file mode 100644 index 00000000..2f6f9ed2 --- /dev/null +++ b/tests/Feature/CustomerAccountTest.php @@ -0,0 +1,249 @@ +instance('current_store', $store); + + Livewire::test(Register::class) + ->set('name', 'Morgan Customer') + ->set('email', 'morgan@example.test') + ->set('password', 'secure-password-123') + ->set('password_confirmation', 'secure-password-123') + ->set('marketingOptIn', true) + ->call('register') + ->assertRedirect(route('storefront.account.dashboard')); + + $customer = Customer::withoutGlobalScopes()->where('email', 'morgan@example.test')->firstOrFail(); + $this->assertAuthenticatedAs($customer, 'customer'); + expect($customer->store_id)->toBe($store->id) + ->and($customer->marketing_opt_in)->toBeTrue() + ->and(Hash::check('secure-password-123', $customer->password))->toBeTrue(); +}); + +it('resolves the store on customer login and registration pages', function () { + shopStore('shop.test'); + + $this->get('http://shop.test/account/login')->assertOk(); + $this->get('http://shop.test/account/register')->assertOk(); + $this->get('http://unknown-shop.test/account/login')->assertNotFound(); +}); + +it('keeps customer authentication across the redirect to the account page', function () { + $store = shopStore('shop.test'); + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'email' => 'browser-login@example.test', + 'password' => 'shop-password', + ]); + app()->instance('current_store', $store); + + Livewire::test(Login::class) + ->set('email', $customer->email) + ->set('password', 'shop-password') + ->call('login') + ->assertRedirect(route('storefront.account.dashboard')); + + app()->forgetInstance('current_store'); + \Illuminate\Support\Facades\Auth::forgetGuards(); + + $this->get('http://shop.test/account') + ->assertOk() + ->assertSee('My account'); + + $this->assertAuthenticatedAs($customer, 'customer'); +}); + +it('rejects slash-backslash customer login redirect paths', function () { + $store = shopStore('shop.test'); + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'email' => 'safe-redirect@example.test', + 'password' => 'shop-password', + ]); + app()->instance('current_store', $store); + + Livewire::withQueryParams(['redirect' => '/\\attacker.test/']) + ->test(Login::class) + ->assertSet('redirectTo', '') + ->set('email', $customer->email) + ->set('password', 'shop-password') + ->call('login') + ->assertRedirect(route('storefront.account.dashboard')); + + Livewire::withQueryParams(['redirect' => ['/checkout']]) + ->test(Login::class) + ->assertSet('redirectTo', ''); +}); + +it('revalidates customer login redirect state before redirecting', function () { + $store = shopStore(); + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'email' => 'tampered-redirect@example.test', + 'password' => 'shop-password', + ]); + app()->instance('current_store', $store); + + Livewire::test(Login::class) + ->set('redirectTo', 'https://attacker.test/after-login') + ->set('email', $customer->email) + ->set('password', 'shop-password') + ->call('login') + ->assertRedirect(route('storefront.account.dashboard')); +}); + +it('preserves safe local customer login redirect paths', function () { + $store = shopStore(); + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'email' => 'local-redirect@example.test', + 'password' => 'shop-password', + ]); + app()->instance('current_store', $store); + + Livewire::withQueryParams(['redirect' => '/checkout?step=address']) + ->test(Login::class) + ->set('email', $customer->email) + ->set('password', 'shop-password') + ->call('login') + ->assertRedirect('/checkout?step=address'); +}); + +it('allows the same customer email in separate stores and keeps login tenant scoped', function () { + $firstStore = shopStore(); + $firstCustomer = Customer::factory()->create([ + 'store_id' => $firstStore->id, + 'email' => 'shared@example.test', + 'password' => 'first-password', + ]); + $secondStore = shopStore('second.test'); + $secondCustomer = Customer::factory()->create([ + 'store_id' => $secondStore->id, + 'email' => 'shared@example.test', + 'password' => 'second-password', + ]); + expect($firstCustomer->id)->not->toBe($secondCustomer->id); + + app()->instance('current_store', $firstStore); + Livewire::test(Login::class) + ->set('email', 'shared@example.test') + ->set('password', 'second-password') + ->call('login') + ->assertHasErrors('email'); + + app('auth')->guard('customer')->logout(); + app()->instance('current_store', $secondStore); + Livewire::test(Login::class) + ->set('email', 'shared@example.test') + ->set('password', 'second-password') + ->call('login') + ->assertRedirect(route('storefront.account.dashboard')); + + $this->assertAuthenticatedAs($secondCustomer, 'customer'); +}); + +it('merges guest cart lines on login and prefers the higher duplicate quantity', function () { + $store = shopStore(); + $product = shopProduct($store); + $customer = Customer::factory()->create(['store_id' => $store->id, 'email' => 'buyer@example.test', 'password' => 'shop-password']); + $guestCart = Cart::factory()->create(['store_id' => $store->id]); + $guestCart->lines()->create([ + 'variant_id' => $product['variant']->id, 'quantity' => 3, 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 7500, 'line_discount_amount' => 0, 'line_total_amount' => 7500, + ]); + $customerCart = Cart::factory()->create(['store_id' => $store->id, 'customer_id' => $customer->id]); + $customerCart->lines()->create([ + 'variant_id' => $product['variant']->id, 'quantity' => 2, 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 5000, 'line_discount_amount' => 0, 'line_total_amount' => 5000, + ]); + session()->put('cart_id', $guestCart->id); + app()->instance('current_store', $store); + + Livewire::test(Login::class) + ->set('email', 'buyer@example.test') + ->set('password', 'shop-password') + ->call('login') + ->assertRedirect(route('storefront.account.dashboard')); + + expect($customerCart->lines()->firstOrFail()->quantity)->toBe(3) + ->and($guestCart->refresh()->status)->toBe('abandoned') + ->and(session()->has('cart_id'))->toBeFalse(); +}); + +it('saves addresses for the signed-in customer and prevents editing another customer address', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $otherCustomer = Customer::factory()->create(['store_id' => $store->id]); + $foreignAddress = $otherCustomer->addresses()->create([ + 'label' => 'Home', + 'address_json' => ['first_name' => 'Other'], + 'is_default' => true, + ]); + + Livewire::actingAs($customer, 'customer')->test(AddressIndex::class) + ->set('address.first_name', 'Taylor') + ->set('address.last_name', 'Buyer') + ->set('address.address_line_1', '12 Example Road') + ->set('address.city', 'Berlin') + ->set('address.country', 'DE') + ->set('address.postal_code', '10115') + ->set('label', 'Home') + ->set('isDefault', true) + ->call('saveAddress') + ->assertHasNoErrors(); + + $savedAddress = $customer->addresses()->firstOrFail(); + expect($savedAddress->is_default)->toBeTrue() + ->and($savedAddress->address_json['address1'])->toBe('12 Example Road'); + + expect(fn () => Livewire::actingAs($customer, 'customer')->test(AddressIndex::class) + ->call('editAddress', $foreignAddress->id)) + ->toThrow(ModelNotFoundException::class); +}); + +it('stores and validates customer reset tokens within the current store', function () { + $firstStore = shopStore(); + $firstCustomer = Customer::factory()->create(['store_id' => $firstStore->id, 'email' => 'shared@example.test']); + $secondStore = shopStore('second.test'); + $secondCustomer = Customer::factory()->create(['store_id' => $secondStore->id, 'email' => 'shared@example.test']); + app()->instance('current_store', $firstStore); + + $token = \Illuminate\Support\Facades\Password::broker('customers')->createToken($firstCustomer); + $this->assertDatabaseHas('customer_password_reset_tokens', [ + 'store_id' => $firstStore->id, + 'email' => 'shared@example.test', + ]); + expect(\Illuminate\Support\Facades\Password::broker('customers')->tokenExists($firstCustomer, $token))->toBeTrue(); + + app()->instance('current_store', $secondStore); + expect(\Illuminate\Support\Facades\Password::broker('customers')->tokenExists($secondCustomer, $token))->toBeFalse(); +}); + +it('shows the same password reset acknowledgement for known and unknown customer emails', function () { + $store = shopStore(); + $customer = Customer::factory()->create(['store_id' => $store->id, 'email' => 'known@example.test']); + app()->instance('current_store', $store); + \Illuminate\Support\Facades\Notification::fake(); + + Livewire::test(\App\Livewire\Storefront\Account\Auth\ForgotPassword::class) + ->set('email', $customer->email) + ->call('sendResetLink') + ->assertSet('statusMessage', 'If an account with that email exists, we sent a password reset link.'); + Livewire::test(\App\Livewire\Storefront\Account\Auth\ForgotPassword::class) + ->set('email', 'unknown@example.test') + ->call('sendResetLink') + ->assertSet('statusMessage', 'If an account with that email exists, we sent a password reset link.'); + + \Illuminate\Support\Facades\Notification::assertSentTo($customer, \Illuminate\Auth\Notifications\ResetPassword::class); + expect(\Illuminate\Support\Facades\DB::table('customer_password_reset_tokens')->count())->toBe(1); +}); diff --git a/tests/Feature/CustomerOrderNotificationsTest.php b/tests/Feature/CustomerOrderNotificationsTest.php new file mode 100644 index 00000000..9c9e7890 --- /dev/null +++ b/tests/Feature/CustomerOrderNotificationsTest.php @@ -0,0 +1,195 @@ +create([ + 'store_id' => $store->getKey(), + 'order_number' => $number, + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => $store->default_currency, + 'subtotal_amount' => 5000, + 'total_amount' => 5000, + 'email' => $email, + 'placed_at' => now(), + ]); + $order->lines()->create([ + 'title_snapshot' => 'Cotton shirt', + 'variant_title_snapshot' => 'Blue / Medium', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + return $order; +} + +function customerNotificationApiToken(User $user, Store $store): string +{ + $plainTextToken = 'shop_'.Str::random(64); + $user->tokens()->create([ + 'store_id' => $store->getKey(), + 'name' => 'customer-notifications-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['write-orders'], + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +function invokeCustomerOrderEventListeners(object $event): void +{ + foreach (app('events')->getListeners($event::class) as $listener) { + $listener($event, [$event]); + } +} + +it('queues the registered customer emails for order, refund, shipment, and cancellation events', function () { + $store = shopStore(); + $order = customerNotificationTestOrder($store, '#EMAIL-1001', 'customer@example.test'); + $refund = new Refund(['amount' => 1250, 'reason' => 'Returned item']); + $fulfillment = new Fulfillment([ + 'status' => 'shipped', + 'tracking_company' => 'DHL', + 'tracking_number' => 'TRACK-123', + 'tracking_url' => 'https://tracking.example.test/TRACK-123', + ]); + $fulfillment->setRelation('order', $order); + Mail::fake(); + $bankTransferOrder = customerNotificationTestOrder($store, '#EMAIL-1007', 'bank-transfer@example.test'); + $bankTransferOrder->forceFill(['payment_method' => 'bank_transfer', 'status' => 'pending', 'financial_status' => 'pending'])->save(); + + invokeCustomerOrderEventListeners(new OrderCreated($order)); + invokeCustomerOrderEventListeners(new OrderRefunded($order, $refund)); + invokeCustomerOrderEventListeners(new FulfillmentShipped($fulfillment)); + invokeCustomerOrderEventListeners(new OrderCancelled($order)); + invokeCustomerOrderEventListeners(new OrderCreated($bankTransferOrder)); + + Mail::assertQueued(CustomerOrderNotification::class, function (CustomerOrderNotification $mail) use ($order): bool { + if ($mail->notificationType !== CustomerOrderNotification::ORDER_CONFIRMATION + || $mail->order->getKey() !== $order->getKey()) { + return false; + } + + $mail->assertSeeInHtml('Thank you for your order'); + $mail->assertSeeInHtml('Cotton shirt'); + + return $mail->hasTo('customer@example.test'); + }); + Mail::assertQueued(CustomerOrderNotification::class, function (CustomerOrderNotification $mail): bool { + if ($mail->notificationType !== CustomerOrderNotification::REFUND) { + return false; + } + + $mail->assertSeeInHtml('Returned item'); + + return $mail->details === ['amount' => 1250, 'reason' => 'Returned item']; + }); + Mail::assertQueued(CustomerOrderNotification::class, function (CustomerOrderNotification $mail): bool { + if ($mail->notificationType !== CustomerOrderNotification::SHIPPED) { + return false; + } + + $mail->assertSeeInHtml('TRACK-123'); + + return $mail->details['tracking_number'] === 'TRACK-123'; + }); + Mail::assertQueued(CustomerOrderNotification::class, function (CustomerOrderNotification $mail): bool { + if ($mail->notificationType !== CustomerOrderNotification::CANCELLED) { + return false; + } + + $mail->assertSeeInHtml('Your order was cancelled'); + + return true; + }); + Mail::assertQueued(CustomerOrderNotification::class, function (CustomerOrderNotification $mail) use ($bankTransferOrder): bool { + if ($mail->notificationType !== CustomerOrderNotification::ORDER_CONFIRMATION + || $mail->order->getKey() !== $bankTransferOrder->getKey()) { + return false; + } + + $mail->assertSeeInHtml('DE89 3704 0044 0532 0130 00'); + + return true; + }); + Mail::assertQueuedCount(5); +}); + +it('suppresses refund and shipment email when the event disables customer notification', function () { + $store = shopStore(); + $order = customerNotificationTestOrder($store, '#EMAIL-1002', 'customer@example.test'); + $refund = new Refund(['amount' => 1250, 'reason' => 'Returned item']); + $fulfillment = new Fulfillment(['status' => 'shipped', 'tracking_number' => 'TRACK-456']); + $fulfillment->setRelation('order', $order); + Mail::fake(); + + invokeCustomerOrderEventListeners(new OrderRefunded($order, $refund, false)); + invokeCustomerOrderEventListeners(new FulfillmentShipped($fulfillment, false)); + + Mail::assertNothingOutgoing(); +}); + +it('passes the default and explicit customer notification choice to order services', function () { + $store = shopStore(); + $user = User::factory()->create(); + $user->stores()->attach($store->getKey(), ['role' => 'owner']); + $token = customerNotificationApiToken($user, $store); + $defaultRefundOrder = customerNotificationTestOrder($store, '#EMAIL-1003', 'default@example.test'); + $silentRefundOrder = customerNotificationTestOrder($store, '#EMAIL-1004', 'silent@example.test'); + $defaultFulfillmentOrder = customerNotificationTestOrder($store, '#EMAIL-1005', 'default-ship@example.test'); + $silentFulfillmentOrder = customerNotificationTestOrder($store, '#EMAIL-1006', 'silent-ship@example.test'); + $refund = new Refund(['id' => 1, 'order_id' => $defaultRefundOrder->getKey(), 'amount' => 1000, 'status' => 'processed']); + $refundService = Mockery::mock(RefundService::class); + $refundService->shouldReceive('refund') + ->twice() + ->withArgs(fn (Order $order, int $amount, ?string $reason, bool $restock, array $lineItems, bool $notifyCustomer): bool => $amount === 1000 + && $reason === null + && ! $restock + && $lineItems === [] + && $notifyCustomer === ($order->getKey() === $defaultRefundOrder->getKey())) + ->andReturn($refund); + $this->app->instance(RefundService::class, $refundService); + + $baseUrl = "/api/admin/v1/stores/{$store->getKey()}/orders"; + $this->withToken($token)->postJson("{$baseUrl}/{$defaultRefundOrder->getKey()}/refunds", ['amount' => 1000])->assertCreated(); + $this->withToken($token)->postJson("{$baseUrl}/{$silentRefundOrder->getKey()}/refunds", ['amount' => 1000, 'notify_customer' => false])->assertCreated(); + + $defaultFulfillment = $defaultFulfillmentOrder->fulfillments()->create(['status' => 'shipped', 'shipped_at' => now(), 'created_at' => now()]); + $defaultFulfillment->lines()->create(['order_line_id' => $defaultFulfillmentOrder->lines()->firstOrFail()->getKey(), 'quantity' => 1]); + $silentFulfillment = $silentFulfillmentOrder->fulfillments()->create(['status' => 'shipped', 'shipped_at' => now(), 'created_at' => now()]); + $silentFulfillment->lines()->create(['order_line_id' => $silentFulfillmentOrder->lines()->firstOrFail()->getKey(), 'quantity' => 1]); + $fulfillmentService = Mockery::mock(FulfillmentService::class); + $fulfillmentService->shouldReceive('create')->twice()->andReturn($defaultFulfillment, $silentFulfillment); + $fulfillmentService->shouldReceive('markShipped') + ->twice() + ->withArgs(fn (Fulfillment $fulfillment, bool $notifyCustomer): bool => $notifyCustomer === ($fulfillment->getKey() === $defaultFulfillment->getKey())) + ->andReturn($defaultFulfillment, $silentFulfillment); + $this->app->instance(FulfillmentService::class, $fulfillmentService); + + $this->withToken($token)->postJson("{$baseUrl}/{$defaultFulfillmentOrder->getKey()}/fulfillments", [ + 'line_items' => [['order_line_id' => $defaultFulfillmentOrder->lines()->firstOrFail()->getKey(), 'quantity' => 1]], + ])->assertCreated(); + $this->withToken($token)->postJson("{$baseUrl}/{$silentFulfillmentOrder->getKey()}/fulfillments", [ + 'line_items' => [['order_line_id' => $silentFulfillmentOrder->lines()->firstOrFail()->getKey(), 'quantity' => 1]], + 'notify_customer' => false, + ])->assertCreated(); +}); diff --git a/tests/Feature/DiscountApplicationTest.php b/tests/Feature/DiscountApplicationTest.php new file mode 100644 index 00000000..2b94985e --- /dev/null +++ b/tests/Feature/DiscountApplicationTest.php @@ -0,0 +1,181 @@ +instance('current_store', $store); + $percentDiscount = Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => null, + 'type' => 'percentage', + 'value' => 10, + 'rules_json' => ['activation_method' => 'automatic'], + ]); + $fixedDiscount = Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => null, + 'type' => 'fixed_amount', + 'value' => 500, + 'rules_json' => ['activation_method' => 'automatic'], + ]); + $product = shopProduct($store, price: 10000, stock: 5, requiresShipping: false); + $cart = Cart::factory()->create(['store_id' => $store->id]); + + $cart = app(CartService::class)->add($cart, $product['variant'], 1); + $line = $cart->lines->firstOrFail(); + expect($cart->discount_code)->toBeNull() + ->and($line->line_discount_amount)->toBe(1500) + ->and($line->line_total_amount)->toBe(8500); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->start($cart); + $checkout = $checkouts->setContact($checkout, 'buyer@example.test'); + $checkout = $checkouts->setAddress($checkout, 'buyer@example.test', []); + $checkout = $checkouts->selectShippingMethod($checkout); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, ['card_number' => '4242424242424242']); + + expect($checkout->refresh()->discount_code)->toBeNull() + ->and($checkout->totals_json['applied_discount_ids'])->toBe([$percentDiscount->id, $fixedDiscount->id]) + ->and($order->discount_amount)->toBe(1500) + ->and($order->total_amount)->toBe(8500) + ->and($order->lines->firstOrFail()->discount_allocations_json)->toBe([ + ['discount_id' => $percentDiscount->id, 'amount' => 1000], + ['discount_id' => $fixedDiscount->id, 'amount' => 500], + ]) + ->and($percentDiscount->refresh()->usage_count)->toBe(1) + ->and($fixedDiscount->refresh()->usage_count)->toBe(1); +}); + +it('allows a guest or registered customer one automatic use and blocks subsequent use', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $discount = Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => null, + 'type' => 'percentage', + 'value' => 10, + 'rules_json' => ['activation_method' => 'automatic', 'one_per_customer' => true], + ]); + $product = shopProduct($store, price: 10000, stock: 8, requiresShipping: false); + $checkouts = app(CheckoutService::class); + + $placeOrder = function (?Customer $customer, string $email) use ($store, $product, $checkouts): Order { + $cart = Cart::factory()->create(['store_id' => $store->id, 'customer_id' => $customer?->id]); + $cart = app(CartService::class)->add($cart, $product['variant'], 1); + $checkout = $checkouts->start($cart, $customer); + $checkout = $checkouts->setContact($checkout, $email); + $checkout = $checkouts->setAddress($checkout, $email, []); + $checkout = $checkouts->selectShippingMethod($checkout); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + return $checkouts->pay($checkout, ['card_number' => '4242424242424242']); + }; + + $guestOrder = $placeOrder(null, 'guest@example.test'); + expect($guestOrder->discount_amount)->toBe(1000); + + $repeatGuestCart = Cart::factory()->create(['store_id' => $store->id]); + $repeatGuestCart = app(CartService::class)->add($repeatGuestCart, $product['variant'], 1); + expect($repeatGuestCart->lines->firstOrFail()->line_discount_amount)->toBe(1000); + $repeatGuestCheckout = $checkouts->start($repeatGuestCart); + $repeatGuestCheckout = $checkouts->setContact($repeatGuestCheckout, 'GUEST@example.test'); + expect($repeatGuestCheckout->discount_amount)->toBe(0) + ->and($repeatGuestCheckout->totals_json['applied_discount_ids'])->toBe([]); + + $customer = Customer::factory()->create(['store_id' => $store->id, 'email' => 'customer@example.test']); + $customerOrder = $placeOrder($customer, $customer->email); + expect($customerOrder->discount_amount)->toBe(1000); + + $repeatCustomerCart = Cart::factory()->create(['store_id' => $store->id, 'customer_id' => $customer->id]); + $repeatCustomerCart = app(CartService::class)->add($repeatCustomerCart, $product['variant'], 1); + expect($repeatCustomerCart->lines->firstOrFail()->line_discount_amount)->toBe(0) + ->and($discount->refresh()->usage_count)->toBe(2); +}); + +it('rejects a repeat code redemption by a registered customer', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $customer = Customer::factory()->create(['store_id' => $store->id, 'email' => 'once@example.test']); + $discount = Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => 'ONCE20', + 'rules_json' => ['activation_method' => 'code', 'one_per_customer' => true], + ]); + $historyCart = Cart::factory()->create(['store_id' => $store->id, 'customer_id' => $customer->id]); + $historyCheckout = Checkout::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'cart_id' => $historyCart->id, + 'customer_id' => $customer->id, + 'status' => 'completed', + 'email' => $customer->email, + 'discount_code' => $discount->code, + 'totals_json' => ['applied_discount_ids' => [$discount->id]], + ]); + Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'checkout_id' => $historyCheckout->id, + 'customer_id' => $customer->id, + 'order_number' => '#1001', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'EUR', + 'email' => $customer->email, + 'placed_at' => now(), + ]); + $product = shopProduct($store); + $cart = Cart::factory()->create(['store_id' => $store->id, 'customer_id' => $customer->id]); + $cart = app(CartService::class)->add($cart, $product['variant'], 1); + + expect(fn () => app(CartService::class)->applyDiscount($cart, 'once20')) + ->toThrow(ValidationException::class, 'This discount can only be used once per customer.'); +}); + +it('restores the selected shipping price when a free shipping code is removed', function () { + $store = shopStore(); + $product = shopProduct($store, price: 10000); + $cart = Cart::factory()->create(['store_id' => $store->id]); + $cart = app(CartService::class)->add($cart, $product['variant'], 1); + $zone = ShippingZone::factory()->create(['store_id' => $store->id]); + $rate = ShippingRate::factory()->create(['shipping_zone_id' => $zone->id, 'price_amount' => 500]); + Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => 'FREESHIP', + 'type' => 'free_shipping', + 'value' => 0, + 'rules_json' => ['activation_method' => 'code'], + ]); + $checkoutService = app(CheckoutService::class); + $checkout = $checkoutService->start($cart); + $checkout = $checkoutService->setAddress($checkout, 'buyer@example.test', [ + 'first_name' => 'Alex', + 'last_name' => 'Buyer', + 'address1' => '1 Test Street', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + ]); + $checkout = $checkoutService->applyDiscount($checkout, 'FREESHIP'); + $checkout = $checkoutService->selectShippingMethod($checkout, $rate->id); + + expect($checkout->shipping_amount)->toBe(0); + + $checkout = $checkoutService->removeDiscount($checkout); + + expect($checkout->shipping_amount)->toBe(500) + ->and($checkout->total_amount)->toBe(10500); +}); diff --git a/tests/Feature/DiscountFormTest.php b/tests/Feature/DiscountFormTest.php new file mode 100644 index 00000000..a3dfff9d --- /dev/null +++ b/tests/Feature/DiscountFormTest.php @@ -0,0 +1,177 @@ +instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $this->actingAs($user); + + $product = Product::factory()->create(['store_id' => $store->id, 'title' => 'Blue Shirt']); + $collection = Collection::create([ + 'store_id' => $store->id, + 'title' => 'Summer collection', + 'handle' => 'summer-collection', + 'type' => 'manual', + 'status' => 'active', + ]); + + $form = Livewire::test(Form::class) + ->set('title', 'Summer offer') + ->set('code', 'summer20') + ->set('valueAmount', 101) + ->call('save') + ->assertHasErrors('valueAmount'); + + $form->set('valueAmount', 20) + ->set('minimumPurchaseAmount', 5000) + ->set('specificProductIds', [$product->id]) + ->set('specificCollectionIds', [$collection->id]) + ->set('usageLimit', 40) + ->set('onePerCustomer', true) + ->set('startsAt', '2026-09-01T09:30') + ->set('endsAt', '2026-12-31T23:59') + ->set('isActive', true) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('admin.discounts')); + + $discount = Discount::query()->where('code', 'SUMMER20')->firstOrFail(); + expect($discount->store_id)->toBe($store->id) + ->and($discount->title)->toBe('Summer offer') + ->and($discount->type)->toBe('percentage') + ->and($discount->value)->toBe(20) + ->and($discount->minimum_subtotal_amount)->toBe(5000) + ->and($discount->usage_limit)->toBe(40) + ->and($discount->starts_at->format('Y-m-d H:i'))->toBe('2026-09-01 09:30') + ->and($discount->ends_at->format('Y-m-d H:i'))->toBe('2026-12-31 23:59') + ->and($discount->is_active)->toBeTrue() + ->and($discount->rules_json)->toMatchArray([ + 'activation_method' => 'code', + 'one_per_customer' => true, + 'product_ids' => [$product->id], + 'collection_ids' => [$collection->id], + ]); + + $discount->forceFill(['usage_count' => 7])->save(); + $edit = Livewire::test(Form::class, ['discount' => $discount->id]) + ->assertSet('type', 'code') + ->assertSet('specificProductIds', [$product->id]) + ->set('title', 'Holiday offer') + ->call('save') + ->assertHasNoErrors() + ->set('title', 'Holiday offer') + ->set('type', 'automatic') + ->set('valueType', 'fixed_amount') + ->set('valueAmount', 750) + ->set('isActive', false) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('admin.discounts')); + + $discount->refresh(); + expect($discount->title)->toBe('Holiday offer') + ->and($discount->code)->toBeNull() + ->and($discount->type)->toBe('fixed_amount') + ->and($discount->value)->toBe(750) + ->and($discount->usage_count)->toBe(7) + ->and($discount->is_active)->toBeFalse() + ->and($discount->rules_json['activation_method'])->toBe('automatic'); +}); + +it('hides another stores discount from an authorized admin', function () { + $firstStore = shopStore(); + $foreignDiscount = Discount::withoutGlobalScopes()->create([ + 'store_id' => $firstStore->id, + 'code' => 'FOREIGN10', + 'title' => 'Private discount', + 'type' => 'percentage', + 'value' => 10, + 'starts_at' => now(), + 'is_active' => true, + ]); + + $currentStore = shopStore('second-shop.test'); + app()->instance('current_store', $currentStore); + $user = User::factory()->create(); + $user->stores()->attach($currentStore->id, ['role' => 'owner']); + $this->actingAs($user); + + expect(fn () => Livewire::test(Form::class) + ->set('discountId', $foreignDiscount->id) + ->set('title', 'Changed by another store') + ->call('save')) + ->toThrow(ModelNotFoundException::class); + + expect(Discount::withoutGlobalScopes()->findOrFail($foreignDiscount->id)->title)->toBe('Private discount'); +}); + +it('requires a store role with discount management permission', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'support']); + $this->actingAs($user); + + Livewire::test(Form::class)->assertStatus(403); +}); + +it('allows support staff to see discounts without showing write controls', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'support']); + $discount = Discount::factory()->for($store)->create(['code' => 'SUPPORT10', 'title' => 'Support-visible offer']); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Support-visible offer') + ->assertDontSee('Create discount') + ->assertDontSee('Deactivate') + ->assertDontSee('Delete'); +}); + +it('filters discounts by code and active, scheduled, and expired status', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $this->actingAs($user); + $now = now()->startOfDay()->setTime(12, 0); + $this->travelTo($now); + + Discount::factory()->for($store)->create([ + 'title' => 'Available offer', 'code' => 'SAVE20', 'is_active' => true, + 'starts_at' => $now->copy()->subDay(), 'ends_at' => $now->copy()->addDay(), + ]); + Discount::factory()->for($store)->create([ + 'title' => 'Upcoming offer', 'code' => 'LATER20', 'is_active' => true, + 'starts_at' => $now->copy()->addDay(), 'ends_at' => null, + ]); + Discount::factory()->for($store)->create([ + 'title' => 'Past offer', 'code' => 'OLD20', 'is_active' => true, + 'starts_at' => $now->copy()->subDays(3), 'ends_at' => $now->copy()->subDay(), + ]); + + Livewire::actingAs($user)->test(Index::class) + ->set('search', 'SAVE') + ->set('statusFilter', 'active') + ->assertSee('SAVE20') + ->assertDontSee('LATER20') + ->set('search', '') + ->set('statusFilter', 'scheduled') + ->assertSee('LATER20') + ->assertDontSee('SAVE20') + ->set('statusFilter', 'expired') + ->assertSee('OLD20') + ->assertDontSee('LATER20'); +}); diff --git a/tests/Feature/DomainWebhookDispatchTest.php b/tests/Feature/DomainWebhookDispatchTest.php new file mode 100644 index 00000000..ab16f10f --- /dev/null +++ b/tests/Feature/DomainWebhookDispatchTest.php @@ -0,0 +1,80 @@ + $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example.test/orders', + 'signing_secret_encrypted' => 'test-signing-secret', + 'status' => 'active', + ]); + WebhookSubscription::create([ + 'store_id' => $otherStore->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.other.test/orders', + 'signing_secret_encrypted' => 'other-secret', + 'status' => 'active', + ]); + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'order_number' => '#1001', + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'EUR', + 'total_amount' => 2500, + 'email' => 'private@example.test', + 'placed_at' => now(), + ]); + $line = $order->lines()->create([ + 'title_snapshot' => 'Blue shirt', + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + ]); + Queue::fake([DeliverWebhook::class]); + + $listener = app('events')->getListeners(OrderCreated::class)[0]; + $event = new OrderCreated($order); + $listener($event, [$event]); + + Queue::assertPushedTimes(DeliverWebhook::class, 1); + Queue::assertPushed(DeliverWebhook::class, fn (DeliverWebhook $job): bool => $job->subscriptionId === $subscription->id + && $job->eventType === 'order.created' + && $job->payload['id'] === $order->id + && $job->payload['line_items'][0]['id'] === $line->id + && ! array_key_exists('email', $job->payload)); +}); + +it('maps archiving a product to the canonical deleted event and skips new product status noise', function () { + $store = shopStore(); + $subscription = WebhookSubscription::create([ + 'store_id' => $store->id, + 'event_type' => 'product.deleted', + 'target_url' => 'https://hooks.example.test/products', + 'signing_secret_encrypted' => 'test-signing-secret', + 'status' => 'active', + ]); + $product = Product::factory()->create(['store_id' => $store->id, 'status' => 'archived']); + Queue::fake([DeliverWebhook::class]); + $listener = app(\App\Listeners\DispatchWebhooks::class); + + $listener->productStatusChanged(new ProductStatusChanged($product, 'active', 'archived')); + $listener->productStatusChanged(new ProductStatusChanged($product, 'draft', 'active', true)); + + Queue::assertPushedTimes(DeliverWebhook::class, 1); + Queue::assertPushed(DeliverWebhook::class, fn (DeliverWebhook $job): bool => $job->subscriptionId === $subscription->id + && $job->eventType === 'product.deleted' + && $job->payload['id'] === $product->id); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..e1a73210 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,33 @@ get('/'); + $organization = Organization::query()->create([ + 'name' => 'Example organization', + 'billing_email' => 'billing@example.test', + ]); + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Example store', + 'handle' => 'example-store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + StoreDomain::query()->create([ + 'store_id' => $store->id, + 'hostname' => 'shop.test', + 'type' => 'storefront', + 'is_primary' => true, + ]); + Cache::flush(); + + $response = $this->get('http://shop.test/'); $response->assertStatus(200); }); diff --git a/tests/Feature/FulfillmentLifecycleTest.php b/tests/Feature/FulfillmentLifecycleTest.php new file mode 100644 index 00000000..ce234212 --- /dev/null +++ b/tests/Feature/FulfillmentLifecycleTest.php @@ -0,0 +1,203 @@ +} */ +function fulfillmentLifecycleOrder(Store $store, array $lineQuantities): array +{ + $order = Order::query()->create([ + 'store_id' => $store->id, + 'order_number' => 'FUL-'.fake()->unique()->numerify('#####'), + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + ]); + $lines = []; + + foreach ($lineQuantities as $index => $quantity) { + $lines[] = $order->lines()->create([ + 'title_snapshot' => 'Fulfillment item '.($index + 1), + 'variant_title_snapshot' => 'Default', + 'quantity' => $quantity, + 'unit_price_amount' => 1200, + 'total_amount' => 1200 * $quantity, + ]); + } + + return ['order' => $order, 'lines' => $lines]; +} + +function fulfillmentLifecycleStaff(Store $store): User +{ + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'staff']); + + return $user; +} + +it('keeps a fully allocated pending shipment partial until it is delivered', function () { + $store = shopStore(); + ['order' => $order, 'lines' => [$line]] = fulfillmentLifecycleOrder($store, [3]); + $service = app(FulfillmentService::class); + + $fulfillment = $service->create($order, [$line->id => 3]); + + expect($fulfillment->status)->toBe('pending') + ->and($order->refresh()->fulfillment_status)->toBe('partial') + ->and($order->status)->toBe('paid'); + + $service->markShipped($fulfillment); + + expect($fulfillment->refresh()->status)->toBe('shipped') + ->and($fulfillment->shipped_at)->not->toBeNull() + ->and($order->refresh()->fulfillment_status)->toBe('partial') + ->and($order->status)->toBe('paid'); + + $service->markDelivered($fulfillment); + + expect($fulfillment->refresh()->status)->toBe('delivered') + ->and($fulfillment->delivered_at)->not->toBeNull() + ->and($order->refresh()->fulfillment_status)->toBe('fulfilled') + ->and($order->status)->toBe('fulfilled'); +}); + +it('fulfills an order line across multiple partial shipments only after every quantity is delivered', function () { + $store = shopStore(); + ['order' => $order, 'lines' => [$line]] = fulfillmentLifecycleOrder($store, [3]); + $service = app(FulfillmentService::class); + + $firstShipment = $service->create($order, [$line->id => 1]); + + expect($order->refresh()->fulfillment_status)->toBe('partial'); + + $service->markShipped($firstShipment); + $service->markDelivered($firstShipment); + + expect($order->refresh()->fulfillment_status)->toBe('partial') + ->and($order->status)->toBe('paid'); + + $secondShipment = $service->create($order, [$line->id => 2]); + + expect($order->refresh()->fulfillment_status)->toBe('partial') + ->and(FulfillmentLine::query()->where('order_line_id', $line->id)->sum('quantity'))->toBe(3); + + $service->markShipped($secondShipment); + + expect($order->refresh()->fulfillment_status)->toBe('partial'); + + $service->markDelivered($secondShipment); + + expect($order->refresh()->fulfillment_status)->toBe('fulfilled') + ->and($order->status)->toBe('fulfilled'); +}); + +it('lets staff select partial quantities and shows shipment transition actions on the order page', function () { + $store = shopStore(); + ['order' => $order, 'lines' => [$line]] = fulfillmentLifecycleOrder($store, [4]); + $user = fulfillmentLifecycleStaff($store); + app()->instance('current_store', $store); + + $page = Livewire::actingAs($user)->test(Show::class, ['order' => $order->id]) + ->assertSet("fulfillmentLines.{$line->id}", 4) + ->set("fulfillmentLines.{$line->id}", 2) + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'DHL-5488') + ->set('trackingUrl', 'https://tracking.example/DHL-5488') + ->call('fulfill') + ->assertHasNoErrors() + ->assertSee('Mark as shipped') + ->assertDontSee('Mark as delivered'); + + $fulfillment = Fulfillment::query()->where('order_id', $order->id)->firstOrFail(); + + expect($fulfillment->lines()->sum('quantity'))->toBe(2) + ->and($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('DHL-5488') + ->and($fulfillment->tracking_url)->toBe('https://tracking.example/DHL-5488') + ->and($order->refresh()->fulfillment_status)->toBe('partial'); + + $page->call('markAsShipped', $fulfillment->id) + ->assertSee('Mark as delivered') + ->call('markAsDelivered', $fulfillment->id) + ->assertSee('Partial'); + + expect($fulfillment->refresh()->status)->toBe('delivered') + ->and($order->refresh()->fulfillment_status)->toBe('partial') + ->and($order->status)->toBe('paid'); +}); + +it('renders payment and refund timestamps on the admin order page', function () { + $store = shopStore(); + ['order' => $order] = fulfillmentLifecycleOrder($store, [1]); + $staff = fulfillmentLifecycleStaff($store); + app()->instance('current_store', $store); + $payment = $order->payments()->create([ + 'provider' => 'mock', + 'method' => 'credit_card', + 'status' => 'succeeded', + 'amount' => 1200, + 'currency' => 'EUR', + 'created_at' => now(), + ]); + $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => 300, + 'reason' => 'Test adjustment', + 'status' => 'succeeded', + 'created_at' => now(), + ]); + + Livewire::actingAs($staff) + ->test(Show::class, ['order' => $order->id]) + ->assertSee('Payments') + ->assertSee('Refunds') + ->assertSee('Test adjustment'); +}); + +it('prevents support users from changing fulfillment status', function () { + $store = shopStore(); + ['order' => $order, 'lines' => [$line]] = fulfillmentLifecycleOrder($store, [1]); + $support = User::factory()->create(); + $support->stores()->attach($store->id, ['role' => 'support']); + app()->instance('current_store', $store); + $fulfillment = app(FulfillmentService::class)->create($order, [$line->id => 1]); + + Livewire::actingAs($support) + ->test(Show::class, ['order' => $order->id]) + ->assertDontSee('Mark as shipped') + ->call('markAsShipped', $fulfillment->id); + + expect($fulfillment->refresh()->status)->toBe('pending') + ->and($fulfillment->shipped_at)->toBeNull(); +}); + +it('does not mark an order fulfilled while another allocated fulfillment is still pending or shipped', function () { + $store = shopStore(); + ['order' => $order, 'lines' => [$firstLine, $secondLine]] = fulfillmentLifecycleOrder($store, [1, 1]); + $service = app(FulfillmentService::class); + $firstShipment = $service->create($order, [$firstLine->id => 1]); + $secondShipment = $service->create($order, [$secondLine->id => 1]); + + expect($order->refresh()->fulfillment_status)->toBe('partial'); + + $service->markShipped($firstShipment); + $service->markShipped($secondShipment); + $service->markDelivered($firstShipment); + + expect($order->refresh()->fulfillment_status)->toBe('partial') + ->and($order->status)->toBe('paid'); + + $service->markDelivered($secondShipment); + + expect($order->refresh()->fulfillment_status)->toBe('fulfilled') + ->and($order->status)->toBe('fulfilled'); +}); diff --git a/tests/Feature/InventoryIndexTest.php b/tests/Feature/InventoryIndexTest.php new file mode 100644 index 00000000..d1f02be9 --- /dev/null +++ b/tests/Feature/InventoryIndexTest.php @@ -0,0 +1,166 @@ +create([ + 'store_id' => $store->id, + 'title' => $title, + 'handle' => str($title)->slug().'-'.fake()->unique()->numberBetween(1, 100000), + ]); + $variant = ProductVariant::factory()->for($product)->create([ + 'sku' => $sku, + 'position' => 0, + 'is_default' => true, + ]); + + return $variant->inventoryItem()->create([ + 'store_id' => $store->id, + 'quantity_on_hand' => $quantity, + 'quantity_reserved' => $reserved, + 'policy' => $policy, + ]); +} + +function inventoryIndexUser(Store $store, string $role = 'staff'): User +{ + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => $role]); + + return $user; +} + +it('shows only inventory belonging to the current store', function () { + $store = shopStore(); + $localInventory = inventoryIndexItem($store, 'Local Linen Shirt', 'LOCAL-001', 12); + $otherStore = shopStore('other-inventory.test'); + inventoryIndexItem($otherStore, 'Foreign Wool Hat', 'FOREIGN-001', 7); + $user = inventoryIndexUser($store); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Local Linen Shirt') + ->assertSee('LOCAL-001') + ->assertDontSee('Foreign Wool Hat') + ->assertDontSee('FOREIGN-001'); +}); + +it('lets support staff view stock without exposing inventory controls', function () { + $store = shopStore(); + inventoryIndexItem($store, 'Support View Product', 'SUPPORT-VIEW-001', 5); + $user = inventoryIndexUser($store, 'support'); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Support View Product') + ->assertSee('5') + ->assertDontSee('Save') + ->assertDontSee('inventory-quantity-'); +}); + +it('searches inventory by product title and SKU', function () { + $store = shopStore(); + inventoryIndexItem($store, 'Azure Trail Jacket', 'JACKET-204', 9); + inventoryIndexItem($store, 'Canvas Weekend Bag', 'BAG-883', 4); + $user = inventoryIndexUser($store); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(Index::class) + ->set('search', 'Azure Trail') + ->assertSee('Azure Trail Jacket') + ->assertDontSee('Canvas Weekend Bag') + ->set('search', 'BAG-883') + ->assertSee('Canvas Weekend Bag') + ->assertSee('BAG-883') + ->assertDontSee('Azure Trail Jacket'); +}); + +it('filters rows by available stock level', function () { + $store = shopStore(); + inventoryIndexItem($store, 'Ready to Ship Mug', 'MUG-READY', 12, 2); + inventoryIndexItem($store, 'Low Stock Scarf', 'SCARF-LOW', 5); + inventoryIndexItem($store, 'Sold Out Candle', 'CANDLE-OUT', 0); + $user = inventoryIndexUser($store); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(Index::class) + ->set('stockFilter', 'in_stock') + ->assertSee('Ready to Ship Mug') + ->assertSee('Low Stock Scarf') + ->assertDontSee('Sold Out Candle') + ->set('stockFilter', 'low_stock') + ->assertSee('Low Stock Scarf') + ->assertDontSee('Ready to Ship Mug') + ->assertDontSee('Sold Out Candle') + ->set('stockFilter', 'out_of_stock') + ->assertSee('Sold Out Candle') + ->assertDontSee('Ready to Ship Mug') + ->assertDontSee('Low Stock Scarf'); +}); + +it('saves validated quantity and policy changes for a permitted store user', function () { + $store = shopStore(); + $inventoryItem = inventoryIndexItem($store, 'Updateable Travel Cup', 'CUP-900', 8); + $user = inventoryIndexUser($store, 'staff'); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(Index::class) + ->set("quantities.{$inventoryItem->id}", '3') + ->set("policies.{$inventoryItem->id}", 'continue') + ->call('saveInventoryItem', $inventoryItem->id) + ->assertHasNoErrors(); + + expect($inventoryItem->refresh()->quantity_on_hand)->toBe(3) + ->and($inventoryItem->policy)->toBe('continue'); +}); + +it('denies quantity and policy changes when the product update permission fails', function () { + $store = shopStore(); + $inventoryItem = inventoryIndexItem($store, 'Protected Stock Item', 'PRIVATE-001', 6); + $user = inventoryIndexUser($store); + app()->instance('current_store', $store); + Gate::before(fn (User $actingUser, string $ability): ?bool => $actingUser->is($user) && $ability === 'update' ? false : null); + + Livewire::actingAs($user) + ->test(Index::class) + ->set("quantities.{$inventoryItem->id}", '1') + ->set("policies.{$inventoryItem->id}", 'continue') + ->call('saveInventoryItem', $inventoryItem->id) + ->assertForbidden(); + + expect($inventoryItem->refresh()->quantity_on_hand)->toBe(6) + ->and($inventoryItem->policy)->toBe('deny'); +}); + +it('rejects invalid stock values without persisting changes', function () { + $store = shopStore(); + $inventoryItem = inventoryIndexItem($store, 'Validated Hiking Socks', 'SOCK-VALIDATE', 6); + $user = inventoryIndexUser($store); + app()->instance('current_store', $store); + + Livewire::actingAs($user) + ->test(Index::class) + ->set("quantities.{$inventoryItem->id}", '-1') + ->set("policies.{$inventoryItem->id}", 'sell-everything') + ->call('saveInventoryItem', $inventoryItem->id) + ->assertHasErrors([ + "quantities.{$inventoryItem->id}" => 'min', + "policies.{$inventoryItem->id}" => 'in', + ]); + + expect($inventoryItem->refresh()->quantity_on_hand)->toBe(6) + ->and($inventoryItem->policy)->toBe('deny'); +}); diff --git a/tests/Feature/NavigationManagementTest.php b/tests/Feature/NavigationManagementTest.php new file mode 100644 index 00000000..cded22bc --- /dev/null +++ b/tests/Feature/NavigationManagementTest.php @@ -0,0 +1,191 @@ +create(); + $user->stores()->attach($store->id, ['role' => $role]); + + return $user; +} + +it('renders the navigation editor with current store menus', function () { + $store = shopStore('navigation-admin.test'); + $user = navigationAdmin($store); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get(route('admin.navigation')) + ->assertOk() + ->assertSee('Navigation') + ->assertSee('Main Menu') + ->assertSee('Footer Menu') + ->assertSee('Add item'); + + expect(NavigationMenu::query()->where('store_id', $store->id)->count())->toBe(2); +}); + +it('creates edits nests and saves store-scoped navigation items for the storefront', function () { + $store = shopStore('navigation-menu.test'); + $otherStore = shopStore('navigation-menu-other.test'); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + app()->instance('current_store', $store); + + $component = Livewire::actingAs($user)->test(NavigationIndex::class); + $mainMenu = NavigationMenu::query()->where('store_id', $store->id)->where('handle', 'main-menu')->firstOrFail(); + app()->forgetInstance('current_store'); + + $component->call('selectMenu', $mainMenu->id) + ->call('addItem') + ->set('itemLabel', 'Departments') + ->set('itemUrl', '/collections') + ->set('itemParentId', '') + ->call('saveItem') + ->assertHasNoErrors(); + + $rootId = $component->get('menuItems')[0]['id']; + + $component->call('addItem', $rootId) + ->set('itemLabel', 'Summer collection') + ->set('itemUrl', '/collections/summer') + ->call('saveItem') + ->assertHasNoErrors() + ->call('addItem', $rootId) + ->set('itemLabel', 'Winter collection') + ->set('itemUrl', '/collections/winter') + ->call('saveItem') + ->assertHasNoErrors() + ->call('addItem') + ->set('itemLabel', 'About') + ->set('itemType', 'page') + ->set('itemResourceId', (string) Page::query()->create([ + 'store_id' => $store->id, + 'title' => 'About', + 'handle' => 'about', + 'status' => 'published', + ])->id) + ->call('saveItem') + ->assertHasNoErrors(); + + $aboutId = $component->get('menuItems')[1]['id']; + $childIds = array_column($component->get('menuItems')[0]['children'], 'id'); + + $component->call('editItem', $aboutId) + ->set('itemLabel', 'About our store') + ->call('saveItem') + ->call('reorderItems', ['root' => [$aboutId, $rootId], $rootId => [$childIds[1], $childIds[0]]]) + ->call('reorderItems', $aboutId, 0, 'root') + ->call('saveMenu') + ->assertHasNoErrors(); + + $footerMenu = NavigationMenu::query()->where('store_id', $store->id)->where('handle', 'footer-menu')->firstOrFail(); + $component->call('selectMenu', $footerMenu->id) + ->call('addItem') + ->set('itemLabel', 'Footer help') + ->set('itemUrl', '/pages/help') + ->call('saveItem') + ->call('saveMenu') + ->assertHasNoErrors(); + + $savedRoot = NavigationItem::query()->where('menu_id', $mainMenu->id)->whereNull('parent_id')->where('label', 'Departments')->firstOrFail(); + $savedChild = NavigationItem::query()->where('menu_id', $mainMenu->id)->where('parent_id', $savedRoot->id)->where('label', 'Summer collection')->firstOrFail(); + + expect($savedRoot->position)->toBe(1) + ->and($savedChild->position)->toBe(1) + ->and($savedChild->label)->toBe('Summer collection') + ->and($savedRoot->children()->orderBy('position')->pluck('label')->all())->toBe(['Winter collection', 'Summer collection']) + ->and(NavigationItem::query()->where('menu_id', $mainMenu->id)->whereNull('parent_id')->orderBy('position')->value('label'))->toBe('About our store'); + + $this->get('http://navigation-menu.test/') + ->assertOk() + ->assertSee('Departments') + ->assertSee('Summer collection') + ->assertSee('About our store') + ->assertSee('/pages/about') + ->assertSee('Footer help') + ->assertSee('/pages/help'); +}); + +it('allows an admin to clear and save an empty menu', function () { + $store = shopStore('navigation-empty.test'); + $user = navigationAdmin($store); + app()->instance('current_store', $store); + $component = Livewire::actingAs($user)->test(NavigationIndex::class); + $mainMenu = NavigationMenu::query()->where('store_id', $store->id)->where('handle', 'main-menu')->firstOrFail(); + + $component->call('addItem') + ->set('itemLabel', 'Temporary link') + ->set('itemUrl', '/temporary') + ->call('saveItem') + ->call('saveMenu'); + + $itemId = $component->get('menuItems')[0]['id']; + $component->call('removeItem', $itemId)->call('saveMenu')->assertHasNoErrors(); + + expect(NavigationItem::query()->where('menu_id', $mainMenu->id)->count())->toBe(0); +}); + +it('rejects cross-store menus and resource references', function () { + $store = shopStore('navigation-scope.test'); + $otherStore = shopStore('navigation-scope-other.test'); + $user = User::factory()->create(); + $user->stores()->attach([$store->id => ['role' => 'owner'], $otherStore->id => ['role' => 'owner']]); + app()->instance('current_store', $store); + $foreignMenu = NavigationMenu::query()->create(['store_id' => $otherStore->id, 'handle' => 'private-menu', 'title' => 'Private']); + $foreignPage = Page::query()->create(['store_id' => $otherStore->id, 'title' => 'Private page', 'handle' => 'private', 'status' => 'published']); + + expect(fn () => Livewire::actingAs($user)->test(NavigationIndex::class)->call('selectMenu', $foreignMenu->id)) + ->toThrow(ModelNotFoundException::class); + + $mainMenu = NavigationMenu::query()->where('store_id', $store->id)->where('handle', 'main-menu')->firstOrFail(); + + $component = Livewire::actingAs($user)->test(NavigationIndex::class) + ->call('selectMenu', $mainMenu->id) + ->call('addItem') + ->set('itemLabel', 'Foreign page') + ->set('itemType', 'page') + ->set('itemResourceId', (string) $foreignPage->id) + ->call('saveItem') + ->assertHasErrors('itemResourceId'); + + $component->set('menuItems', [[ + 'id' => 'forged-item', + 'label' => 'Foreign page', + 'type' => 'page', + 'url' => null, + 'resource_id' => (string) $foreignPage->id, + 'children' => [], + ]])->call('saveMenu')->assertHasErrors('menuItems.0.resource_id'); + + expect(NavigationItem::query()->where('menu_id', $mainMenu->id)->count())->toBe(0); +}); + +it('rejects unsafe menu URLs and unauthorized navigation access', function () { + $store = shopStore('navigation-security.test'); + $owner = navigationAdmin($store); + $staff = navigationAdmin($store, 'staff'); + app()->instance('current_store', $store); + $mainMenu = NavigationMenu::query()->create(['store_id' => $store->id, 'handle' => 'main-menu', 'title' => 'Main Menu']); + + Livewire::actingAs($owner)->test(NavigationIndex::class) + ->call('selectMenu', $mainMenu->id) + ->call('addItem') + ->set('itemLabel', 'Unsafe') + ->set('itemUrl', 'javascript:alert(1)') + ->call('saveItem') + ->assertHasErrors('itemUrl'); + + $this->actingAs($staff) + ->withSession(['current_store_id' => $store->id]) + ->get(route('admin.navigation')) + ->assertForbidden(); +}); diff --git a/tests/Feature/PlatformManagementTest.php b/tests/Feature/PlatformManagementTest.php new file mode 100644 index 00000000..07b7f689 --- /dev/null +++ b/tests/Feature/PlatformManagementTest.php @@ -0,0 +1,167 @@ +tokens()->create([ + 'store_id' => $store?->id, + 'name' => 'platform-management-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +it('creates organizations and stores through platform-scoped endpoints', function () { + $user = User::factory()->create(); + $token = platformManagementToken($user, null, ['manage-platform']); + + $organizationResponse = $this->withToken($token)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Acme Corporation', + 'billing_email' => 'billing@acme.test', + ])->assertCreated() + ->assertJsonPath('data.name', 'Acme Corporation') + ->assertJsonPath('data.billing_email', 'billing@acme.test'); + + $organizationId = $organizationResponse->json('data.id'); + $storeResponse = $this->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => $organizationId, + 'name' => 'Acme Store', + 'handle' => 'acme-store', + 'default_currency' => 'EUR', + 'default_locale' => 'de', + 'timezone' => 'Europe/Berlin', + ])->assertCreated() + ->assertJsonPath('data.organization_id', $organizationId) + ->assertJsonPath('data.handle', 'acme-store') + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.default_currency', 'EUR'); + + $storeId = $storeResponse->json('data.id'); + $this->assertDatabaseHas('stores', ['organization_id' => $organizationId, 'handle' => 'acme-store', 'status' => 'active']); + $this->assertDatabaseHas('store_users', ['store_id' => $storeId, 'user_id' => $user->id, 'role' => 'owner']); + expect(Store::query()->findOrFail($storeId)->users()->wherePivot('role', 'owner')->count())->toBe(1); + expect(Organization::query()->count())->toBe(1); +}); + +it('rejects missing or store-bound platform authority', function () { + $organization = Organization::factory()->create(); + $store = Store::factory()->create(['organization_id' => $organization->id]); + $user = User::factory()->create(); + + $this->postJson('/api/admin/v1/platform/organizations', ['name' => 'No token'])->assertUnauthorized(); + + $readOnlyToken = platformManagementToken($user, null, ['read-products']); + $this->withToken($readOnlyToken)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'No scope', + 'billing_email' => 'scope@acme.test', + ])->assertForbidden(); + + $storeToken = platformManagementToken($user, $store, ['manage-platform']); + $this->withToken($storeToken)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Store-bound token', + 'billing_email' => 'bound@acme.test', + ])->assertForbidden(); + + expect(Organization::query()->where('name', 'Store-bound token')->exists())->toBeFalse(); +}); + +it('validates store handles locale currency timezone and organization', function () { + $organization = Organization::factory()->create(); + $user = User::factory()->create(); + $token = platformManagementToken($user, null, ['manage-platform']); + + $this->withToken($token)->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => $organization->id, + 'name' => 'Invalid store', + 'handle' => 'Bad_Handle', + 'default_currency' => 'ZZZ', + 'default_locale' => 'zz', + 'timezone' => 'Not/A_Timezone', + ])->assertUnprocessable() + ->assertJsonValidationErrors(['handle', 'default_currency', 'default_locale', 'timezone']); + + $this->withToken($token)->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => 999999, + 'name' => 'Unknown parent', + 'handle' => 'unknown-parent', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ])->assertUnprocessable()->assertJsonValidationErrors('organization_id'); + + expect(Store::query()->where('name', 'Invalid store')->exists())->toBeFalse(); +}); + +it('persists store invitations and rejects existing store members', function () { + $store = shopStore(); + $invitedUser = User::factory()->create(['email' => 'staff@acme.test']); + $operator = User::factory()->create(); + $token = platformManagementToken($operator, null, ['manage-platform']); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/invites", [ + 'email' => 'invitee@acme.test', + 'role' => 'staff', + ])->assertCreated() + ->assertJsonPath('data.email', 'invitee@acme.test') + ->assertJsonPath('data.role', 'staff'); + + $invitation = StoreInvitation::query()->firstOrFail(); + expect($invitation->store_id)->toBe($store->id) + ->and($invitation->token_hash)->toHaveLength(64) + ->and($invitation->token_hash)->not->toBe(''); + + $store->users()->attach($invitedUser->id, ['role' => 'staff']); + $this->postJson("/api/admin/v1/stores/{$store->id}/invites", [ + 'email' => 'STAFF@ACME.TEST', + 'role' => 'admin', + ])->assertStatus(409); + + expect(StoreInvitation::query()->count())->toBe(1); +}); + +it('limits the admin API per bearer token instead of sharing a client IP bucket', function () { + $firstUser = User::factory()->create(); + $secondUser = User::factory()->create(); + $firstToken = platformManagementToken($firstUser, null, ['manage-platform']); + $secondToken = platformManagementToken($secondUser, null, ['manage-platform']); + + for ($requestNumber = 1; $requestNumber <= 60; $requestNumber++) { + $this->withToken($firstToken)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => "Rate limited organization {$requestNumber}", + 'billing_email' => "billing-{$requestNumber}@acme.test", + ])->assertCreated(); + } + + $this->withToken($firstToken)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Over limit organization', + 'billing_email' => 'over-limit@acme.test', + ])->assertStatus(429); + + $this->withToken($secondToken)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Second token organization', + 'billing_email' => 'second-token@acme.test', + ])->assertCreated(); +}); + +it('validates invitation payload and does not expose the secret token hash', function () { + $store = shopStore(); + $user = User::factory()->create(); + $token = platformManagementToken($user, null, ['manage-platform']); + + $response = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/invites", [ + 'email' => 'not-an-email', + 'role' => 'superuser', + ])->assertUnprocessable()->assertJsonValidationErrors(['email', 'role']); + + expect($response->json())->not->toHaveKey('token_hash') + ->and(StoreInvitation::query()->count())->toBe(0); +}); diff --git a/tests/Feature/ProductMediaUploadTest.php b/tests/Feature/ProductMediaUploadTest.php new file mode 100644 index 00000000..f4880006 --- /dev/null +++ b/tests/Feature/ProductMediaUploadTest.php @@ -0,0 +1,160 @@ +tokens()->create([ + 'store_id' => $storeId, + 'name' => 'media-upload-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'expires_at' => now()->addHour(), + ]); + + return $plainTextToken; +} + +function tinyPngBytes(): string +{ + $image = new Imagick; + $image->newImage(1, 1, new ImagickPixel('white')); + $image->setImageFormat('png'); + $bytes = $image->getImageBlob(); + $image->clear(); + $image->destroy(); + + return $bytes; +} + +it('creates a store-scoped signed upload and stores verified media once', function () { + Storage::fake('public'); + Queue::fake(); + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = productMediaApiToken($user, $store->id, ['write-products']); + + $presign = $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/presign-upload", [ + 'filename' => 'small.png', + 'content_type' => 'image/png', + 'byte_size' => strlen(tinyPngBytes()), + ])->assertCreated() + ->assertJsonPath('method', 'PUT') + ->assertJsonPath('headers.Content-Type', 'image/png'); + + $mediaId = $presign->json('media_id'); + $media = ProductMedia::query()->findOrFail($mediaId); + expect($media->storage_key)->toStartWith("stores/{$store->id}/products/{$product->id}/media/") + ->and($media->status)->toBe('processing'); + + $uploadUrl = $presign->json('upload_url'); + parse_str((string) parse_url($uploadUrl, PHP_URL_QUERY), $query); + $uploadPath = (string) parse_url($uploadUrl, PHP_URL_PATH); + $this->call('PUT', $uploadPath.'?'.http_build_query($query), [], [], [], [ + 'HTTP_HOST' => parse_url($uploadUrl, PHP_URL_HOST), + 'CONTENT_TYPE' => 'image/png', + 'HTTP_ACCEPT' => 'application/json', + ], tinyPngBytes())->assertOk() + ->assertJsonPath('media.status', 'processing'); + + Storage::disk('public')->assertExists($media->storage_key); + Queue::assertPushed(ProcessMediaUpload::class, fn (ProcessMediaUpload $job): bool => $job->mediaId === $mediaId); + (new ProcessMediaUpload($mediaId))->handle(); + $media->refresh(); + expect($media->status)->toBe('ready')->and($media->width)->toBe(1)->and($media->height)->toBe(1); + + foreach (['thumbnail', 'small', 'medium', 'large'] as $size) { + Storage::disk('public')->assertExists("media/{$product->id}/{$mediaId}/{$size}.png"); + Storage::disk('public')->assertExists("media/{$product->id}/{$mediaId}/{$size}.webp"); + } + + expect($media->thumbnail_url)->toContain("media/{$product->id}/{$mediaId}/thumbnail.webp"); + + $this->call('PUT', $uploadPath.'?'.http_build_query($query), [], [], [], [ + 'HTTP_HOST' => parse_url($uploadUrl, PHP_URL_HOST), + 'CONTENT_TYPE' => 'image/png', + 'HTTP_ACCEPT' => 'application/json', + ], tinyPngBytes())->assertConflict(); +}); + +it('removes the original and generated image sizes when product media is deleted', function () { + Storage::fake('public'); + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $media = ProductMedia::query()->create([ + 'product_id' => $product->id, + 'type' => 'image', + 'storage_key' => 'stores/1/products/1/media/example.png', + 'mime_type' => 'image/png', + 'byte_size' => 10, + 'status' => 'ready', + ]); + Storage::disk('public')->put($media->storage_key, 'original'); + Storage::disk('public')->put("media/{$product->id}/{$media->id}/thumbnail.webp", 'thumb'); + + $media->delete(); + + Storage::disk('public')->assertMissing($media->storage_key); + Storage::disk('public')->assertMissing("media/{$product->id}/{$media->id}/thumbnail.webp"); +}); + +it('rejects invalid media metadata, missing abilities, and tampered upload URLs', function () { + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $readToken = productMediaApiToken($user, $store->id, ['read-products']); + + $this->withToken($readToken)->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/presign-upload", [ + 'filename' => 'small.png', + 'content_type' => 'image/png', + 'byte_size' => 67, + ])->assertForbidden(); + + $writeToken = productMediaApiToken($user, $store->id, ['write-products']); + $this->withToken($writeToken)->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/presign-upload", [ + 'filename' => 'small.jpg', + 'content_type' => 'image/png', + 'byte_size' => 67, + ])->assertUnprocessable(); + + $presign = $this->withToken($writeToken)->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/presign-upload", [ + 'filename' => 'small.png', + 'content_type' => 'image/png', + 'byte_size' => 67, + ])->assertCreated(); + $uploadUrl = $presign->json('upload_url').'&tampered=1'; + parse_str((string) parse_url($uploadUrl, PHP_URL_QUERY), $query); + $uploadPath = (string) parse_url($uploadUrl, PHP_URL_PATH); + + $this->call('PUT', $uploadPath.'?'.http_build_query($query), [], [], [], [ + 'HTTP_HOST' => parse_url($uploadUrl, PHP_URL_HOST), + 'CONTENT_TYPE' => 'image/png', + 'HTTP_ACCEPT' => 'application/json', + ], tinyPngBytes())->assertForbidden(); +}); + +it('applies the configured image size limit when issuing signed upload URLs', function () { + config()->set('shop.media.image_max_bytes', 66); + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + $token = productMediaApiToken($user, $store->id, ['write-products']); + + $this->withToken($token)->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/presign-upload", [ + 'filename' => 'small.png', + 'content_type' => 'image/png', + 'byte_size' => 67, + ])->assertUnprocessable() + ->assertJsonValidationErrors('byte_size'); +}); diff --git a/tests/Feature/ScheduledMaintenanceTest.php b/tests/Feature/ScheduledMaintenanceTest.php new file mode 100644 index 00000000..63f52501 --- /dev/null +++ b/tests/Feature/ScheduledMaintenanceTest.php @@ -0,0 +1,105 @@ +update(['quantity_reserved' => 2]); + $cart = Cart::factory()->create(['store_id' => $store->id]); + $cart->lines()->create([ + 'variant_id' => $product['variant']->id, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 5000, + 'line_discount_amount' => 0, + 'line_total_amount' => 5000, + ]); + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'status' => 'payment_selected', + 'payment_method' => 'credit_card', + 'expires_at' => now()->subMinute(), + ]); + + (new ExpireAbandonedCheckouts)->handle(app(CheckoutService::class)); + + expect($checkout->refresh()->status)->toBe('expired') + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(0); +}); + +it('abandons stale carts and expires their active checkouts', function () { + $store = shopStore(); + $product = shopProduct($store); + $product['inventory']->update(['quantity_reserved' => 1]); + $cart = Cart::factory()->create(['store_id' => $store->id]); + $cart->lines()->create([ + 'variant_id' => $product['variant']->id, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]); + DB::table('carts')->where('id', $cart->id)->update(['updated_at' => now()->subDays(15)]); + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'status' => 'payment_selected', + 'payment_method' => 'credit_card', + 'expires_at' => now()->addHour(), + ]); + + (new CleanupAbandonedCarts)->handle(app(CheckoutService::class)); + + expect($cart->refresh()->status)->toBe('abandoned') + ->and($checkout->refresh()->status)->toBe('expired') + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(0); +}); + +it('cancels overdue bank transfers using each store configured timeout', function () { + $store = shopStore(); + StoreSettings::query()->create(['store_id' => $store->id, 'settings_json' => ['bank_transfer_cancel_days' => 3]]); + $product = shopProduct($store, stock: 12); + $product['inventory']->update(['quantity_reserved' => 2]); + $order = Order::query()->create([ + 'store_id' => $store->id, + 'order_number' => '#OLD-1001', + 'payment_method' => 'bank_transfer', + 'status' => 'pending', + 'financial_status' => 'pending', + 'currency' => 'EUR', + 'total_amount' => 5000, + 'placed_at' => now()->subDays(4), + ]); + $line = $order->lines()->create([ + 'variant_id' => $product['variant']->id, + 'product_id' => $product['product']->id, + 'title_snapshot' => $product['product']->title, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + $payment = $order->payments()->create([ + 'method' => 'bank_transfer', + 'status' => 'pending', + 'amount' => 5000, + 'currency' => 'EUR', + ]); + + (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class)); + + expect($order->refresh()->status)->toBe('cancelled') + ->and($order->financial_status)->toBe('voided') + ->and($payment->refresh()->status)->toBe('failed') + ->and($product['inventory']->refresh()->quantity_reserved)->toBe(0); +}); diff --git a/tests/Feature/SearchApiTest.php b/tests/Feature/SearchApiTest.php new file mode 100644 index 00000000..ad8de9c8 --- /dev/null +++ b/tests/Feature/SearchApiTest.php @@ -0,0 +1,45 @@ +update(['compare_at_amount' => 3500]); + $media = ProductMedia::query()->create([ + 'product_id' => $product['product']->id, + 'type' => 'image', + 'storage_key' => 'products/test-shirt.png', + 'alt_text' => 'Blue cotton shirt', + 'mime_type' => 'image/png', + 'byte_size' => 100, + 'position' => 0, + 'status' => 'ready', + ]); + Storage::disk('public')->put($media->storage_key, 'placeholder'); + + $this->getJson('http://shop.test/api/storefront/v1/search?q=cotton&per_page=1') + ->assertOk() + ->assertJsonPath('pagination.total', 1) + ->assertJsonPath('pagination.per_page', 1) + ->assertJsonPath('results.0.id', $product['product']->id) + ->assertJsonPath('results.0.price_amount', 2500) + ->assertJsonPath('results.0.compare_at_amount', 3500) + ->assertJsonPath('results.0.image_url', Storage::disk('public')->url($media->storage_key)); +}); + +it('returns a client error for malformed filters and validation errors for invalid filter shapes', function () { + shopStore(); + + $this->getJson('http://shop.test/api/storefront/v1/search?q=shirt&filters='.rawurlencode('{"tags":')) + ->assertBadRequest(); + + $this->getJson('http://shop.test/api/storefront/v1/search?q=shirt&filters='.rawurlencode('{"tags":"cotton"}')) + ->assertUnprocessable() + ->assertJsonValidationErrors('tags'); + + $this->getJson('http://shop.test/api/storefront/v1/search?q='.str_repeat('x', 201)) + ->assertBadRequest(); +}); diff --git a/tests/Feature/SearchSettingsTest.php b/tests/Feature/SearchSettingsTest.php new file mode 100644 index 00000000..7230704b --- /dev/null +++ b/tests/Feature/SearchSettingsTest.php @@ -0,0 +1,83 @@ +instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('synonymGroups', ['tee, t-shirt, tshirt', '']) + ->set('stopWords', 'the, a, ARE') + ->call('save') + ->assertHasNoErrors(); + + $settings = DB::table('search_settings')->where('store_id', $store->id)->first(); + expect(json_decode($settings->synonyms_json, true))->toBe([['tee', 't-shirt', 'tshirt']]) + ->and(json_decode($settings->stop_words_json, true))->toBe(['the', 'a', 'are']); +}); + +it('uses synonyms and stop words when building FTS expressions', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $tee = Product::factory()->create(['store_id' => $store->id, 'title' => 'Lightweight Tee']); + $shirt = Product::factory()->create(['store_id' => $store->id, 'title' => 'Cotton T-Shirt']); + DB::table('search_settings')->insert([ + 'store_id' => $store->id, + 'synonyms_json' => json_encode([['tee', 't-shirt']], JSON_THROW_ON_ERROR), + 'stop_words_json' => json_encode(['the'], JSON_THROW_ON_ERROR), + ]); + + $search = app(SearchService::class); + $expression = $search->fullTextExpression($store->id, 'the tee'); + $productIds = $search->rankedProductIds($store->id, $expression); + + expect($expression)->toContain('tee') + ->and(array_map('intval', $productIds))->toContain($tee->id) + ->and(array_map('intval', $productIds))->toContain($shirt->id) + ->and($search->fullTextExpression($store->id, 'the'))->toBe(''); +}); + +it('queues a store-scoped reindex and rejects duplicate in-progress requests', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => 'owner']); + Queue::fake(); + + $component = Livewire::actingAs($user)->test(Settings::class)->call('reindex')->assertHasNoErrors(); + $component->call('reindex')->assertHasErrors('reindex'); + + Queue::assertPushed(ReindexStoreProducts::class, fn (ReindexStoreProducts $job): bool => $job->storeId === $store->id); + expect(DB::table('search_settings')->where('store_id', $store->id)->value('index_status'))->toBe('queued'); +}); + +it('rebuilds only the requested store index and tracks job progress', function () { + $firstStore = shopStore(); + $secondStore = shopStore('second-search.test'); + $firstProduct = Product::factory()->create(['store_id' => $firstStore->id, 'title' => 'Reindex this wool coat']); + $secondProduct = Product::factory()->create(['store_id' => $secondStore->id, 'title' => 'Keep this cotton bag']); + DB::table('products_fts')->where('product_id', (string) $firstProduct->id)->delete(); + + (new ReindexStoreProducts($firstStore->id))->handle(); + + $firstIndexedTitle = DB::table('products_fts')->where('product_id', (string) $firstProduct->id)->value('title'); + $secondIndexedTitle = DB::table('products_fts')->where('product_id', (string) $secondProduct->id)->value('title'); + $settings = DB::table('search_settings')->where('store_id', $firstStore->id)->first(); + expect($firstIndexedTitle)->toBe('Reindex this wool coat') + ->and($secondIndexedTitle)->toBe('Keep this cotton bag') + ->and($settings->index_status)->toBe('ready') + ->and($settings->documents_count)->toBe(1) + ->and($settings->pending_updates)->toBe(0) + ->and($settings->last_reindex_at)->not->toBeNull(); +}); diff --git a/tests/Feature/StoreDomainsAndSwitchingTest.php b/tests/Feature/StoreDomainsAndSwitchingTest.php new file mode 100644 index 00000000..1b1fcdff --- /dev/null +++ b/tests/Feature/StoreDomainsAndSwitchingTest.php @@ -0,0 +1,174 @@ +create(); + $user->stores()->attach($store->id, ['role' => $role]); + + return $user; +} + +it('selects a store when an administrator belongs to more than one store', function () { + $firstStore = shopStore('selection-first.test'); + $secondStore = shopStore('selection-second.test'); + $user = domainAdmin($firstStore); + $user->stores()->attach($secondStore->id, ['role' => 'admin']); + + $this->actingAs($user)->get('/admin') + ->assertRedirect(route('admin.select-store')); + + $this->actingAs($user)->get(route('admin.select-store')) + ->assertOk() + ->assertSee('Choose a store') + ->assertSee($firstStore->name) + ->assertSee($secondStore->name); + + Livewire::actingAs($user) + ->test(SelectStore::class) + ->call('select', (string) $secondStore->id) + ->assertRedirectToRoute('admin.dashboard'); + + $this->assertSame($secondStore->id, (int) session('current_store_id')); +}); + +it('redirects a successful login to store selection when the account has multiple stores', function () { + $firstStore = shopStore('login-first-selection.test'); + $secondStore = shopStore('login-second-selection.test'); + $user = User::factory()->create(['email' => 'multiple-stores@shop.test', 'password' => 'password']); + $user->stores()->attach([$firstStore->id => ['role' => 'owner'], $secondStore->id => ['role' => 'admin']]); + + Livewire::test(AdminLogin::class) + ->set('email', $user->email) + ->set('password', 'password') + ->call('authenticate') + ->assertRedirectToRoute('admin.select-store'); +}); + +it('renders the domains tab within the admin settings route', function () { + $store = shopStore('settings-domain-tab.test'); + $user = domainAdmin($store); + + $this->actingAs($user) + ->withSession(['current_store_id' => $store->id]) + ->get(route('admin.settings', ['tab' => 'domains'])) + ->assertOk() + ->assertSee('Domains') + ->assertSee('settings-domain-tab.test') + ->assertSee('Add domain'); +}); + +it('switches the current store only to a store the administrator can access', function () { + $firstStore = shopStore('switch-first.test'); + $secondStore = shopStore('switch-second.test'); + $foreignStore = shopStore('switch-foreign.test'); + $user = domainAdmin($firstStore); + $user->stores()->attach($secondStore->id, ['role' => 'admin']); + app()->instance('current_store', $firstStore); + session()->put('current_store_id', $firstStore->id); + + Livewire::actingAs($user) + ->test(TopBar::class) + ->assertSee($firstStore->name) + ->assertSee($secondStore->name) + ->call('switchStore', (string) $secondStore->id) + ->assertRedirectToRoute('admin.dashboard'); + + $this->assertSame($secondStore->id, (int) session('current_store_id')); + + Livewire::actingAs($user) + ->test(TopBar::class) + ->call('switchStore', (string) $foreignStore->id) + ->assertForbidden(); + + $this->assertSame($secondStore->id, (int) session('current_store_id')); +}); + +it('adds normalized domains with a primary per domain type', function () { + $store = Store::factory()->create(); + $user = domainAdmin($store); + session()->put('current_store_id', $store->id); + + Livewire::actingAs($user) + ->test(Domains::class) + ->call('openAddDomainModal') + ->set('newHostname', ' Shop.Example.Test ') + ->set('newType', 'storefront') + ->call('addDomain') + ->assertHasNoErrors() + ->assertSet('showAddDomainModal', false) + ->assertSee('shop.example.test') + ->assertSee('Primary'); + + $domain = StoreDomain::query()->where('hostname', 'shop.example.test')->firstOrFail(); + expect($domain->store_id)->toBe($store->id) + ->and($domain->type)->toBe('storefront') + ->and($domain->is_primary)->toBeTrue() + ->and($domain->tls_mode)->toBe('managed'); +}); + +it('rejects invalid duplicate and cross-tenant domain mutations', function () { + $store = shopStore(); + $otherStore = shopStore('domain-other-store.test'); + $user = domainAdmin($store); + app()->instance('current_store', $store); + $foreignDomain = $otherStore->domains()->firstOrFail(); + + Livewire::actingAs($user) + ->test(Domains::class) + ->set('newHostname', 'https://not-a-hostname.test/path') + ->call('addDomain') + ->assertHasErrors('newHostname') + ->set('newHostname', $foreignDomain->hostname) + ->call('addDomain') + ->assertHasErrors('newHostname'); + + expect(fn () => Livewire::actingAs($user)->test(Domains::class)->call('removeDomain', $foreignDomain->id)) + ->toThrow(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + + $this->assertModelExists($foreignDomain); + $this->assertDatabaseMissing('store_domains', ['store_id' => $store->id, 'hostname' => 'not-a-hostname.test']); +}); + +it('changes primary domains only within the selected store and domain type', function () { + $store = shopStore(); + $user = domainAdmin($store); + $currentPrimary = $store->domains()->where('type', 'storefront')->firstOrFail(); + $newPrimary = $store->domains()->create(['hostname' => 'secondary-storefront.test', 'type' => 'storefront', 'is_primary' => false, 'tls_mode' => 'managed']); + $adminPrimary = $store->domains()->create(['hostname' => 'admin-domain.test', 'type' => 'admin', 'is_primary' => true, 'tls_mode' => 'managed']); + app()->instance('current_store', $store); + Cache::put('store-domain:secondary-storefront.test', $store->id, now()->addMinute()); + + Livewire::actingAs($user)->test(Domains::class)->call('setPrimary', $newPrimary->id)->assertHasNoErrors(); + + expect($newPrimary->refresh()->is_primary)->toBeTrue() + ->and($currentPrimary->refresh()->is_primary)->toBeFalse() + ->and($adminPrimary->refresh()->is_primary)->toBeTrue() + ->and(Cache::get('store-domain:secondary-storefront.test'))->toBeNull(); +}); + +it('promotes the next domain when a primary is removed and denies non-admin domain management', function () { + $store = shopStore(); + $primary = $store->domains()->firstOrFail(); + $backup = $store->domains()->create(['hostname' => 'backup-domain.test', 'type' => 'storefront', 'is_primary' => false, 'tls_mode' => 'managed']); + $owner = domainAdmin($store); + app()->instance('current_store', $store); + Cache::put('store-domain:'.$primary->hostname, $store->id, now()->addMinute()); + + Livewire::actingAs($owner)->test(Domains::class)->call('removeDomain', $primary->id)->assertHasNoErrors(); + + expect($backup->refresh()->is_primary)->toBeTrue() + ->and(Cache::get('store-domain:'.$primary->hostname))->toBeNull(); + + $staff = domainAdmin($store, 'staff'); + Livewire::actingAs($staff)->test(Domains::class)->assertForbidden(); +}); diff --git a/tests/Feature/StorefrontAnalyticsEventsTest.php b/tests/Feature/StorefrontAnalyticsEventsTest.php new file mode 100644 index 00000000..2d826f0f --- /dev/null +++ b/tests/Feature/StorefrontAnalyticsEventsTest.php @@ -0,0 +1,58 @@ +instance('current_store', $store); + + $this->get('http://shop.test/products/'.$product['product']->handle) + ->assertOk() + ->assertSee('data-storefront-analytics', false) + ->assertSee('/api/storefront/v1/analytics/events', false); + + Livewire::test(ProductShow::class, ['handle' => $product['product']->handle]) + ->call('addToCart') + ->assertDispatched('storefront-analytics', type: 'add_to_cart'); +}); + +it('dispatches search and cart removal events after customer actions succeed', function () { + $store = shopStore(); + $product = shopProduct($store); + app()->instance('current_store', $store); + $cart = Cart::factory()->create(['store_id' => $store->id]); + session()->put('cart_id', $cart->id); + app(CartService::class)->add($cart, $product['variant'], 2); + $line = $cart->lines()->firstOrFail(); + + Livewire::test(SearchIndex::class) + ->set('query', 'cotton shirt') + ->assertDispatched('storefront-analytics', type: 'search'); + + Livewire::test(CartShow::class) + ->call('removeItem', $line->id) + ->assertDispatched('storefront-analytics', type: 'remove_from_cart'); +}); + +it('refreshes the storefront header cart count after cart updates', function () { + $store = shopStore(); + app()->instance('current_store', $store); + $product = shopProduct($store); + $cart = Cart::factory()->create(['store_id' => $store->id]); + session()->put('cart_id', $cart->id); + + $counter = Livewire::test(CartCount::class) + ->assertSee('Open shopping cart, 0 items'); + + app(CartService::class)->add($cart, $product['variant'], 2); + + $counter->dispatch('cart-updated') + ->assertSee('Open shopping cart, 2 items'); +}); diff --git a/tests/Feature/StorefrontAnalyticsIngestionTest.php b/tests/Feature/StorefrontAnalyticsIngestionTest.php new file mode 100644 index 00000000..bcc7e9d4 --- /dev/null +++ b/tests/Feature/StorefrontAnalyticsIngestionTest.php @@ -0,0 +1,25 @@ + 'product_view', + 'session_id' => 'browser-session-1', + 'client_event_id' => 'client-event-1', + 'properties' => ['product_id' => 42, 'url' => '/products/jacket'], + 'occurred_at' => now()->toIso8601String(), + ]; + + $firstResponse = $this->postJson('http://shop.test/api/storefront/v1/analytics/events', ['events' => [$event]]); + $firstResponse->assertAccepted()->assertExactJson(['accepted' => 1, 'rejected' => 0]); + + $saved = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->id)->where('client_event_id', 'client-event-1')->firstOrFail(); + expect($saved->properties_json)->toBe(['product_id' => 42, 'url' => '/products/jacket']) + ->and($saved->occurred_at)->not->toBeNull(); + + $duplicateResponse = $this->postJson('http://shop.test/api/storefront/v1/analytics/events', ['events' => [$event]]); + $duplicateResponse->assertAccepted()->assertExactJson(['accepted' => 0, 'rejected' => 1]); + expect(AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->id)->where('client_event_id', 'client-event-1')->count())->toBe(1); +}); diff --git a/tests/Feature/TaxProviderTest.php b/tests/Feature/TaxProviderTest.php new file mode 100644 index 00000000..afd872ff --- /dev/null +++ b/tests/Feature/TaxProviderTest.php @@ -0,0 +1,80 @@ +create(['store_id' => $store->id]); + app(CartService::class)->add($cart, $product['variant'], 1); + + return app(CheckoutService::class)->start($cart); +} + +function taxAddress(): array +{ + return [ + 'first_name' => 'Alex', + 'last_name' => 'Buyer', + 'address1' => '1 Test Street', + 'city' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + ]; +} + +it('calculates manual regional tax and stores a provider snapshot', function () { + $store = shopStore(); + TaxSetting::factory()->create([ + 'store_id' => $store->id, + 'mode' => 'manual', + 'default_rate' => 500, + 'config_json' => ['tax_rates' => [['country_code' => 'DE', 'province_code' => 'BE', 'rate' => 1900]]], + ]); + + $checkout = app(CheckoutService::class)->setAddress(taxCheckoutForStore($store), 'buyer@example.test', taxAddress()); + + expect($checkout->tax_amount)->toBe(475) + ->and($checkout->tax_provider_snapshot_json['provider'])->toBe('manual') + ->and($checkout->tax_provider_snapshot_json['rate'])->toBe(1900); +}); + +it('allows checkout to continue without tax when the unavailable provider fallback is allow', function () { + $store = shopStore(); + TaxSetting::factory()->create([ + 'store_id' => $store->id, + 'mode' => 'provider', + 'provider' => 'stripe_tax', + 'default_rate' => 1900, + 'config_json' => ['fallback' => 'allow'], + ]); + + $checkout = app(CheckoutService::class)->setAddress(taxCheckoutForStore($store), 'buyer@example.test', taxAddress()); + + expect($checkout->tax_amount)->toBe(0) + ->and($checkout->total_amount)->toBe(2500) + ->and($checkout->tax_provider_snapshot_json['provider'])->toBe('stripe_tax') + ->and($checkout->tax_provider_snapshot_json['status'])->toBe('fallback'); +}); + +it('blocks checkout and records the failed provider response when fallback is block', function () { + $store = shopStore(); + TaxSetting::factory()->create([ + 'store_id' => $store->id, + 'mode' => 'provider', + 'provider' => 'stripe_tax', + 'config_json' => ['fallback' => 'block'], + ]); + $checkout = taxCheckoutForStore($store); + + expect(fn () => app(CheckoutService::class)->setAddress($checkout, 'buyer@example.test', taxAddress())) + ->toThrow(ValidationException::class); + + expect($checkout->refresh()->tax_provider_snapshot_json['status'])->toBe('unavailable'); +}); diff --git a/tests/Feature/TenantResolutionTest.php b/tests/Feature/TenantResolutionTest.php new file mode 100644 index 00000000..100a689b --- /dev/null +++ b/tests/Feature/TenantResolutionTest.php @@ -0,0 +1,37 @@ +get('http://shop-one.test/') + ->assertOk(); + + $this->get('http://unknown.test/') + ->assertNotFound(); +}); + +it('returns a maintenance response for a suspended storefront', function () { + $store = shopStore('suspended.test'); + $store->update(['status' => 'suspended']); + + $this->get('http://suspended.test/') + ->assertServiceUnavailable(); +}); + +it('limits store-scoped product queries to the resolved tenant', function () { + $firstStore = shopStore('first.test'); + $firstProduct = shopProduct($firstStore)['product']; + $secondStore = shopStore('second.test'); + $secondProduct = shopProduct($secondStore)['product']; + + app()->instance('current_store', $firstStore); + + expect(Product::query()->pluck('id')->all())->toBe([$firstProduct->id]) + ->and(Product::withoutGlobalScopes()->count())->toBe(2); + + app()->instance('current_store', $secondStore); + + expect(Product::query()->pluck('id')->all())->toBe([$secondProduct->id]); +}); diff --git a/tests/Feature/VariantMatrixTest.php b/tests/Feature/VariantMatrixTest.php new file mode 100644 index 00000000..c0485816 --- /dev/null +++ b/tests/Feature/VariantMatrixTest.php @@ -0,0 +1,168 @@ +options()->create(['name' => $name, 'position' => $product->options()->count()]); + $optionValues = []; + + foreach ($values as $position => $value) { + $optionValues[$value] = $option->values()->create(['value' => $value, 'position' => $position]); + } + + return ['option' => $option, 'values' => $optionValues]; +} + +function variantMatrixMakeVariant(Product $product, array $valueIds, array $attributes = []): ProductVariant +{ + $position = $product->variants()->count(); + + $variant = $product->variants()->create(array_merge([ + 'sku' => 'MATRIX-'.fake()->unique()->numerify('#####'), + 'price_amount' => 4200, + 'compare_at_amount' => 5000, + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => true, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => 'active', + ], $attributes)); + + $variant->optionValues()->sync($valueIds); + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + 'quantity_on_hand' => 8, + 'quantity_reserved' => 2, + 'policy' => 'continue', + ]); + + return $variant; +} + +it('rebuilds the cartesian matrix while preserving matching variant data and creating inventory for new variants', function () { + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $size = variantMatrixMakeOption($product, 'Size', ['Small', 'Medium']); + $color = variantMatrixMakeOption($product, 'Color', ['Black', 'White']); + $preserved = variantMatrixMakeVariant($product, [$size['values']['Small']->id, $color['values']['Black']->id], [ + 'sku' => 'SHIRT-S-BLACK', + 'price_amount' => 6100, + 'compare_at_amount' => 7200, + 'weight_g' => 310, + ]); + $preservedInventory = $preserved->inventoryItem()->firstOrFail(); + + $variants = app(VariantMatrixService::class)->rebuild($product, [ + ['name' => 'Garment size', 'values' => ['Small', 'Medium']], + ['name' => 'Color', 'values' => ['Black', 'White']], + ]); + + expect($variants)->toHaveCount(4) + ->and($variants->pluck('id'))->toContain($preserved->id) + ->and($variants->firstWhere('id', $preserved->id)->sku)->toBe('SHIRT-S-BLACK') + ->and($variants->firstWhere('id', $preserved->id)->price_amount)->toBe(6100) + ->and($variants->firstWhere('id', $preserved->id)->compare_at_amount)->toBe(7200) + ->and($variants->firstWhere('id', $preserved->id)->inventoryItem->id)->toBe($preservedInventory->id) + ->and($variants->firstWhere('id', $preserved->id)->inventoryItem->quantity_on_hand)->toBe(8) + ->and($variants->firstWhere('id', $preserved->id)->inventoryItem->quantity_reserved)->toBe(2); + + expect(ProductOption::query()->where('product_id', $product->id)->whereKey($size['option']->id)->value('name')) + ->toBe('Garment size'); + + $newVariants = $variants->where('id', '!=', $preserved->id); + + expect($newVariants)->toHaveCount(3); + + foreach ($newVariants as $variant) { + expect($variant->price_amount)->toBe(6100) + ->and($variant->compare_at_amount)->toBe(7200) + ->and($variant->currency)->toBe('EUR') + ->and($variant->weight_g)->toBe(310) + ->and($variant->sku)->toBeNull() + ->and($variant->inventoryItem)->not->toBeNull() + ->and($variant->inventoryItem->quantity_on_hand)->toBe(0) + ->and($variant->inventoryItem->quantity_reserved)->toBe(0) + ->and($variant->inventoryItem->policy)->toBe('continue'); + } +}); + +it('archives removed variants with order history and deletes removed variants without it', function () { + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $size = variantMatrixMakeOption($product, 'Size', ['Small', 'Medium', 'Large']); + $small = variantMatrixMakeVariant($product, [$size['values']['Small']->id]); + $medium = variantMatrixMakeVariant($product, [$size['values']['Medium']->id]); + $large = variantMatrixMakeVariant($product, [$size['values']['Large']->id]); + $order = Order::query()->create([ + 'store_id' => $store->id, + 'order_number' => 'MATRIX-ORDER-1', + 'payment_method' => 'mock', + ]); + OrderLine::query()->create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $medium->id, + 'title_snapshot' => $product->title, + 'quantity' => 1, + 'unit_price_amount' => 4200, + 'total_amount' => 4200, + ]); + + $variants = app(VariantMatrixService::class)->rebuild($product, [ + ['name' => 'Size', 'values' => ['Small', 'Extra Large']], + ]); + + expect($variants)->toHaveCount(2) + ->and(ProductVariant::query()->findOrFail($small->id)->status)->toBe('active') + ->and(ProductVariant::query()->findOrFail($medium->id)->status)->toBe('archived') + ->and(ProductVariant::query()->find($large->id))->toBeNull() + ->and($variants->firstWhere('id', $medium->id))->toBeNull(); +}); + +it('creates one default variant and inventory item for a product with no options or existing variants', function () { + $store = shopStore(currency: 'CHF'); + $product = Product::factory()->create(['store_id' => $store->id]); + + $variants = app(VariantMatrixService::class)->rebuild($product, []); + + expect($variants)->toHaveCount(1) + ->and($variants->first()->is_default)->toBeTrue() + ->and($variants->first()->price_amount)->toBe(0) + ->and($variants->first()->currency)->toBe('CHF') + ->and($variants->first()->inventoryItem)->not->toBeNull() + ->and($variants->first()->inventoryItem->quantity_on_hand)->toBe(0); +}); + +it('rejects more than three options, empty values, and blank option values without changing the product', function () { + $store = shopStore(); + $product = Product::factory()->create(['store_id' => $store->id]); + $service = app(VariantMatrixService::class); + + expect(fn () => $service->rebuild($product, [ + ['name' => 'Size', 'values' => ['Small']], + ['name' => 'Color', 'values' => ['Black']], + ['name' => 'Material', 'values' => ['Cotton']], + ['name' => 'Fit', 'values' => ['Regular']], + ]))->toThrow(ValidationException::class); + + expect(fn () => $service->rebuild($product, [ + ['name' => 'Size', 'values' => []], + ]))->toThrow(ValidationException::class); + + expect(fn () => $service->rebuild($product, [ + ['name' => 'Size', 'values' => [' ']], + ]))->toThrow(ValidationException::class); + + expect(ProductOption::query()->where('product_id', $product->id)->count())->toBe(0) + ->and(ProductVariant::query()->where('product_id', $product->id)->count())->toBe(0) + ->and(ProductOptionValue::query()->count())->toBe(0); +}); diff --git a/tests/Feature/WebhookDeliveryTest.php b/tests/Feature/WebhookDeliveryTest.php new file mode 100644 index 00000000..2a36c770 --- /dev/null +++ b/tests/Feature/WebhookDeliveryTest.php @@ -0,0 +1,69 @@ +create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example.test/events', + 'signing_secret_encrypted' => 'subscription-secret', + 'status' => 'active', + ]); + WebhookSubscription::query()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://paused.example.test/events', + 'signing_secret_encrypted' => 'paused-secret', + 'status' => 'paused', + ]); + Queue::fake(); + $service = app(WebhookService::class); + + expect($service->dispatch($store, 'order.created', ['order_id' => 42], new DateTimeImmutable('2026-04-12T10:11:12Z')))->toBe(1); + Queue::assertPushed(DeliverWebhook::class, fn (DeliverWebhook $job): bool => $job->subscriptionId === $subscription->id + && $job->eventType === 'order.created' + && $job->timestamp === 1775988672); + + Http::fake(['https://hooks.example.test/*' => Http::response('accepted', 202)]); + $service->deliver($subscription->id, 'delivery-event-1', 'order.created', ['order_id' => 42], 1775988672); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://hooks.example.test/events' + && $request->header('X-Platform-Event')[0] === 'order.created' + && $request->header('X-Platform-Timestamp')[0] === '1775988672' + && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', '1775988672.{"order_id":42}', 'subscription-secret')); + $delivery = WebhookDelivery::query()->where('event_id', 'delivery-event-1')->firstOrFail(); + expect($delivery->status)->toBe('success') + ->and($delivery->attempt_count)->toBe(1) + ->and($delivery->response_code)->toBe(202) + ->and($delivery->response_body_snippet)->toBe('accepted'); +}); + +it('pauses a webhook subscription after five consecutive failed attempts', function () { + $store = shopStore(); + $subscription = WebhookSubscription::query()->create([ + 'store_id' => $store->id, + 'event_type' => 'product.updated', + 'target_url' => 'https://hooks.example.test/fail', + 'signing_secret_encrypted' => 'subscription-secret', + 'status' => 'active', + ]); + Http::fake(['https://hooks.example.test/*' => Http::response('unavailable', 503)]); + $service = app(WebhookService::class); + + foreach (range(1, 5) as $attempt) { + expect(fn () => $service->deliver($subscription->id, 'delivery-event-2', 'product.updated', ['product_id' => 7], 1775988672)) + ->toThrow(RuntimeException::class); + } + + expect($subscription->refresh()->status)->toBe('paused') + ->and(WebhookDelivery::query()->where('event_id', 'delivery-event-2')->value('attempt_count'))->toBe(5) + ->and(WebhookDelivery::query()->where('event_id', 'delivery-event-2')->value('response_code'))->toBe(503); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..28c78995 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,7 +12,7 @@ */ pest()->extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); /* @@ -45,3 +45,43 @@ function something() { // .. } + +function shopStore(string $hostname = 'shop.test', string $currency = 'EUR'): \App\Models\Store +{ + $store = \App\Models\Store::factory()->create(['default_currency' => $currency]); + $store->domains()->create([ + 'hostname' => $hostname, + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]); + \Illuminate\Support\Facades\Cache::flush(); + + return $store; +} + +/** @return array{product: \App\Models\Product, variant: \App\Models\ProductVariant, inventory: \App\Models\InventoryItem} */ +function shopProduct(\App\Models\Store $store, int $price = 2500, int $stock = 10, bool $requiresShipping = true): array +{ + $product = \App\Models\Product::factory()->create([ + 'store_id' => $store->id, + 'title' => 'Test cotton shirt', + 'handle' => 'test-cotton-shirt-'.fake()->unique()->numberBetween(1, 100000), + 'status' => 'active', + 'published_at' => now(), + ]); + $variant = \App\Models\ProductVariant::factory()->for($product)->create([ + 'currency' => $store->default_currency, + 'price_amount' => $price, + 'requires_shipping' => $requiresShipping, + ]); + $inventory = \App\Models\InventoryItem::factory()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $stock, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + return ['product' => $product, 'variant' => $variant, 'inventory' => $inventory]; +} diff --git a/tests/Unit/DiscountCalculatorTest.php b/tests/Unit/DiscountCalculatorTest.php new file mode 100644 index 00000000..bac5929d --- /dev/null +++ b/tests/Unit/DiscountCalculatorTest.php @@ -0,0 +1,38 @@ + 'percentage', + 'value' => 10, + 'is_active' => true, + 'rules_json' => [], + ]); + $lines = [ + ['variant_id' => 1, 'line_subtotal_amount' => 1001], + ['variant_id' => 2, 'line_subtotal_amount' => 1000], + ]; + + $result = app(DiscountCalculator::class)->calculate($discount, $lines, 2001); + + expect($result)->toBe(['amount' => 200, 'allocations' => [1 => 100, 2 => 100]]); +}); + +it('applies product eligibility only to matching line items', function () { + $discount = new Discount([ + 'type' => 'fixed_amount', + 'value' => 300, + 'is_active' => true, + 'rules_json' => ['product_ids' => [12]], + ]); + $lines = [ + ['variant_id' => 1, 'product_id' => 12, 'line_subtotal_amount' => 500], + ['variant_id' => 2, 'product_id' => 13, 'line_subtotal_amount' => 500], + ]; + + $result = app(DiscountCalculator::class)->calculate($discount, $lines, 1000); + + expect($result)->toBe(['amount' => 300, 'allocations' => [1 => 300]]); +}); diff --git a/tests/Unit/HtmlSanitizerTest.php b/tests/Unit/HtmlSanitizerTest.php new file mode 100644 index 00000000..c7b08ab6 --- /dev/null +++ b/tests/Unit/HtmlSanitizerTest.php @@ -0,0 +1,19 @@ +sanitize('

Hello there

'); + + expect($html)->toContain('

Hello there

') + ->and($html)->not->toContain('onclick') + ->and($html)->not->toContain('alert(1)'); +}); + +it('removes unsafe links and protects links opened in a new tab', function () { + $html = app(HtmlSanitizer::class)->sanitize('unsafesafe'); + + expect($html)->toContain('unsafe') + ->and($html)->toContain('rel="noopener noreferrer"') + ->and($html)->not->toContain('javascript:'); +}); diff --git a/tests/Unit/PricingEngineTest.php b/tests/Unit/PricingEngineTest.php new file mode 100644 index 00000000..f7f184bc --- /dev/null +++ b/tests/Unit/PricingEngineTest.php @@ -0,0 +1,63 @@ + 'percentage', + 'value' => 10, + 'is_active' => true, + 'rules_json' => [], + ]); + + $result = app(PricingEngine::class)->calculate( + lines: [['variant_id' => 9, 'quantity' => 2, 'unit_price_amount' => 1000]], + shippingAmount: 500, + discount: $discount, + taxRate: 1900, + shippingTaxable: true, + currency: 'EUR', + ); + + expect($result->subtotal)->toBe(2000) + ->and($result->discount)->toBe(200) + ->and($result->shipping)->toBe(500) + ->and($result->taxTotal)->toBe(437) + ->and($result->total)->toBe(2737) + ->and($result->currency)->toBe('EUR') + ->and($result->discountAllocations)->toBe([9 => 200]); +}); + +it('does not add included tax on top of the customer total', function () { + $result = app(PricingEngine::class)->calculate( + lines: [['variant_id' => 3, 'quantity' => 1, 'unit_price_amount' => 1190]], + taxRate: 1900, + pricesIncludeTax: true, + ); + + expect($result->taxTotal)->toBe(190) + ->and($result->total)->toBe(1190); +}); + +it('applies an automatic free-shipping discount before shipping tax is calculated', function () { + $discount = new Discount([ + 'type' => 'free_shipping', + 'value' => 0, + 'is_active' => true, + 'rules_json' => ['activation_method' => 'automatic'], + ]); + + $result = app(PricingEngine::class)->calculate( + lines: [['variant_id' => 4, 'quantity' => 1, 'unit_price_amount' => 1000]], + shippingAmount: 500, + discount: $discount, + taxRate: 1000, + shippingTaxable: true, + ); + + expect($result->discount)->toBe(0) + ->and($result->shipping)->toBe(0) + ->and($result->taxTotal)->toBe(100) + ->and($result->total)->toBe(1100); +}); diff --git a/tests/Unit/TaxCalculatorTest.php b/tests/Unit/TaxCalculatorTest.php new file mode 100644 index 00000000..621f3efe --- /dev/null +++ b/tests/Unit/TaxCalculatorTest.php @@ -0,0 +1,26 @@ +calculate([3, 3, 3], 0, 1900); + + expect($lines)->toHaveCount(1) + ->and($lines[0]->amount)->toBe(3); +}); + +it('extracts included tax by truncating each gross amount', function () { + $lines = app(TaxCalculator::class)->calculate([1190, 1190], 0, 1900, pricesIncludeTax: true); + + expect($lines)->toHaveCount(1) + ->and($lines[0]->amount)->toBe(380); +}); + +it('prefers a country province rate and falls back to a country or default rate', function () { + $calculator = app(TaxCalculator::class); + + expect($calculator->rateForAddress(['DE' => ['BE' => 700, 'default' => 1900]], 'de', 'be', 500)) + ->toBe(700) + ->and($calculator->rateForAddress(['DE' => 1900], 'de', null, 500))->toBe(1900) + ->and($calculator->rateForAddress([], 'US', null, 500))->toBe(500); +});