output:
Chunk ID: c14dcb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1008
Output:
- 2026-05-04: `php artisan make:test ../Browser/SmokeTest --pest --no-interaction` created the Pest browser smoke test file at `tests/Browser/SmokeTest.php`; `tests/Pest.php` now binds `tests/Browser` to the Laravel test case, `phpunit.xml` includes the Browser suite in the default test run, and `.gitignore` excludes Pest browser screenshots.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the browser smoke test changes.
- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php` passed after adding automated browser smoke coverage: 3 tests, 26 assertions.
- 2026-05-04: `php artisan test --compact` passed with the Browser suite included in the default run after adding automated browser smoke coverage: 212 tests, 1151 assertions.
- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource controller, route group middleware, scoped exists validation, and JSON API testing docs plus Pest 4 docs before the admin collection API changes.
- 2026-05-04: `php artisan make:controller Api/Admin/V1/CollectionController --no-interaction`, `php artisan make:resource Admin/V1/CollectionResource --no-interaction`, and `php artisan make:test Api/AdminCollectionApiTest --pest --no-interaction` created the collection API controller/resource and tests.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin collection API changes.
- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin collection API changes: 28 tests, 218 assertions.
- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 12 admin API routes, including collection list/create/update/delete.
- 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions.
- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 validation, date validation, prohibited/unique update-field validation, JSON API testing, and Pest 4 docs before the admin discount API changes.
- 2026-05-04: `php artisan make:controller Api/Admin/V1/DiscountController --no-interaction`, `php artisan make:resource Admin/V1/DiscountResource --no-interaction`, and `php artisan make:test Api/AdminDiscountApiTest --pest --no-interaction` created the discount API controller/resource and tests.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin discount API changes.
- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminDiscountApiTest.php` passed after the admin discount API changes: 3 tests, 25 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions.
- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.
- 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.

## Decisions

- Follow the roadmap order strictly. Phase 1 is required before catalog, checkout, admin, storefront, and QA flows can be meaningfully implemented.
- Preserve the starter Fortify conventions where they do not conflict with the shop specs.
- Use integer minor units for all money columns and JSON text columns cast through Eloquent as specified.
- Keep `password_hash` as the database column for admin users and customers while exposing `password`/`getAuthPassword()` at the model layer for Fortify/Laravel auth compatibility.
- Seed both `shop.test` and `acme-fashion.test` as storefront domains for the first store so the goal URL and E2E spec URL can both resolve.
- Herd needs a site link for non-project-folder aliases; `acme-fashion.test` is linked to the same local project path as `shop.test` so the seeded domain can be verified in browser E2E flows.


output:
Chunk ID: 1b6f01
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2065
Output:
- `PricingEngine` applies an explicit checkout discount code first, then active automatic discounts returned by `DiscountService`.
- Order creation replays the same code-then-automatic discount sequence in memory so `order_lines.discount_allocations_json` keeps separate `{discount_id, code, amount}` entries for every line-item discount while preserving aggregate cart line totals.
- `orders.checkout_id` is intentionally added beyond the original schema table to enforce idempotent checkout completion without duplicate orders.
- Failed card payments release reserved inventory and move the checkout back to `shipping_selected` so customers can retry payment selection.
- Bank transfer order completion creates a pending order and payment while keeping inventory reserved until the later admin payment-confirmation flow.
- Inventory commit/release/restock locks by primary key without the current-store global scope because scheduled jobs can operate across stores in one run.
- Admin order Livewire components persist locked `storeId`/`orderId` state and reload orders without global scopes but with explicit store constraints before each service-backed action.
- Admin order actions deliberately delegate to `OrderService`, `RefundService`, and `FulfillmentService` so UI validation cannot bypass payment, refund, fulfillment, or inventory guards.
- Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
- Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
- Customer addresses are scoped through the locked customer id because `customer_addresses` intentionally belongs to customers and has no direct `store_id` column.
- Admin/store Livewire route middleware is registered with Livewire persistent middleware so component POST requests keep `current_store` binding and store-role checks after the initial full-page route load.
- Admin routes use a custom `EnsureUserEmailIsVerified` middleware so verified-user gating works for full-page Livewire routes without relying on the stock redirect middleware path that fails during Livewire component redirects in this app.
- Admin settings are split across `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, and `/admin/settings/notifications`; domains are managed on the general settings page because the current route surface does not need a separate domains route.
- Checkout and notification settings persist in `store_settings.settings_json`; `bank_transfer_cancel_days` remains a root-level key because the existing cancellation job already consumes it from that location.
- Admin navigation persists nested ordered menu items with Livewire `wire:sort`, up/down sibling controls, and a parent selector; children are saved under their selected top-level parent with per-parent positions.
- The theme editor uses the `ThemeSettingsService` default settings shape as the editable schema and writes back to `theme_settings.settings_json`.
- Theme files are seeded and edited through local disk storage at `theme_files.storage_key`; saving a file updates the stored contents plus `sha256` and `byte_size` metadata.
- Theme duplication copies each file's local disk contents to a new storage key for the copied theme instead of sharing the original file storage path.
- Search uses a local SQLite FTS5 virtual table (`products_fts`) with `SearchService` as the only query/sync boundary; product observer events keep the index synchronized for create/update/delete.
- Search settings apply at query time: per-store stop words are removed, synonym groups expand into sanitized SQLite FTS5 `OR` terms, and terms are joined with explicit `AND`; the admin page can rebuild the per-store index synchronously for this self-contained app.
- Search API rate limiting is registered as `search` (30/minute per IP), analytics ingestion uses the `analytics` limiter (60/minute per IP), and a `webhooks` limiter is registered for future inbound app endpoints.
- Analytics ingestion stores raw events with client-event deduplication and keeps aggregation separate in `analytics_daily`; admin analytics reads pre-aggregated daily metrics for KPIs and raw events for referrers.
- Seeded analytics metrics are deterministic demo data and are not tied to seeded orders because order/payment seed data remains intentionally absent.
- The admin analytics CSV export is generated as a data URL for the self-contained local app rather than creating persistent export files/jobs.
- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
- Admin order API routes use the `admin.api` middleware, which accepts either the existing session-authenticated admin user or a hashed `oauth_tokens` bearer token scoped to the route store; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations and update `last_used_at` on successful use.
- Outbound webhook delivery jobs are forced onto the database queue and `webhooks` queue name so domain events enqueue delivery work instead of making external HTTP requests inline when the default queue connection is `sync`.
- Webhook signatures follow the security spec message shape of `{timestamp}.{json_body}` with HMAC-SHA256, while the delivery payload is wrapped with `id`, `api_version`, `event_type`, `store_id`, `occurred_at`, and `data`.
- Webhook circuit breaker state is derived from the latest five delivery rows for a subscription because the schema does not include a dedicated consecutive-failure counter column.
- Customer password resets use a custom store-scoped service instead of Laravel's stock password broker token repository because the `customer_password_reset_tokens` table includes `store_id` in the primary key and the stock repository does not write tenant columns.
- Customer password reset pages are routed under `/account/forgot-password` and `/account/reset-password/{token}` so the existing Fortify starter/admin root routes (`/forgot-password`, `/reset-password/{token}`) remain intact.
- Product media uploads use Livewire temporary file uploads on the existing product form, create `ProductMedia` rows in `processing` status, and dispatch the existing `ProcessMediaUpload` job instead of adding a second media-processing path.
- Resource policies now type concrete model parameters for Product, Collection, Customer, Discount, Order, Fulfillment, Refund, Page, and Theme actions; `ChecksStoreRole::storeIdForModel()` stays generic because it is the shared store-id extraction helper.

## Open Issues

- Customer password reset is implemented under `/account/forgot-password` and `/account/reset-password/{token}`; the exact spec root paths remain occupied by the existing Fortify starter/admin reset routes.
- `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
- Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
- SQLite enum/check constraints from the schema spec are not yet explicitly enforced as database `CHECK` constraints; enum validation is currently enforced through casts/services/model invariants.

## Completion Summary


command:
/bin/zsh
-lc
sed -n '416,428p' specs/progress.md

progress.md

type: read

cmd: sed -n '416,428p' specs/progress.md

aggregated_output:
## Completion Summary

Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.

status: completed


output:
Chunk ID: 92d648
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 233
Output:
## Completion Summary

Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.
 - 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 scoped unique validation, route model binding, API resources/pagination, JSON API validation testing, and Pest 4 docs before the admin page API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/PageController --no-interaction`, `php artisan make:resource Admin/V1/PageResource --no-interaction`, and `php artisan make:test Api/AdminPageApiTest --pest --no-interaction` created the content page API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin page API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminPageApiTest.php` passed after the admin page API changes: 3 tests, 26 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin page API changes: 34 tests, 269 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 20 admin API routes, including page list/create/update/delete.
@@
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, and order fulfillment/refund mutations.
@@
-- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
@@
-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/specs/progress.md

type: update

unified_diff:
@@ -29,3 +29,3 @@
 | Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
 | Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
@@ -347,2 +347,8 @@
 - 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 scoped unique validation, route model binding, API resources/pagination, JSON API validation testing, and Pest 4 docs before the admin page API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/PageController --no-interaction`, `php artisan make:resource Admin/V1/PageResource --no-interaction`, and `php artisan make:test Api/AdminPageApiTest --pest --no-interaction` created the content page API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin page API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminPageApiTest.php` passed after the admin page API changes: 3 tests, 26 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin page API changes: 34 tests, 269 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 20 admin API routes, including page list/create/update/delete.

@@ -380,3 +386,3 @@
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, and order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
@@ -411,3 +417,3 @@
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
@@ -417,2 +423,2 @@

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: Progress is updated for the page API slice. I’m running the full suite now; if it stays green I’ll do whitespace/status checks and commit this slice.

phase: commentary


type: message

role: assistant

text: Progress is updated for the page API slice. I’m running the full suite now; if it stays green I’ll do whitespace/status checks and commit this slice.


output:
Chunk ID: b2e732
Wall time: 1.0013 seconds
Process running with session ID 87571
Original token count: 1
Output:

  .


output:
Chunk ID: 18acff
Wall time: 5.0010 seconds
Process running with session ID 87571
Original token count: 7
Output:
..........................


output:
Chunk ID: ccd712
Wall time: 5.0006 seconds
Process running with session ID 87571
Original token count: 7
Output:
............................


output:
Chunk ID: 29876b
Wall time: 5.0011 seconds
Process running with session ID 87571
Original token count: 16
Output:
.....................
  .....................................


output:
Chunk ID: 755a3e
Wall time: 5.0015 seconds
Process running with session ID 87571
Original token count: 23
Output:
.......................................
  ..................................................


output:
Chunk ID: 0a4e55
Wall time: 5.0003 seconds
Process running with session ID 87571
Original token count: 21
Output:
...................

  Tests:    221 passed (1226 assertions)
  Duration: 55.68s


command:
/bin/zsh
-lc
php artisan test --compact

type: unknown

cmd: php artisan test --compact


output:
Chunk ID: aa46e9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


message: Full suite is green after the page API changes: 221 tests, 1226 assertions. I’m recording that final evidence before staging.


text: Full suite is green after the page API changes: 221 tests, 1226 assertions. I’m recording that final evidence before staging.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminPageApiTest.php` passed after the admin page API changes: 3 tests, 26 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin page API changes: 34 tests, 269 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 20 admin API routes, including page list/create/update/delete.
+- 2026-05-04: `php artisan test --compact` passed after the admin page API changes: 221 tests, 1226 assertions.

 ## Decisions
*** End Patch


unified_diff:
@@ -353,2 +353,3 @@
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 20 admin API routes, including page list/create/update/delete.
+- 2026-05-04: `php artisan test --compact` passed after the admin page API changes: 221 tests, 1226 assertions.


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


command:
/bin/zsh
-lc
git status --short

cmd: git status --short

aggregated_output:
 M routes/api.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/PageController.php
?? app/Http/Resources/Admin/V1/PageResource.php
?? tests/Feature/Api/AdminPageApiTest.php


output:
Chunk ID: 492dcd
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 89
Output:
 M routes/api.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/PageController.php
?? app/Http/Resources/Admin/V1/PageResource.php
?? tests/Feature/Api/AdminPageApiTest.php


command:
/bin/zsh
-lc
git diff --stat

cmd: git diff --stat

aggregated_output:
 routes/api.php    | 11 +++++++++++
 specs/progress.md | 15 +++++++++++----
 2 files changed, 22 insertions(+), 4 deletions(-)


command:
/bin/zsh
-lc
git diff --check

cmd: git diff --check


output:
Chunk ID: 5574b7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: f34cbf
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 32
Output:
 routes/api.php    | 11 +++++++++++
 specs/progress.md | 15 +++++++++++----
 2 files changed, 22 insertions(+), 4 deletions(-)


message: Generated Playwright console logs reappeared after the browser suite ran. I’m removing only those untracked artifacts, then I’ll stage the page API files.


text: Generated Playwright console logs reappeared after the browser suite ran. I’m removing only those untracked artifacts, then I’ll stage the page API files.


command:
/bin/zsh
-lc
git clean -f .playwright-mcp

cmd: git clean -f .playwright-mcp

aggregated_output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


output:
Chunk ID: 79082d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


cmd: sed -n '1,280p' app/Http/Controllers/Api/Admin/V1/PageController.php


aggregated_output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/PageController.php
?? app/Http/Resources/Admin/V1/PageResource.php
?? tests/Feature/Api/AdminPageApiTest.php


command: sed -n '1,280p' app/Http/Controllers/Api/Admin/V1/PageController.php

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Enums\PageStatus;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\PageResource;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Services\NavigationService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator as ValidationValidator;

class PageController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in($this->pageStatusValues())],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $pages = Page::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', '%'.$search.'%')
                        ->orWhere('handle', 'like', '%'.$search.'%');
                });
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return PageResource::collection($pages);
    }

    public function store(Request $request, Store $store, NavigationService $navigation): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $this->validatePayload($request, $store);

        $page = Page::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            ...$this->attributesForCreate($validated),
        ]);

        $this->forgetNavigation($store, $navigation);

        return PageResource::make($page)
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Page $page, NavigationService $navigation): PageResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessPageBelongsToStore($page, $store);

        $validated = $this->validatePayload($request, $store, $page);
        $attributes = $this->attributesForUpdate($validated, $page);

        if ($attributes !== []) {
            $page->update($attributes);
            $this->forgetNavigation($store, $navigation);
        }

        return PageResource::make($page->refresh());
    }

    public function destroy(Request $request, Store $store, Page $page, NavigationService $navigation): JsonResponse
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessPageBelongsToStore($page, $store);

        $page->delete();
        $this->forgetNavigation($store, $navigation);

        return response()->json(['message' => 'Page deleted']);
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessPageBelongsToStore(Page $page, Store $store): void
    {
        abort_unless((int) $page->store_id === $store->getKey(), 404);
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Page $page = null): array
    {
        $creating = $page === null;

        $validator = Validator::make($request->all(), [
            'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
            'handle' => ['sometimes', 'nullable', 'string', 'max:255'],
            'body_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'status' => ['sometimes', Rule::in($this->pageStatusValues())],
            'published_at' => ['sometimes', 'nullable', 'date'],
        ]);

        $validator->after(function (ValidationValidator $validator) use ($request, $store, $page, $creating): void {
            if ($validator->errors()->isNotEmpty()) {
                return;
            }

            $handle = $this->handleForRequest($request, $page, $creating);

            if ($handle === '') {
                $validator->errors()->add('handle', __('The handle must contain at least one letter or number.'));

                return;
            }

            $exists = Page::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('handle', $handle)
                ->when($page instanceof Page, fn (Builder $query) => $query->whereKeyNot($page->getKey()))
                ->exists();

            if ($exists) {
                $validator->errors()->add('handle', __('The handle has already been taken.'));
            }
        });

        return $validator->validate();
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated): array
    {
        $status = PageStatus::from(data_get($validated, 'status', PageStatus::Draft->value));

        return [
            'title' => $validated['title'],
            'handle' => $this->normalizeHandle($validated['handle'] ?? $validated['title']),
            'body_html' => $validated['body_html'] ?? null,
            'status' => $status,
            'published_at' => $this->publishedAtForCreate($validated, $status),
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated, Page $page): array
    {
        $attributes = Arr::only($validated, ['title', 'body_html']);

        if (array_key_exists('handle', $validated) && filled($validated['handle'])) {
            $attributes['handle'] = $this->normalizeHandle($validated['handle']);
        }

        if (array_key_exists('status', $validated)) {
            $attributes['status'] = PageStatus::from($validated['status']);
        }

        if (array_key_exists('published_at', $validated)) {
            $attributes['published_at'] = $validated['published_at'];
        } elseif (($attributes['status'] ?? $page->status) === PageStatus::Published && $page->published_at === null) {
            $attributes['published_at'] = now();
        }

        return $attributes;
    }

    /**
     * @param  array<string, mixed>  $validated
     */
    private function publishedAtForCreate(array $validated, PageStatus $status): mixed
    {
        if (array_key_exists('published_at', $validated)) {
            return $validated['published_at'];
        }

        return $status === PageStatus::Published ? now() : null;
    }

    private function handleForRequest(Request $request, ?Page $page, bool $creating): string
    {
        if ($request->exists('handle') && filled($request->input('handle'))) {
            return $this->normalizeHandle($request->input('handle'));
        }

        if ($creating) {
            return $this->normalizeHandle($request->input('title'));
        }

        return (string) $page?->handle;
    }

    private function normalizeHandle(mixed $value): string
    {
        return Str::slug((string) $value);
    }

    /**
     * @return list<string>
     */
    private function pageStatusValues(): array
    {
        return array_map(fn (PageStatus $status): string => $status->value, PageStatus::cases());
    }

    private function forgetNavigation(Store $store, NavigationService $navigation): void
    {
        NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->get()
            ->each(fn (NavigationMenu $menu): mixed => $navigation->forget($menu));
    }
}


output:
Chunk ID: 0f741c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/PageController.php
?? app/Http/Resources/Admin/V1/PageResource.php
?? tests/Feature/Api/AdminPageApiTest.php


output:
Chunk ID: 3e6ec2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: 35831f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2087
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Enums\PageStatus;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\PageResource;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Services\NavigationService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator as ValidationValidator;

class PageController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in($this->pageStatusValues())],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $pages = Page::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', '%'.$search.'%')
                        ->orWhere('handle', 'like', '%'.$search.'%');
                });
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return PageResource::collection($pages);
    }

    public function store(Request $request, Store $store, NavigationService $navigation): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $this->validatePayload($request, $store);

        $page = Page::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            ...$this->attributesForCreate($validated),
        ]);

        $this->forgetNavigation($store, $navigation);

        return PageResource::make($page)
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Page $page, NavigationService $navigation): PageResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessPageBelongsToStore($page, $store);

        $validated = $this->validatePayload($request, $store, $page);
        $attributes = $this->attributesForUpdate($validated, $page);

        if ($attributes !== []) {
            $page->update($attributes);
            $this->forgetNavigation($store, $navigation);
        }

        return PageResource::make($page->refresh());
    }

    public function destroy(Request $request, Store $store, Page $page, NavigationService $navigation): JsonResponse
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessPageBelongsToStore($page, $store);

        $page->delete();
        $this->forgetNavigation($store, $navigation);

        return response()->json(['message' => 'Page deleted']);
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessPageBelongsToStore(Page $page, Store $store): void
    {
        abort_unless((int) $page->store_id === $store->getKey(), 404);
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Page $page = null): array
    {
        $creating = $page === null;

        $validator = Validator::make($request->all(), [
            'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
            'handle' => ['sometimes', 'nullable', 'string', 'max:255'],
            'body_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'status' => ['sometimes', Rule::in($this->pageStatusValues())],
            'published_at' => ['sometimes', 'nullable', 'date'],
        ]);

        $validator->after(function (ValidationValidator $validator) use ($request, $store, $page, $creating): void {
            if ($validator->errors()->isNotEmpty()) {
                return;
            }

            $handle = $this->handleForRequest($request, $page, $creating);

            if ($handle === '') {
                $validator->errors()->add('handle', __('The handle must contain at least one letter or number.'));

                return;
            }

            $exists = Page::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('handle', $handle)
                ->when($page instanceof Page, fn (Builder $query) => $query->whereKeyNot($page->getKey()))
                ->exists();

            if ($exists) {
                $validator->errors()->add('handle', __('The handle has already been taken.'));
            }
        });

        return $validator->validate();
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated): array
    {
        $status = PageStatus::from(data_get($validated, 'status', PageStatus::Draft->value));

        return [
            'title' => $validated['title'],
            'handle' => $this->normalizeHandle($validated['handle'] ?? $validated['title']),
            'body_html' => $validated['body_html'] ?? null,
            'status' => $status,
            'published_at' => $this->publishedAtForCreate($validated, $status),
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated, Page $page): array
    {
        $attributes = Arr::only($validated, ['title', 'body_html']);

        if (array_key_exists('handle', $validated) && filled($validated['handle'])) {
            $attributes['handle'] = $this->normalizeHandle($validated['handle']);
        }

        if (array_key_exists('status', $validated)) {
            $attributes['status'] = PageStatus::from($validated['status']);
        }

        if (array_key_exists('published_at', $validated)) {
            $attributes['published_at'] = $validated['published_at'];
        } elseif (($attributes['status'] ?? $page->status) === PageStatus::Published && $page->published_at === null) {
            $attributes['published_at'] = now();
        }

        return $attributes;
    }

    /**
     * @param  array<string, mixed>  $validated
     */
    private function publishedAtForCreate(array $validated, PageStatus $status): mixed
    {
        if (array_key_exists('published_at', $validated)) {
            return $validated['published_at'];
        }

        return $status === PageStatus::Published ? now() : null;
    }

    private function handleForRequest(Request $request, ?Page $page, bool $creating): string
    {
        if ($request->exists('handle') && filled($request->input('handle'))) {
            return $this->normalizeHandle($request->input('handle'));
        }

        if ($creating) {
            return $this->normalizeHandle($request->input('title'));
        }

        return (string) $page?->handle;
    }

    private function normalizeHandle(mixed $value): string
    {
        return Str::slug((string) $value);
    }

    /**
     * @return list<string>
     */
    private function pageStatusValues(): array
    {
        return array_map(fn (PageStatus $status): string => $status->value, PageStatus::cases());
    }

    private function forgetNavigation(Store $store, NavigationService $navigation): void
    {
        NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->get()
            ->each(fn (NavigationMenu $menu): mixed => $navigation->forget($menu));
    }
}


cmd: git add app/Http/Controllers/Api/Admin/V1/PageController.php app/Http/Resources/Admin/V1/PageResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminPageApiTest.php


command: git add app/Http/Controllers/Api/Admin/V1/PageController.php app/Http/Resources/Admin/V1/PageResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminPageApiTest.php


output:
Chunk ID: acdc09
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


command:
/bin/zsh
-lc
git diff --cached --stat

cmd: git diff --cached --stat

aggregated_output:
 .../Controllers/Api/Admin/V1/PageController.php    | 239 +++++++++++++++++++++
 app/Http/Resources/Admin/V1/PageResource.php       |  29 +++
 routes/api.php                                     |  11 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminPageApiTest.php             | 145 +++++++++++++
 5 files changed, 435 insertions(+), 4 deletions(-)


aggregated_output:
A  app/Http/Controllers/Api/Admin/V1/PageController.php
A  app/Http/Resources/Admin/V1/PageResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminPageApiTest.php


output:
Chunk ID: a08a89
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 97
Output:
 .../Controllers/Api/Admin/V1/PageController.php    | 239 +++++++++++++++++++++
 app/Http/Resources/Admin/V1/PageResource.php       |  29 +++
 routes/api.php                                     |  11 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminPageApiTest.php             | 145 +++++++++++++
 5 files changed, 435 insertions(+), 4 deletions(-)


output:
Chunk ID: 60b9c5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
A  app/Http/Controllers/Api/Admin/V1/PageController.php
A  app/Http/Resources/Admin/V1/PageResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminPageApiTest.php


cmd: git commit -m "Add admin page API endpoints"


command: git commit -m "Add admin page API endpoints"

aggregated_output:
[2026-05-04-codex-5-5-goal d8fd4ebe] Add admin page API endpoints
 5 files changed, 435 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/PageController.php
 create mode 100644 app/Http/Resources/Admin/V1/PageResource.php
 create mode 100644 tests/Feature/Api/AdminPageApiTest.php


output:
Chunk ID: 95b641
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 79
Output:
[2026-05-04-codex-5-5-goal d8fd4ebe] Add admin page API endpoints
 5 files changed, 435 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/PageController.php
 create mode 100644 app/Http/Resources/Admin/V1/PageResource.php
 create mode 100644 tests/Feature/Api/AdminPageApiTest.php


message: Committed `d8fd4ebe` for the page API. I’m continuing into the remaining admin REST gaps; next I’ll pick a narrow surface with existing services and tests, likely search settings or analytics read/export.


text: Committed `d8fd4ebe` for the page API. I’m continuing into the remaining admin REST gaps; next I’ll pick a narrow surface with existing services and tests, likely search settings or analytics read/export.


output:
Chunk ID: b2d3ee
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


cmd: rg -n "Admin .*Settings|Search|Analytics|Exports|themes|settings" specs/02-API-ROUTES.md


cmd: sed -n '2290,2605p' specs/02-API-ROUTES.md


cmd: sed -n '2705,2915p' specs/02-API-ROUTES.md


cmd: sed -n '2915,2995p' specs/02-API-ROUTES.md


command: sed -n '2705,2915p' specs/02-API-ROUTES.md

aggregated_output:
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found

---

### 3.10 Search Admin

#### Admin Search Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/search/reindex

Trigger a full search reindex for the store. Runs asynchronously via a queued job.

**Success response:** `202 Accepted`

```json
{
  "message": "Reindex job queued.",
  "job_id": "job_reindex_abc123",
  "status": "queued"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `409` - Reindex already in progress
- `429` - Rate limited

---

#### GET /api/admin/v1/stores/{storeId}/search/status

Get the current search index status.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "index_status": "ready",
    "last_reindex_at": "2026-02-11T08:00:00Z",
    "last_reindex_duration_seconds": 45,
    "documents_count": 342,
    "pending_updates": 0
  }
}
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {
    "period": {
      "from": "2026-02-01",
      "to": "2026-02-11"
    },
    "summary": {
      "orders_count": 156,
      "revenue_amount": 1250000,
      "aov_amount": 8012,
      "visits_count": 4520,
      "add_to_cart_count": 890,
      "checkout_started_count": 420,
      "conversion_rate": 0.0345,
      "currency": "EUR"
    },
    "daily": [
      {
        "date": "2026-02-01",
        "orders_count": 12,
        "revenue_amount": 95000,
        "aov_amount": 7917,
        "visits_count": 380,
        "add_to_cart_count": 72,
        "checkout_started_count": 35
      }
    ],
    "top_products": [
      {
        "product_id": 10,
        "title": "Classic T-Shirt",
        "units_sold": 45,
        "revenue_amount": 112500
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `format` | string | No | One of: `csv`. Default: `csv`. |
| `filters` | object | No | Same filter options as GET /orders. |

**Success response:** `202 Accepted`

```json
{
  "export_id": 1,
  "status": "queued",
  "created_at": "2026-02-11T10:00:00Z"
}
```

---

#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}

Check export status and get download URL.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "completed",
    "format": "csv",
    "row_count": 156,
    "download_url": "https://s3.example.com/...presigned...",
    "download_expires_at": "2026-02-11T11:00:00Z",
    "created_at": "2026-02-11T10:00:00Z",
    "completed_at": "2026-02-11T10:01:30Z"
  }
}
```

**Export status values:** `queued`, `processing`, `completed`, `failed`.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

## 4. Webhook Receiver Endpoints

> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).

---

## 5. OAuth Endpoints (App Ecosystem)

> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.

These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.

---

### GET /oauth/authorize

Display the authorization prompt to the merchant.

- **Middleware:** `web`, `auth`


command: sed -n '2290,2605p' specs/02-API-ROUTES.md

aggregated_output:
List shipping zones with their rates.

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "name": "Germany",
      "countries_json": ["DE"],
      "regions_json": [],
      "rates": [
        {
          "id": 1,
          "name": "Standard Shipping",
          "type": "flat",
          "config_json": {
            "price_amount": 500,
            "currency": "EUR"
          },
          "is_active": true
        },
        {
          "id": 3,
          "name": "Weight-Based Shipping",
          "type": "weight",
          "config_json": {
            "currency": "EUR",
            "tiers": [
              { "min_weight_g": 0, "max_weight_g": 1000, "price_amount": 500 },
              { "min_weight_g": 1001, "max_weight_g": 5000, "price_amount": 800 },
              { "min_weight_g": 5001, "max_weight_g": null, "price_amount": 1200 }
            ]
          },
          "is_active": true
        }
      ]
    }
  ]
}
```

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones

Create a shipping zone.

**Request body:**

```json
{
  "name": "Europe (EU)",
  "countries_json": ["DE", "FR", "NL", "BE", "AT", "IT", "ES"],
  "regions_json": []
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |
| `regions_json` | array of strings | No | Region/province codes for more granular zones. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (overlapping countries with existing zones)

---

#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}

Update a shipping zone.

Same fields as POST.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates

Add a shipping rate to a zone.

**Request body:**

```json
{
  "name": "Express Shipping",
  "type": "flat",
  "config_json": {
    "price_amount": 1200,
    "currency": "EUR"
  },
  "is_active": true
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |
| `config_json` | object | Yes | Schema depends on type. |
| `is_active` | boolean | No | Default: true. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

### 3.7 Tax Settings

#### Admin Tax Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/tax/settings

Get tax configuration for a store.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "mode": "manual",
    "provider": "none",
    "prices_include_tax": true,
    "config_json": {
      "default_tax_rate": 1900,
      "tax_rates": [
        {
          "country_code": "DE",
          "rate": 1900,
          "name": "MwSt",
          "shipping_taxed": true
        },
        {
          "country_code": "AT",
          "rate": 2000,
          "name": "USt",
          "shipping_taxed": true
        }
      ]
    },
    "updated_at": "2026-02-10T14:00:00Z"
  }
}
```

---

#### PUT /api/admin/v1/stores/{storeId}/tax/settings

Update tax configuration.

**Request body:**

```json
{
  "mode": "provider",
  "provider": "stripe_tax",
  "prices_include_tax": false,
  "config_json": {
    "stripe_tax_settings_id": "txr_abc123"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `mode` | string | Yes | One of: `manual`, `provider`. |
| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |
| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |
| `config_json` | object | Yes | Schema depends on mode/provider. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",
    "version": "1.0.0",
    "status": "draft",
    "published_at": null,
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid theme archive (missing required templates, invalid manifest)

---

#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish

Publish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "published",
    "published_at": "2026-02-11T10:05:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Theme has validation errors preventing publish

---

#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings

Update theme settings (customization values).

**Request body:**

```json
{
  "settings_json": {
    "colors": {
      "primary": "#1a73e8",
      "secondary": "#fbbc04",
      "background": "#ffffff",
      "text": "#202124"
    },
    "typography": {
      "heading_font": "Inter",
      "body_font": "Inter"
    },
    "header": {
      "show_announcement_bar": true,
      "announcement_text": "Free shipping on orders over 50 EUR!"
    }
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `settings_json` | object | Yes | Must conform to the theme's settings schema. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Settings do not conform to theme schema

---

### 3.9 Pages

#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |


command: sed -n '2915,2995p' specs/02-API-ROUTES.md

aggregated_output:
- **Middleware:** `web`, `auth`

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | The app's OAuth client ID |
| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
| `response_type` | string | Yes | Must be `code` |
| `scope` | string | Yes | Space-separated list of requested scopes |
| `state` | string | Yes | CSRF token for the client |

**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.

---

### POST /oauth/token

Exchange an authorization code for access and refresh tokens.

- **Middleware:** None (public endpoint)

**Authorization code exchange request:**

```json
{
  "grant_type": "authorization_code",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "redirect_uri": "https://myapp.example.com/callback",
  "code": "auth_code_abc123"
}
```

**Success response:** `200 OK`

```json
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ...",
  "refresh_token": "def..."
}
```

**Error responses:**
- `400` - Invalid grant (code already used, expired, etc.)
- `401` - Invalid client credentials

**Refresh token request:**

```json
{
  "grant_type": "refresh_token",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "refresh_token": "def..."
}
```

---

### App API Endpoints

Third-party apps use OAuth tokens to access store data via scoped endpoints.

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |

Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.

---

## 6. Middleware Stack

### 6.1 Route Group Middleware

#### Web (default)


command: rg -n "Admin .*Settings|Search|Analytics|Exports|themes|settings" specs/02-API-ROUTES.md

type: search

cmd: rg -n 'Admin .*Settings|Search|Analytics|Exports|themes|settings' specs/02-API-ROUTES.md

query: Admin .*Settings|Search|Analytics|Exports|themes|settings

aggregated_output:
92:| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
93:| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
94:| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |
100:| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
101:| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |
128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
132:#### Analytics
136:| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |
138:#### Search Settings
142:| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |
159:| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
1017:### 2.5 Search Endpoints
1021:Search products within the current store.
1030:| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
1108:| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |
1141:### 2.6 Analytics Endpoints
1379:      "read-settings",
1380:      "write-settings",
1415:| `query` | string | No | Search by title, vendor, or SKU. |
1756:| `query` | string | No | Search by title. |
1885:| `query` | string | No | Search by order number or customer email. |
2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
2472:    "stripe_tax_settings_id": "txr_abc123"
2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
2502:#### POST /api/admin/v1/stores/{storeId}/themes
2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
2560:Update theme settings (customization values).
2566:  "settings_json": {
2587:| `settings_json` | object | Yes | Must conform to the theme's settings schema. |
2713:### 3.10 Search Admin
2715:#### Admin Search Endpoint Summary
2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
2764:### 3.11 Analytics and Exports
2766:#### Admin Analytics Endpoint Summary
3084:| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |
3085:| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |


output:
Chunk ID: 28f7da
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1010
Output:
92:| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
93:| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
94:| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |
100:| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
101:| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |
128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
132:#### Analytics
136:| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |
138:#### Search Settings
142:| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |
159:| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
1017:### 2.5 Search Endpoints
1021:Search products within the current store.
1030:| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
1108:| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |
1141:### 2.6 Analytics Endpoints
1379:      "read-settings",
1380:      "write-settings",
1415:| `query` | string | No | Search by title, vendor, or SKU. |
1756:| `query` | string | No | Search by title. |
1885:| `query` | string | No | Search by order number or customer email. |
2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
2472:    "stripe_tax_settings_id": "txr_abc123"
2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
2502:#### POST /api/admin/v1/stores/{storeId}/themes
2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
2560:Update theme settings (customization values).
2566:  "settings_json": {
2587:| `settings_json` | object | Yes | Must conform to the theme's settings schema. |
2713:### 3.10 Search Admin
2715:#### Admin Search Endpoint Summary
2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
2764:### 3.11 Analytics and Exports
2766:#### Admin Analytics Endpoint Summary
3084:| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |
3085:| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |


output:
Chunk ID: 73edae
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1899
Output:

List shipping zones with their rates.

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "name": "Germany",
      "countries_json": ["DE"],
      "regions_json": [],
      "rates": [
        {
          "id": 1,
          "name": "Standard Shipping",
          "type": "flat",
          "config_json": {
            "price_amount": 500,
            "currency": "EUR"
          },
          "is_active": true
        },
        {
          "id": 3,
          "name": "Weight-Based Shipping",
          "type": "weight",
          "config_json": {
            "currency": "EUR",
            "tiers": [
              { "min_weight_g": 0, "max_weight_g": 1000, "price_amount": 500 },
              { "min_weight_g": 1001, "max_weight_g": 5000, "price_amount": 800 },
              { "min_weight_g": 5001, "max_weight_g": null, "price_amount": 1200 }
            ]
          },
          "is_active": true
        }
      ]
    }
  ]
}
```

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones

Create a shipping zone.

**Request body:**

```json
{
  "name": "Europe (EU)",
  "countries_json": ["DE", "FR", "NL", "BE", "AT", "IT", "ES"],
  "regions_json": []
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `countries_json` | array of strings | Yes | Valid ISO 3166-1 alpha-2 codes. At least one country. |
| `regions_json` | array of strings | No | Region/province codes for more granular zones. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (overlapping countries with existing zones)

---

#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}

Update a shipping zone.

Same fields as POST.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates

Add a shipping rate to a zone.

**Request body:**

```json
{
  "name": "Express Shipping",
  "type": "flat",
  "config_json": {
    "price_amount": 1200,
    "currency": "EUR"
  },
  "is_active": true
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `type` | string | Yes | One of: `flat`, `weight`, `price`, `carrier`. |
| `config_json` | object | Yes | Schema depends on type. |
| `is_active` | boolean | No | Default: true. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Zone not found
- `422` - Validation errors

---

### 3.7 Tax Settings

#### Admin Tax Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/tax/settings

Get tax configuration for a store.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "mode": "manual",
    "provider": "none",
    "prices_include_tax": true,
    "config_json": {
      "default_tax_rate": 1900,
      "tax_rates": [
        {
          "country_code": "DE",
          "rate": 1900,
          "name": "MwSt",
          "shipping_taxed": true
        },
        {
          "country_code": "AT",
          "rate": 2000,
          "name": "USt",
          "shipping_taxed": true
        }
      ]
    },
    "updated_at": "2026-02-10T14:00:00Z"
  }
}
```

---

#### PUT /api/admin/v1/stores/{storeId}/tax/settings

Update tax configuration.

**Request body:**

```json
{
  "mode": "provider",
  "provider": "stripe_tax",
  "prices_include_tax": false,
  "config_json": {
    "stripe_tax_settings_id": "txr_abc123"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `mode` | string | Yes | One of: `manual`, `provider`. |
| `provider` | string | Conditional | Required when mode is `provider`. One of: `stripe_tax`, `none`. |
| `prices_include_tax` | boolean | Yes | Whether product prices are tax-inclusive. |
| `config_json` | object | Yes | Schema depends on mode/provider. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",
    "version": "1.0.0",
    "status": "draft",
    "published_at": null,
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid theme archive (missing required templates, invalid manifest)

---

#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish

Publish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "published",
    "published_at": "2026-02-11T10:05:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Theme has validation errors preventing publish

---

#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings

Update theme settings (customization values).

**Request body:**

```json
{
  "settings_json": {
    "colors": {
      "primary": "#1a73e8",
      "secondary": "#fbbc04",
      "background": "#ffffff",
      "text": "#202124"
    },
    "typography": {
      "heading_font": "Inter",
      "body_font": "Inter"
    },
    "header": {
      "show_announcement_bar": true,
      "announcement_text": "Free shipping on orders over 50 EUR!"
    }
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `settings_json` | object | Yes | Must conform to the theme's settings schema. |

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Settings do not conform to theme schema

---

### 3.9 Pages

#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |


output:
Chunk ID: 690e8d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1334
Output:
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found

---

### 3.10 Search Admin

#### Admin Search Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/search/reindex

Trigger a full search reindex for the store. Runs asynchronously via a queued job.

**Success response:** `202 Accepted`

```json
{
  "message": "Reindex job queued.",
  "job_id": "job_reindex_abc123",
  "status": "queued"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `409` - Reindex already in progress
- `429` - Rate limited

---

#### GET /api/admin/v1/stores/{storeId}/search/status

Get the current search index status.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "index_status": "ready",
    "last_reindex_at": "2026-02-11T08:00:00Z",
    "last_reindex_duration_seconds": 45,
    "documents_count": 342,
    "pending_updates": 0
  }
}
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {
    "period": {
      "from": "2026-02-01",
      "to": "2026-02-11"
    },
    "summary": {
      "orders_count": 156,
      "revenue_amount": 1250000,
      "aov_amount": 8012,
      "visits_count": 4520,
      "add_to_cart_count": 890,
      "checkout_started_count": 420,
      "conversion_rate": 0.0345,
      "currency": "EUR"
    },
    "daily": [
      {
        "date": "2026-02-01",
        "orders_count": 12,
        "revenue_amount": 95000,
        "aov_amount": 7917,
        "visits_count": 380,
        "add_to_cart_count": 72,
        "checkout_started_count": 35
      }
    ],
    "top_products": [
      {
        "product_id": 10,
        "title": "Classic T-Shirt",
        "units_sold": 45,
        "revenue_amount": 112500
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `format` | string | No | One of: `csv`. Default: `csv`. |
| `filters` | object | No | Same filter options as GET /orders. |

**Success response:** `202 Accepted`

```json
{
  "export_id": 1,
  "status": "queued",
  "created_at": "2026-02-11T10:00:00Z"
}
```

---

#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}

Check export status and get download URL.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "completed",
    "format": "csv",
    "row_count": 156,
    "download_url": "https://s3.example.com/...presigned...",
    "download_expires_at": "2026-02-11T11:00:00Z",
    "created_at": "2026-02-11T10:00:00Z",
    "completed_at": "2026-02-11T10:01:30Z"
  }
}
```

**Export status values:** `queued`, `processing`, `completed`, `failed`.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

## 4. Webhook Receiver Endpoints

> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).

---

## 5. OAuth Endpoints (App Ecosystem)

> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.

These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.

---

### GET /oauth/authorize

Display the authorization prompt to the merchant.

- **Middleware:** `web`, `auth`


output:
Chunk ID: 17dea2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 536
Output:
- **Middleware:** `web`, `auth`

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | The app's OAuth client ID |
| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
| `response_type` | string | Yes | Must be `code` |
| `scope` | string | Yes | Space-separated list of requested scopes |
| `state` | string | Yes | CSRF token for the client |

**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.

---

### POST /oauth/token

Exchange an authorization code for access and refresh tokens.

- **Middleware:** None (public endpoint)

**Authorization code exchange request:**

```json
{
  "grant_type": "authorization_code",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "redirect_uri": "https://myapp.example.com/callback",
  "code": "auth_code_abc123"
}
```

**Success response:** `200 OK`

```json
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ...",
  "refresh_token": "def..."
}
```

**Error responses:**
- `400` - Invalid grant (code already used, expired, etc.)
- `401` - Invalid client credentials

**Refresh token request:**

```json
{
  "grant_type": "refresh_token",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "refresh_token": "def..."
}
```

---

### App API Endpoints

Third-party apps use OAuth tokens to access store data via scoped endpoints.

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |

Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.

---

## 6. Middleware Stack

### 6.1 Route Group Middleware

#### Web (default)


message: Search admin has two compact endpoints in the spec: reindex and status. Since the app already has `SearchService` and search settings UI, I’ll implement that surface next with a synchronous local reindex response rather than introducing a queue/job table just for status bookkeeping.