command: sed -n '1,260p' specs/03-ADMIN-UI.md; sed -n '260,620p' specs/03-ADMIN-UI.md

cmd: sed -n '1,260p' specs/03-ADMIN-UI.md

cmd: sed -n '260,620p' specs/03-ADMIN-UI.md

stdout:
# 03 - Admin Panel UI/UX Specification

This document defines every view, component, and interaction for the admin panel. The admin is entirely server-rendered using Laravel 12 + Livewire v4 + Flux UI Free components + Tailwind CSS v4. There is no SPA or client-side router.

**Flux UI Free components available:** avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip

Anything not covered by Flux UI Free uses plain Tailwind CSS v4 markup with Livewire directives.

---

## 1. Admin Layout Shell

The admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.

### 1.1 Route Prefix

All admin routes live under `/admin` and are protected by authentication middleware plus store-scoping middleware. The store is resolved from the session or a store-selector in the top bar.

### 1.2 Sidebar Navigation

**Livewire component:** `App\Livewire\Admin\Layout\Sidebar`

**Blade view:** `resources/views/livewire/admin/layout/sidebar.blade.php`

#### Component Requirements

| Property | Type | Purpose |
|----------|------|---------|
| `collapsed` | `bool` | Whether the sidebar is collapsed (mobile state) |
| `currentRoute` | `string` | The current route name for active highlighting |

| Action | Behavior |
|--------|----------|
| `toggle()` | Toggles the `collapsed` state |

#### Wireframe

```
+-------------------------------+
|  [BRAND LOGO]  Platform Name  |
|-------------------------------|
|  [separator]                  |
|                               |
|  [chart-bar]    Dashboard     |
|                               |
|  PRODUCTS                     |
|  [cube]         Products      |
|  [rect-stack]   Collections   |
|  [archive-box]  Inventory     |
|                               |
|  [separator]                  |
|  ORDERS                       |
|  [shopping-bag] Orders        |
|                               |
|  [separator]                  |
|  CUSTOMERS                    |
|  [users]        Customers     |
|                               |
|  [separator]                  |
|  DISCOUNTS                    |
|  [tag]          Discounts     |
|                               |
|  [separator]                  |
|  CONTENT                      |
|  [doc-text]     Pages         |
|  [bars-3]       Navigation    |
|  [paint-brush]  Themes        |
|                               |
|  [separator]                  |
|  [chart-pie]    Analytics     |
|  [cog-6-tooth]  Settings      |
|  [squares-2x2]  Apps          |
|  [code-bracket] Developers    |
+-------------------------------+
```

#### Navigation Structure

```
Dashboard                        -> admin.dashboard
Products (group label)
  Products                       -> admin.products.index
  Collections                    -> admin.collections.index
  Inventory                      -> admin.inventory.index
Orders
  Orders                         -> admin.orders.index
Customers
  Customers                      -> admin.customers.index
Discounts
  Discounts                      -> admin.discounts.index
Content (group label)
  Pages                          -> admin.pages.index
  Navigation                     -> admin.navigation.index
  Themes                         -> admin.themes.index
Analytics                        -> admin.analytics.index
Settings                         -> admin.settings.index
Apps                             -> admin.apps.index
Developers                       -> admin.developers.index
```

Each nav item displays an icon (via Flux icon component) and a text label. The active item receives a highlighted background and bold text. All navigation links use Livewire SPA-style page transitions. Groups are visually separated with Flux separator components.

#### Flux Component Mapping

| UI Element | Flux Component |
|-----------|----------------|
| Platform logo and name | `brand` |
| Navigation icons | `icon` |
| Group dividers | `separator` |

#### Responsive Behavior

- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.
- **Mobile (below lg):** Sidebar is hidden off-screen. A hamburger button (icon: bars-3) appears in the top bar. When user clicks it, the sidebar slides in as an overlay with a semi-transparent backdrop. Clicking the backdrop or a nav item closes it.

### 1.3 Top Bar

**Livewire component:** `App\Livewire\Admin\Layout\TopBar`

**Blade view:** `resources/views/livewire/admin/layout/top-bar.blade.php`

#### Component Requirements

| Property | Type | Purpose |
|----------|------|---------|
| `currentStoreName` | `string` | Name of the active store |
| `stores` | `Collection` | List of stores the user has access to |
| `unreadNotificationCount` | `int` | Count of unread notifications |

| Action | Behavior |
|--------|----------|
| `switchStore(string $storeId)` | Switches active store in session, redirects to dashboard |

#### Wireframe

```
+--------------------------------------------------------------------+
| [hamburger*]  [Store Name v]                  [bell(3)]  [avatar v] |
+--------------------------------------------------------------------+
  * hamburger visible on mobile only

  Store Name dropdown:
    - Store A
    - Store B
    - Store C

  Avatar dropdown:
    - Settings (links to /admin/settings)
    - [separator]
    - Log out (POST /logout with CSRF)
```

#### Interaction Flows

- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.
- Notification bell shows an unread count badge (positioned absolutely over the icon) when `unreadNotificationCount > 0`.
- Profile dropdown is triggered by a Flux profile component showing the user's avatar and name.

#### Flux Component Mapping

| UI Element | Flux Component |
|-----------|----------------|
| Store selector | `dropdown` |
| Hamburger (mobile) | `button` (icon: bars-3) |
| Notification bell | `button` (icon: bell, variant: ghost) |
| Notification count | `badge` |
| User profile trigger | `profile` |
| Profile menu | `dropdown` |
| Menu divider | `separator` |

### 1.4 Main Content Area

The main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.

**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always "Home" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > "Blue T-Shirt".

**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.

### 1.5 Toast Notifications

A global toast notification system lives in the layout shell. It uses Alpine.js to listen for Livewire dispatched events.

| Property | Value |
|----------|-------|
| Event name | `toast` |
| Payload | `{ type: 'success' / 'error' / 'info', message: string }` |
| Position | Top-right corner |
| Auto-dismiss | 5 seconds |
| Stacking | Supports multiple toasts |
| Styling | Rounded card, shadow, colored left border (green=success, red=error, blue=info) |

Components dispatch toasts after successful actions (e.g., "Product saved successfully.") or on errors ("Something went wrong. Please try again.").

### 1.6 Dark Mode

The admin supports dark mode via Tailwind's `dark:` variant. The mode follows the user's system preference by default. A toggle can be added in the profile dropdown later. All components and custom markup must include dark mode variants.

Dark mode preference is persisted in `localStorage` under the key `theme` with values `light` or `dark`. On page load, the stored preference is applied before first paint to avoid flash of incorrect theme. If no preference is stored, the system preference (`prefers-color-scheme`) is used as default.

---

## 2. Dashboard

**Route:** `GET /admin`

**Livewire component:** `App\Livewire\Admin\Dashboard`

**Blade view:** `resources/views/livewire/admin/dashboard.blade.php`

### Component Requirements

| Property | Type | Purpose |
|----------|------|---------|
| `dateRange` | `string` | Active date range preset: `today`, `last_7_days`, `last_30_days`, `custom` |
| `customStartDate` | `?string` | Custom range start (Y-m-d) |
| `customEndDate` | `?string` | Custom range end (Y-m-d) |
| `totalSales` | `int` | Total sales amount in minor units |
| `ordersCount` | `int` | Number of orders in range |
| `averageOrderValue` | `int` | Average order value in minor units |
| `visitorsCount` | `int` | Unique visitors in range |
| `salesChange` | `float` | Percentage change vs previous period |
| `ordersChange` | `float` | Percentage change vs previous period |
| `aovChange` | `float` | Percentage change vs previous period |
| `visitorsChange` | `float` | Percentage change vs previous period |
| `ordersChartData` | `array` | Array of `{date, count}` for last 30 days |
| `topProducts` | `array` | Top 5 products: `{title, units_sold, revenue}` |
| `funnelData` | `array` | Funnel steps: `{visits, add_to_cart, checkout_started, checkout_completed}` |

| Action | Behavior |
|--------|----------|
| `updatedDateRange()` | Re-fetches all KPI data for the selected range |
| `loadKpis()` | Queries aggregated order/analytics data |
| `loadChart()` | Queries daily order counts for chart |
| `loadTopProducts()` | Queries top 5 products by revenue |
| `loadFunnel()` | Queries conversion funnel data |

| Computed Property | Purpose |
|-------------------|---------|
| `formattedTotalSales` | Formatted currency string |
| `formattedAov` | Formatted currency string |

### Wireframe

```
+------------------------------------------------------------------+
| Dashboard                           [Today v] (date range picker) |
+------------------------------------------------------------------+
|                                                                    |
|  +-------------+  +-------------+  +-------------+  +----------+  |
|  | Total Sales |  | Orders      |  | Avg Order   |  | Visitors |  |
|  | $12,345     |  | 156         |  | $79.13      |  | 2,340    |  |
|  | [+12.3%] ^  |  | [+8.1%] ^  |  | [-2.1%] v  |  | [+5%] ^  |  |
|  +-------------+  +-------------+  +-------------+  +----------+  |
|                                                                    |
|  +--------------------------------------------------------------+  |
|  | Orders over time                                              |  |
|  |  [line/bar chart - daily order counts for selected range]     |  |
|  +--------------------------------------------------------------+  |
|                                                                    |
|  +------------------------------+  +----------------------------+  |
|  +------------------------------+  +----------------------------+  |
|  | Top products                 |  | Conversion funnel          |  |
|  | Product Title | Sold | Rev   |  | Visits       [========] N |  |
|  | Blue Shirt    | 45   | $900  |  | Add to Cart  [======]   N |  |
|  | Red Hat       | 32   | $640  |  | Checkout     [====]     N |  |
|  | Sneakers      | 28   | $2800 |  | Completed    [==]       N |  |
|  | ...           |      |       |  |                            |  |
|  +------------------------------+  +----------------------------+  |
+--------------------------------------------------------------------+
```

### Layout Description

**Page heading:** "Dashboard" displayed as a Flux heading (size xl).

**Date range filter:** Positioned top-right. A Flux dropdown triggered by a button showing the current range label and a calendar icon. Options: Today, Last 7 days, Last 30 days, Custom range (reveals two date input fields when selected).

### KPI Tiles Row

A responsive 4-column grid (1 column on mobile, 2 on sm, 4 on lg).

Each tile is a card containing:
- A muted-color text label (e.g., "Total Sales")
- A large heading with the formatted number value
- A row with a Flux badge showing percentage change (green if positive, red if negative) and a directional arrow icon

Each tile shows a loading state (reduced opacity) while data refreshes.

### Orders Chart

A card below the KPI tiles containing:
- A Flux heading (size lg): "Orders over time"
- A Chart.js canvas (line or bar chart) showing daily order counts
- Chart.js initialized via Alpine.js; data passed from Livewire
- A spinner overlay shown during chart data loading

### Top Products Table

A card containing:
- A Flux heading (size lg): "Top products"
- A plain table with columns: Product Title, Units Sold, Revenue
- Maximum 5 rows
- Empty state text: "No sales data for this period."

### Conversion Funnel

A card containing:
- A Flux heading (size lg): "Conversion funnel"
- Horizontal bar visualization using Tailwind utility widths
- Each step: label on the left, proportional-width bar on the right, count displayed
- Steps: Visits, Add to Cart, Checkout Started, Checkout Completed
- Bars use decreasing widths and progressively darker colors

### Flux Component Mapping

| UI Element | Flux Component |
|-----------|----------------|
| Page title | `heading` (size xl) |
| Section titles | `heading` (size lg) |
| Date range selector | `dropdown`, `button`, `icon` |
| Date inputs (custom range) | `input` (type date) |
| KPI labels | `text` |
| KPI values | `heading` (size xl) |
| Trend badges | `badge` |
| Trend arrows | `icon` |

### View States

| State | Behavior |
|-------|----------|
| Loading | KPI tiles show reduced opacity; chart shows spinner overlay |
| Empty | "No sales data for this period." in top products; funnel bars at zero width |
| Populated | All tiles, chart, table, and funnel render with data |
| Error | Toast notification with error message |

---

## 3. Products List

**Route:** `GET /admin/products`

**Livewire component:** `App\Livewire\Admin\Products\Index`

**Blade view:** `resources/views/livewire/admin/products/index.blade.php`

### Component Requirements

| Property | Type | Purpose |
|----------|------|---------|
| `search` | `string` | Search query, live-debounced at 300ms |
| `statusFilter` | `string` | Status filter: `all`, `draft`, `active`, `archived` |
| `typeFilter` | `string` | Product type filter |
| `selectedIds` | `array<int>` | Array of selected product IDs for bulk actions |
| `selectAll` | `bool` | Whether all visible rows are selected |
| `sortField` | `string` | Column to sort by (default: `updated_at`) |
| `sortDirection` | `string` | `asc` or `desc` |

| Action | Behavior |
|--------|----------|
| `updatedSearch()` | Resets pagination |
| `updatedStatusFilter()` | Resets pagination |
| `updatedTypeFilter()` | Resets pagination |
| `bulkArchive()` | Archives all selected products |
| `bulkDelete()` | Soft-deletes selected products (with confirmation) |
| `bulkSetActive()` | Sets selected products to active status |
| `sortBy(string $field)` | Toggles sort direction or changes sort field |
| `toggleSelectAll()` | Selects or deselects all visible product IDs |
| `confirmBulkDelete()` | Opens delete confirmation modal |

| Computed Property | Purpose |
|-------------------|---------|
| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
| `productTypes` | Distinct product types for the type filter dropdown |

### Wireframe

```
+------------------------------------------------------------------+
| Products                                    [+ Add product]       |
+------------------------------------------------------------------+
| [search icon] Search products...   [Status: All v] [Type: All v] |
+------------------------------------------------------------------+
| (shown when items selected)                                       |
| "3 products selected"  [Set Active]  [Archive]  [Delete]         |
+------------------------------------------------------------------+
| [x] | IMG | Title ^     | Status  | Inventory | Type | Vendor | Updated ^  |
|-----|-----|-------------|---------|-----------|------|--------|-----------|
| [ ] | img | Blue Shirt  | Active  | 45        | Tops | Nike   | 2h ago    |
| [ ] | img | Red Hat     | Draft   | 12        | Hats | Adidas | 1d ago    |
| [ ] | img | Sneakers    | Active  | 0         | Shoes| Nike   | 3d ago    |
| ...                                                              |
+------------------------------------------------------------------+
| < 1 2 3 ... 10 >  (pagination)                                  |
+------------------------------------------------------------------+
```

### Table Column Definitions

| Column | Sortable | Content |
|--------|----------|---------|
| Select | No | Checkbox for bulk selection |
| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
| Title | Yes | Product title as a link to the edit page (uses Livewire navigate) |
| Status | No | Flux badge: draft=zinc/gray, active=green, archived=red |
| Inventory | Yes | Sum of `inventory_items.quantity_on_hand` across all variants |
| Type | No | Product type text or "-" |
| Vendor | No | Vendor name text or "-" |
| Updated | Yes (default) | Relative timestamp (e.g., "2h ago") |

### Bulk Action Bar

Conditionally shown when `count($selectedIds) > 0`. Contains:
- Text showing "X products selected"
- "Set Active" button (ghost variant)
- "Archive" button (ghost variant)
- "Delete" button (danger variant) - opens confirmation modal

### Empty State

When no products exist (not filtered, genuinely empty):
- Centered container with a large muted placeholder icon (cube)
- Heading: "Add your first product"
- Text: "Start building your catalog by adding products."
- Primary button: "Add product" linking to the create page

### Delete Confirmation Modal

| Property | Value |
|----------|-------|
| Modal name | `confirm-bulk-delete` |
| Max width | md |
| Heading | "Delete products?" |
| Body text | "This will archive X product(s). Products with orders cannot be permanently deleted." |
| Cancel button | Ghost variant, closes modal |
| Confirm button | Danger variant, calls `bulkDelete` |

### Flux Component Mapping

| UI Element | Flux Component |
|-----------|----------------|
| Page title | `heading` |
| Add product button | `button` (variant: primary) |
| Search field | `input` (icon: magnifying-glass) |
| Status filter | `select` |
| Type filter | `select` |
| Bulk action buttons | `button` (ghost/danger variants) |
| Selected count text | `text` |
| Row checkbox | `checkbox` |
| Status badges | `badge` |
| Cell text | `text` |
| Delete confirmation | `modal` |
| Pagination | Laravel default Tailwind pagination |

### View States

| State | Behavior |
|-------|----------|
| Loading | Table body shows reduced opacity |
| Empty (no products) | Empty state with illustration and CTA |
| Empty (filtered) | Table with "No products match your filters" message |
| Populated | Table with data rows and pagination |

---

## 4. Product Create/Edit

**Route:** `GET /admin/products/create` and `GET /admin/products/{product}/edit`

**Livewire component:** `App\Livewire\Admin\Products\Form`

**Blade view:** `resources/views/livewire/admin/products/form.blade.php`

### Component Requirements

| Property | Type | Purpose |
|----------|------|---------|
| `product` | `?Product` | The product being edited, null for create |
| `title` | `string` | Product title |
| `descriptionHtml` | `string` | Product description (HTML) |
| `status` | `string` | `draft`, `active`, `archived` |
| `vendor` | `string` | Vendor name |
| `productType` | `string` | Product type string |
| `tags` | `string` | Comma-separated tags |
| `handle` | `string` | URL handle/slug |
| `publishedAt` | `?string` | Publish date (Y-m-d H:i) |
| `collectionIds` | `array<int>` | Selected collection IDs |
| `options` | `array` | Options array: `[{name: string, values: string[]}]` |
| `variants` | `array` | Variants array: `[{sku, price, compareAtPrice, quantity, requiresShipping, optionValues}]` |
| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
| `newMedia` | `array` | Temporary uploaded files |

| Action | Behavior |
|--------|----------|
| `mount(?Product $product)` | Loads product data into properties or sets defaults for create |
| `save()` | Validates and saves/creates the product, variants, options, and media |
| `addOption()` | Adds a new empty option `{name: '', values: []}` |
| `removeOption(int $index)` | Removes an option and regenerates variants |
| `addOptionValue(int $optionIndex)` | Adds a value to an option |
| `removeOptionValue(int $optionIndex, int $valueIndex)` | Removes a value |
| `generateVariants()` | Generates variant combinations from current options |
| `uploadMedia()` | Handles file upload via Livewire temporary upload |
| `removeMedia(int $mediaId)` | Removes a media item |
| `reorderMedia(array $order)` | Updates media positions |
| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
| `deleteProduct()` | Archives the product (with confirmation modal) |

| Computed Property | Purpose |
|-------------------|---------|
| `availableCollections` | All collections for the current store |
| `isEditing` | `bool` - whether editing or creating |

### Validation Rules

| Field | Rules |
|-------|-------|
| `title` | Required, string, max 255 |
| `descriptionHtml` | Nullable, string, max 65535 |
| `status` | Required, one of: draft, active, archived |
| `vendor` | Nullable, string, max 255 |
| `productType` | Nullable, string, max 255 |
| `tags` | Nullable, string |
| `handle` | Required, string, max 255, unique (excluding current product) |
| `variants.*.sku` | Nullable, string, max 255 |
| `variants.*.price` | Required, integer, min 0 |
| `variants.*.compareAtPrice` | Nullable, integer, min 0 |
| `variants.*.quantity` | Required, integer, min 0 |

### Two-Column Layout Wireframe

```
+------------------------------------------+-------------------+
|          LEFT COLUMN (2/3)               | RIGHT COLUMN (1/3)|
+------------------------------------------+-------------------+
|                                          |                   |
|  TITLE                                   |  STATUS CARD      |
|  +------------------------------------+  |  Status: [Draft v]|
|  | [Short Sleeve T-Shirt           ]  |  |                   |
|  +------------------------------------+  |  PUBLISHING CARD  |
|                                          |  Published at:    |
|  DESCRIPTION                             |  [datetime input]  |
|  +------------------------------------+  |                   |
|  | Describe your product...           |  |  ORGANIZATION     |
|  |                                    |  |  Vendor:          |
|  |                                    |  |  [Nike          ] |
|  |                                    |  |  Product type:    |
|  +------------------------------------+  |  [T-Shirts      ] |
|                                          |  Tags:            |
|  MEDIA                                   |  [summer, cotton ] |
|  +------------------------------------+  |  (comma-separated)|
|  | +--+ +--+ +--+ +--+               |  |                   |
|  | |  | |  | |  | |  |  (thumbnails) |  |  COLLECTIONS      |
|  | +--+ +--+ +--+ +--+               |  |  [x] Summer Sale  |
|  |                                    |  |  [ ] Winter       |
|  | [--- Drag & drop or click ---]    |  |  [x] New Arrivals |
|  +------------------------------------+  |                   |
|                                          |                   |
|  VARIANTS                                |                   |
|  Option name: [Size    ]                 |                   |
|  Values:      [S, M, L, XL         ]    |                   |
|  [trash]                                 |                   |
|                                          |                   |
|  [+ Add another option]                  |                   |
|                                          |                   |
|  Variant | SKU    | Price | Compare | Qty | Ship |          |
|  S / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |
|  M / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |
|  L / Red  | [   ] | [   ] | [     ] | [ ] | [x]  |          |
|                                          |                   |
|  SEO (collapsible)                       |                   |
|  > Search engine listing                 |                   |
|    URL handle: [short-sleeve-t-shirt]    |                   |
+------------------------------------------+-------------------+

+==================================================================+
| STICKY SAVE BAR (fixed bottom)                                    |
|                                      [Discard]  [Save]           |
+==================================================================+
```

### Left Column Sections

#### Title Input
- Flux field with label "Title"
- Flux input, placeholder "Short Sleeve T-Shirt"
- Flux error display for validation

#### Description
- Flux field with label "Description"
- Flux textarea, 8 rows, placeholder "Describe your product..."
- Future enhancement: WYSIWYG toolbar via Alpine.js; baseline is plain textarea

#### Media Section
A card with heading "Media" (size md).

**Upload area:** A dashed-border drop zone with upload icon and text "Drag and drop images or click to upload". A hidden file input (multiple, accept images) triggered by clicking the zone.

**Upload progress:** A progress bar shown during file upload.

**Media grid:** A 4-column grid (responsive: 2 on small, 3 on sm, 4 on lg) of thumbnails:
- Each item: square aspect ratio, object-cover, rounded
- On hover overlay shows: delete button (X icon, top-right), alt text edit button (pencil icon, bottom-left)
- Drag-to-reorder supported via Livewire sortable or Alpine.js Sortable

#### Variants Section
A card with heading "Variants" (size md).

**Options builder:** For each option, a row containing:
- "Option name" input field (placeholder: "Size")
- "Values" input field (placeholder: "S, M, L, XL" - comma-separated)
- Trash button to remove the option

Below options: "Add another option" ghost button with plus icon.

When options change, `generateVariants()` is called automatically via lifecycle hook.

**Variants table:** Shown when variants exist. Scrollable table with inline-editable fields:

| Column | Input Type | Description |
|--------|-----------|-------------|
| Variant | Read-only text | Combination name, e.g. "S / Red" |
| SKU | Text input (small) | Stock keeping unit |


type: CommandExecution

command: sed -n '1,240p' specs/02-API-ROUTES.md; sed -n '240,620p' specs/02-API-ROUTES.md; sed -n '620,1040p' specs/02-API-ROUTES.md

type: read

cmd: sed -n '1,240p' specs/02-API-ROUTES.md

status: completed

stdout:
# API and Routing Specification

This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.

All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).

---

## 1. Web Routes (Blade/Livewire Pages)

### 1.1 Admin Auth Routes

No middleware beyond the default `web` group. These routes handle admin login and logout.

| Method | Path | Description | Component / Action |
|--------|------|-------------|--------------------|
| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
| POST | `/admin/login` | Process admin login | Handled by Livewire action |
| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |

Rate limit: `login` tier (5 per minute per IP).

---

### 1.2 Admin Routes

**Prefix:** `/admin`
**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`

All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).

> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.

#### Dashboard

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |

#### Products

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |

#### Inventory

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |

#### Collections

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |

#### Orders

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |

#### Customers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |

#### Discounts

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |

#### Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |

#### Themes and Online Store

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |

#### Pages

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |

#### Navigation

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |

#### Apps

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |

#### Developers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |

> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.

#### Analytics

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |

#### Search Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |

---

### 1.3 Storefront Routes

**Middleware:** `web`, `store.resolve`

These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.

| Method | Path | Description | Component / View |
|--------|------|-------------|------------------|
| GET | `/` | Home page | `Storefront\Home` |
| GET | `/collections` | All collections listing | `Storefront\Collections\Index` |
| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\Collections\Show` |
| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\Products\Show` |
| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\Cart\Show` |
| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\Pages\Show` |

#### Customer Account Routes

**Additional middleware:** `auth:customer`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |

#### Customer Auth Routes (no auth required)

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
| POST | `/reset-password` | Reset password | Handled by Livewire action |

---

### 1.4 Checkout Routes

**Middleware:** `web`, `store.resolve`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |

---

## 2. Storefront REST API

**Prefix:** `/api/storefront/v1`
**Middleware:** `store.resolve`, `throttle:api.storefront`
**Content-Type:** `application/json`

These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).

---

### 2.1 Cart Endpoints

#### POST /api/storefront/v1/carts

Create a new cart.

- **Authentication:** None (guest) or session-based (logged-in customer)
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "currency": "EUR"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |

**Success response:** `201 Created`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 1,
  "status": "active",
  "lines": [],
  "totals": {
    "subtotal": 0,
    "subtotal": 0,
    "discount": 0,
    "total": 0,
    "currency": "EUR"
  },
  "created_at": "2026-02-11T10:00:00Z",
  "updated_at": "2026-02-11T10:00:00Z"
}
```

**Error responses:**
- `422` - Validation error (invalid currency)

---

#### GET /api/storefront/v1/carts/{cartId}

Retrieve a cart with all lines and computed totals.

- **Authentication:** None (cart ID acts as token)
- **Rate limit:** `api.storefront` (120/min per IP)

**URL parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `cartId` | integer | Yes | The cart identifier |

**Success response:** `200 OK`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 3,
  "status": "active",
  "lines": [
    {
      "id": 201,
      "variant_id": 101,
      "product_title": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "line_subtotal_amount": 5000,
      "line_discount_amount": 0,
      "line_total_amount": 5000,
      "image_url": "https://cdn.example.com/products/tshirt-blue.jpg",
      "requires_shipping": true,
      "available_quantity": 50
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "total": 5000,
    "currency": "EUR",
    "line_count": 1,
    "item_count": 2
  },
  "created_at": "2026-02-11T10:00:00Z",
  "updated_at": "2026-02-11T10:05:00Z"
}
```

**Error responses:**
- `404` - Cart not found

---

#### POST /api/storefront/v1/carts/{cartId}/lines

Add a line item to the cart.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "variant_id": 101,
  "quantity": 2
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |
| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |

**Success response:** `201 Created`

Returns the full cart object (same schema as GET /carts/{cartId}).

**Error responses:**
- `404` - Cart not found
- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)
- `422` - Validation error

```json
{
  "message": "The given data was invalid.",
  "errors": {
    "variant_id": ["The selected variant is out of stock."]
  }
}
```

---

#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}

Update the quantity of a cart line.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "quantity": 3,
  "cart_version": 3
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |
| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |

**Success response:** `200 OK`

Returns the full cart object with updated totals.

**Error responses:**
- `404` - Cart or line not found
- `409` - Version conflict
- `422` - Validation error (e.g., exceeds inventory)

---

#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}

Remove a line item from the cart.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "cart_version": 3
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `cart_version` | integer | Yes | Must match current cart version. |

**Success response:** `200 OK`

Returns the full cart object.

**Error responses:**
- `404` - Cart or line not found
- `409` - Version conflict

---

### 2.2 Checkout Endpoints

#### Checkout Flow Sequence Diagram

```mermaid
sequenceDiagram
    participant C as Customer Browser
    participant API as Storefront API
    participant MockPSP as Mock Payment Provider

    C->>API: POST /checkouts (cart_id, email)
    API-->>C: 201 Checkout (status: started)

    C->>API: PUT /checkouts/{id}/address
    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)

    C->>API: PUT /checkouts/{id}/shipping-method
    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)

    opt Apply discount
        C->>API: POST /checkouts/{id}/apply-discount
        API-->>C: 200 Checkout (totals recalculated)
    end

    C->>API: POST /checkouts/{id}/pay (payment_method + details)
    API->>API: Reserve inventory
    API->>MockPSP: charge(checkout, paymentMethodData)

    alt Credit Card / PayPal (instant capture)
        MockPSP-->>API: PaymentResult (success)
        API->>API: Create order (paid), commit inventory
        API-->>C: 200 (order confirmation data)
    else Credit Card (decline)
        MockPSP-->>API: PaymentResult (failed)
        API->>API: Release reserved inventory
        API-->>C: 422 (error: card_declined / insufficient_funds)
    else Bank Transfer (deferred)
        MockPSP-->>API: PaymentResult (pending)
        API->>API: Create order (pending), inventory stays reserved
        API-->>C: 200 (order confirmation + bank transfer instructions)
    end
```

#### POST /api/storefront/v1/checkouts

Create a checkout from a cart.

- **Authentication:** None or session-based
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "cart_id": 42,
  "email": "customer@example.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |
| `email` | string | Yes | Valid email address. |

**Success response:** `201 Created`

```json
{
  "id": 7,
  "store_id": 1,
  "cart_id": 42,
  "customer_id": null,
  "status": "started",
  "email": "customer@example.com",
  "shipping_address_json": null,
  "billing_address_json": null,
  "shipping_method_id": null,
  "discount_code": null,
  "lines": [
    {
      "variant_id": 101,
      "product_title": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "line_total_amount": 5000
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "shipping": 0,
    "tax": 0,
    "total": 5000,
    "currency": "EUR"
  },
  "available_shipping_methods": [],
  "expires_at": "2026-02-12T10:00:00Z",
  "created_at": "2026-02-11T10:00:00Z"
}
```

**Error responses:**
- `404` - Cart not found
- `422` - Validation errors (empty cart, invalid email)

---

#### GET /api/storefront/v1/checkouts/{checkoutId}

Retrieve the current checkout state and computed totals.

- **Authentication:** None (checkout ID acts as token)
- **Rate limit:** `checkout` (10/min per session)

**Success response:** `200 OK`

Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).

**Error responses:**
- `404` - Checkout not found
- `410` - Checkout expired

---

#### PUT /api/storefront/v1/checkouts/{checkoutId}/address

Set the shipping and/or billing address on a checkout.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "billing_address": null,
  "use_shipping_as_billing": true
}
```

**Shipping address fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |
| `shipping_address.first_name` | string | Yes | Max: 255 |
| `shipping_address.last_name` | string | Yes | Max: 255 |
| `shipping_address.address1` | string | Yes | Max: 500 |
| `shipping_address.address2` | string | No | Max: 500 |
| `shipping_address.city` | string | Yes | Max: 255 |
| `shipping_address.province` | string | No | Max: 255 |
| `shipping_address.province_code` | string | No | Max: 10 |
| `shipping_address.country` | string | Yes | Max: 255 |
| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |
| `shipping_address.postal_code` | string | Yes | Max: 20 |
| `shipping_address.phone` | string | No | Max: 50 |
| `billing_address` | object | No | Same fields as shipping_address |
| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |

**Success response:** `200 OK`

Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.

```json
{
  "id": 7,
  "status": "addressed",
  "shipping_address_json": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "billing_address_json": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "available_shipping_methods": [
    {
      "id": 1,
      "name": "Standard Shipping",
      "type": "flat",
      "price_amount": 500,
      "currency": "EUR",
      "estimated_days_min": 3,
      "estimated_days_max": 5
    },
    {
      "id": 2,
      "name": "Express Shipping",
      "type": "flat",
      "price_amount": 1200,
      "currency": "EUR",
      "estimated_days_min": 1,
      "estimated_days_max": 2
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "shipping": 0,
    "tax": 0,
    "total": 5000,
    "currency": "EUR"
  }
}
```

**Error responses:**
- `404` - Checkout not found
- `410` - Checkout expired
- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)

---

#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method

Select a shipping method for the checkout.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "shipping_method_id": 1
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |

**Success response:** `200 OK`

Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).

```json
{
  "id": 7,
  "status": "shipping_selected",
  "shipping_method_id": 1,
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "shipping": 500,
    "tax": 1045,
    "total": 6545,
    "currency": "EUR"
  },
  "tax_provider_snapshot_json": {
    "provider": "manual",
    "calculated_at": "2026-02-11T10:05:00Z",
    "lines": [
      {
        "variant_id": 101,
        "tax_amount": 950,
        "rate": 1900,
        "jurisdiction": "DE"
      }
    ],
    "shipping_tax_amount": 95,
    "shipping_tax_rate": 1900
  }
}
```

**Error responses:**
- `404` - Checkout not found
- `410` - Checkout expired
- `422` - Invalid shipping method (not available for this address/checkout)

---

#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method

Select a payment method for the checkout. This step records the chosen method without processing payment.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "payment_method": "credit_card"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |

**Success response:** `200 OK`

Returns the full checkout object. Status transitions to `payment_selected`.

```json
{
  "id": 7,
  "status": "payment_selected",
  "payment_method": "credit_card",
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "shipping": 500,
    "tax": 1045,
    "total": 6545,
    "currency": "EUR"
  }
}
```

**Error responses:**
- `404` - Checkout not found
- `410` - Checkout expired
- `422` - Invalid payment method or checkout not in `shipping_selected` state

---

#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount

Apply a discount code to the checkout.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "code": "WELCOME10"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `code` | string | Yes | Max: 50. Must be a valid, active discount code for this store. Case-insensitivity determined by store setting. |

**Success response:** `200 OK`

```json
{
  "id": 7,
  "status": "shipping_selected",
  "discount_code": "WELCOME10",
  "totals": {
    "subtotal": 5000,
    "discount": 500,
    "shipping": 500,
    "tax": 950,
    "total": 5950,
    "currency": "EUR"
  },
  "applied_discounts": [
    {
      "code": "WELCOME10",
      "type": "percent",
      "value_amount": 10,
      "applied_amount": 500,
      "description": "10% off your first order"
    }
  ]
}
```

**Error responses:**
- `400` - Discount code expired (error code: `discount_expired`)
- `400` - Discount usage limit reached (error code: `discount_usage_exceeded`)
- `404` - Checkout not found
- `410` - Checkout expired
- `422` - Invalid code (not found, does not apply to these items)

```json
{
  "message": "This discount code has expired.",
  "error_code": "discount_expired"
}
```

---

#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount

Remove the applied discount code from the checkout.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Success response:** `200 OK`

Returns the full checkout object with discount removed and totals recalculated.

**Error responses:**
- `404` - Checkout not found or no discount applied
- `410` - Checkout expired

---

### 2.3 Payment Endpoint

#### POST /api/storefront/v1/checkouts/{checkoutId}/pay

Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body (Credit Card):**

```json
{
  "payment_method": "credit_card",
  "card_number": "4242424242424242",
  "card_expiry": "12/28",
  "card_cvc": "123",
  "card_holder": "Jane Doe"
}
```

**Request body (PayPal):**

```json
{
  "payment_method": "paypal"
}
```

**Request body (Bank Transfer):**

```json
{
  "payment_method": "bank_transfer"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |
| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |
| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |
| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |

**Success response (Credit Card / PayPal - instant capture):** `200 OK`

```json
{
  "checkout_id": 7,
  "status": "completed",
  "order": {
    "id": 42,
    "order_number": "#1016",
    "status": "paid",
    "financial_status": "paid",
    "payment_method": "credit_card",
    "total_amount": 6545,
    "currency": "EUR"
  }
}
```

**Success response (Bank Transfer - deferred payment):** `200 OK`

```json
{
  "checkout_id": 7,
  "status": "completed",
  "order": {
    "id": 42,
    "order_number": "#1016",
    "status": "pending",
    "financial_status": "pending",
    "payment_method": "bank_transfer",
    "total_amount": 6545,
    "currency": "EUR"
  },
  "bank_transfer_instructions": {
    "bank_name": "Mock Bank AG",
    "iban": "DE89 3704 0044 0532 0130 00",
    "bic": "COBADEFFXXX",
    "reference": "#1016",
    "amount_formatted": "65.45 EUR"
  }
}
```

**Error responses:**
- `404` - Checkout not found
- `409` - Checkout not in a valid state for payment (must be `payment_selected`)
- `422` - Validation errors (missing card fields, invalid payment method)
- `422` - Payment declined (credit card magic numbers)

```json
{
  "message": "Your card was declined.",
  "error_code": "card_declined"
}
```

**Magic card number error codes:**

| Card Number | Error Code | Message |
|---|---|---|
| `4000000000000002` | `card_declined` | "Your card was declined." |
| `4000000000009995` | `insufficient_funds` | "Your card has insufficient funds." |

---

### 2.4 Order Endpoints

#### GET /api/storefront/v1/orders/{orderNumber}

Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).

- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
- **Rate limit:** `api.storefront` (120/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `token` | string | Yes | HMAC-signed access token for this order |

**Success response:** `200 OK`

```json
{
  "order_number": "#1001",
  "status": "paid",
  "financial_status": "paid",
  "fulfillment_status": "unfulfilled",
  "email": "customer@example.com",
  "currency": "EUR",
  "placed_at": "2026-02-11T10:10:00Z",
  "lines": [
    {
      "title_snapshot": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku_snapshot": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "total_amount": 5000
    }
  ],
  "totals": {
    "subtotal_amount": 5000,
    "discount_amount": 500,
    "shipping_amount": 500,
    "tax_amount": 950,
    "total_amount": 5950
  },
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "city": "Berlin",
    "country": "DE",
    "postal_code": "10115"
  },
  "fulfillments": []
}
```

**Error responses:**
- `401` - Invalid or missing token
- `404` - Order not found

---

### 2.5 Search Endpoints

#### GET /api/storefront/v1/search

Search products within the current store.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
| `filters` | json | No | URL-encoded JSON object with filter facets. |
| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |

**Filters JSON schema:**

```json
{
  "collection_id": 5,


command: sed -n '1,280p' specs/04-STOREFRONT-UI.md; sed -n '280,620p' specs/04-STOREFRONT-UI.md

stdout:
# 04 - Storefront UI/UX Specification

> E-commerce platform - Customer-facing storefront UI/UX requirements

---

## Conventions

| Convention | Detail |
|---|---|
| CSS Framework | Tailwind CSS v4, mobile-first design, dark mode support |
| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |
| Templating | Blade templates with reusable storefront-prefixed components |
| Accessibility | WCAG 2.2 AA compliance throughout |
| Responsive | Mobile-first with progressive enhancement at small, medium, large, and extra-large breakpoints |
| Currency | All prices stored as integers (cents), formatted with locale-aware helpers |
| Images | Lazy-loaded, with proper alt text and maintained aspect ratios |

---

## Currency Formatting

All monetary amounts are stored as integers in minor units (cents) and must be formatted for display according to these rules:

| Setting | Value |
|---------|-------|
| Decimal separator | `.` (period) |
| Thousands separator | `,` (comma) |
| Currency symbol | After amount with space (e.g., `24.99 EUR`) |
| Decimal places | Always 2 |
| Free amounts | Display as `0.00 EUR` |
| Negative amounts (refunds) | Prefix with `-` (e.g., `-12.50 EUR`) |

**Helper:** Use a shared Blade component or helper (e.g., `<x-price :amount="$amount" :currency="$currency" />`) to ensure consistent formatting across all storefront pages.

**Examples:**
- 2499 cents -> `24.99 EUR`
- 0 cents -> `0.00 EUR`
- 149900 cents -> `1,499.00 EUR`

---

## 1. Theme System Architecture

### 1.1 View Organization

The storefront views are organized into the following categories:

| Category | Purpose | Views |
|---|---|---|
| Layouts | Master page shell | app (master layout) |
| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |
| Checkout | Purchase flow | checkout index, order confirmation |
| Account | Customer area | login, register, dashboard, orders (list + detail), addresses |
| Errors | Error states | 404, 503 |
| Components | Reusable UI elements | product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination |

### 1.2 Layout Hierarchy

All storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.

### 1.3 Theme Settings

Theme settings are stored in the database as JSON (keyed by theme ID). A singleton service loads and caches the active theme settings for the current store. Settings control:

- Announcement bar visibility, text, link, and background color
- Sticky header toggle
- Home page section order and visibility
- Color palette overrides (primary, secondary, accent)
- Typography scale
- Footer content and social links
- Dark mode behavior (system preference, toggle, or forced)

### 1.4 Section-Based Architecture

Each page is composed of configurable sections. Sections are Blade includes or Livewire components receiving configuration from theme settings.

| Section | Description | Used On |
|---|---|---|
| Hero | Full-width banner with heading, subheading, call-to-action | Home |
| Featured Collections | Grid of collection cards | Home |
| Featured Products | Grid of product cards (lazy-loaded) | Home |
| Newsletter | Email signup form | Home, Footer |
| Rich Text | Customizable content block | Home, Pages |

### 1.5 Blade Components

All reusable UI elements are implemented as Blade components with a storefront prefix. See Section 16 for the full component library reference.

---

## 2. Base Layout

### 2.1 Document Head

The document head includes:

| Element | Description |
|---|---|
| Character encoding | UTF-8 |
| Viewport | Width = device width, initial scale 1 |
| Meta description | Page-specific, yielded from child templates |
| Title | Page-specific title appended with the app name |
| Stylesheets | Compiled CSS via Vite |
| Scripts | Compiled JS via Vite |
| Livewire styles | Injected by Livewire |
| Language attribute | Set on the HTML element from application locale |
| Scroll behavior | Smooth scrolling enabled globally |

### 2.2 Skip Link

A visually hidden "Skip to main content" link is the first element in the body. It becomes visible on keyboard focus, positioned fixed at top-left with a clear visual treatment (solid background, shadow, focus ring). Supports dark mode.

**Accessibility:** Allows keyboard users to bypass the header navigation and jump directly to the main content area.

### 2.3 Announcement Bar

Optional, controlled by theme settings. Renders a full-width bar above the header.

| Attribute | Description |
|---|---|
| Position | Full-width, above the header |
| Background | Configurable color, defaults to dark background (light text) / inverted in dark mode |
| Text | Centered, small size, configurable content |
| Link | Optional, underlined, opens in same tab |
| Dismiss | Close button (X icon) on the right side; dismissal persisted via localStorage |
| Height | Auto height with vertical padding |

### 2.4 Header

The header serves as the primary navigation banner.

**Desktop Layout (large breakpoint and above):**

```
+--------------------------------------------------------------+
| [Logo]      [Nav Item] [Nav Item] [Nav Item]     [S] [C] [A] |
+--------------------------------------------------------------+
```

| Element | Position | Description |
|---|---|---|
| Logo | Left | Linked to home page. Shows image if logo URL is set in theme settings, otherwise store name as text. Max height approximately 40px on desktop, 32px on smaller screens |
| Main Navigation | Center | Horizontal nav with links sourced from the "main-menu" navigation menu. Supports one level of dropdown submenus (revealed on hover/focus) |
| Search Icon | Right group | Button that opens the search modal |
| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |
| Account Icon | Right group | Link to account dashboard (logged in) or login page (guest) |

**Mobile Layout (below large breakpoint):**

```
+--------------------------------------------------------------+
| [Hamburger]           [Logo]                            [Cart]|
+--------------------------------------------------------------+
```

| Element | Position | Description |
|---|---|---|
| Hamburger menu | Left | Toggle button for mobile navigation drawer |
| Logo | Center | Same as desktop but horizontally centered |
| Cart icon | Right | Same as desktop |

**Sticky Header (optional):**

When enabled in theme settings, the header sticks to the top of the viewport with a semi-transparent background and backdrop blur. A subtle bottom border appears on scroll.

**Mobile Navigation Drawer:**

| Attribute | Description |
|---|---|
| Animation | Slides in from the left side |
| Height | Full viewport height |
| Backdrop | Semi-transparent dark overlay behind the drawer |
| Close button | X icon at the top-right of the drawer |
| Navigation items | Vertical stack with comfortable tap targets |
| Submenus | Collapsible accordion pattern with chevron icons |
| Account link | Positioned at the bottom of the navigation list |
| Focus behavior | Focus is trapped within the drawer while open |
| ARIA | Labeled as "Mobile navigation" |

### 2.5 Main Content Area

The main content area is a landmark element with a unique ID for skip-link targeting. It occupies at least the full viewport height.

### 2.6 Footer

The footer is a content-info landmark.

**Layout:**

```
+--------------------------------------------------------------+
| [Column 1: Links]  [Column 2: Links]  [Column 3: Store Info] |
|                                                               |
| [Social Icons]                                                |
| [Copyright] [Payment Icons]                                   |
+--------------------------------------------------------------+
```

| Element | Description |
|---|---|
| Link columns | Responsive grid: 2 columns on mobile, 3 on tablet, up to 4 on desktop. Links sourced from "footer-menu" navigation menu. Each column has an uppercase heading and a list of links |
| Store info column | Store name, address (if configured), contact email |
| Social links | Row of icon links (Facebook, Instagram, Twitter/X, TikTok, YouTube) configured in theme settings. Each with screen reader label. Icons are 20x20, muted color with hover to full contrast |
| Copyright line | Small muted text: "(c) {year} {store name}. All rights reserved." |
| Payment method icons | Row of SVG icons for accepted payment methods (Visa, Mastercard, Amex, PayPal, etc.). Slightly reduced opacity |
| Spacing | Generous vertical padding, content constrained to max-width container with horizontal padding |
| Background | Light gray (dark: very dark gray), top border separator |

### 2.7 Cart Drawer (Livewire)

The cart drawer is always present in the layout as a global Livewire component. It listens for the "cart-updated" browser event to refresh and open. See Section 6 for full specification.

### 2.8 Dark Mode

Dark mode is implemented via Tailwind's dark variant. The behavior is controlled by theme settings:

| Mode | Implementation |
|---|---|
| System preference (default) | Follows OS-level dark mode preference automatically |
| Toggle | User-controlled toggle button in header/footer; preference stored in localStorage |
| Forced light/dark | Applied at server render time on the HTML element |

**Color Token Patterns:**

| Token | Light Mode | Dark Mode |
|---|---|---|
| Page background | White | Near-black |
| Card/section background | Light gray | Very dark gray |
| Heading text | Near-black | White |
| Body text | Medium gray | Light gray |
| Borders | Light gray | Medium-dark gray |
| Primary actions | Blue, darkens on hover | Slightly lighter blue, lightens on hover |

---

## 3. Home Page (GET /)

The home page renders sections in the order defined by theme settings. Each section can be toggled on/off and reordered. Default section order:

1. Hero Banner
2. Featured Collections
3. Featured Products
4. Newsletter Signup
5. Rich Text

### 3.1 Hero Banner Section

**Layout:** Full-width container (no max-width on the outer wrapper). Inner content constrained to standard max-width.

**Desktop (large breakpoint and above):**

```
+--------------------------------------------------------------+
|                                                               |
|  [Background Image / Gradient]                                |
|                                                               |
|       [Heading - very large, bold]                            |
|       [Subheading - medium, regular weight]                   |
|       [CTA Button - primary, large]                           |
|                                                               |
+--------------------------------------------------------------+
```

| Attribute | Description |
|---|---|
| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |
| Content | Centered vertically and horizontally, white text |
| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |
| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |
| CTA | Styled as primary button, large. Links to a configured collection or product |
| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |
| Padding | Generous vertical and horizontal padding |

**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.

**Accessibility:**

- If background is an image, it is decorative (the content provides meaning)
- CTA button has descriptive text (e.g., "Shop the summer collection")
- CTA button has descriptive text (e.g., "Shop the summer collection")

### 3.2 Featured Collections Section

**Layout:** Section with heading and grid of collection cards.

| Attribute | Description |
|---|---|
| Section heading | H2, large, bold, centered, with generous bottom margin |
| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |
| Count | 2 to 4 collections, configurable in theme settings |

**Collection Card:**

```
+---------------------------+
|  [Collection Image]       |
|  3:4 aspect ratio         |
|  cover-fit, rounded       |
|                           |
|  [Title overlay at bottom]|
|  [Shop now ->]            |
+---------------------------+
```

| Element | Description |
|---|---|
| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |
| Title | White text overlaid at bottom of image, semi-bold, medium-large size |
| "Shop now" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |
| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |

**Accessibility:**

- Each card link has the collection title as accessible name
- Images have alt text matching the collection title

### 3.3 Featured Products Section

**Livewire Component:** FeaturedProducts

This section uses Livewire for lazy loading to improve initial page load.

| Attribute | Description |
|---|---|
| Section heading | H2, same styling as featured collections heading |
| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |
| Products | 4 to 8, configurable. Sourced from a configured collection or "best sellers" logic |
| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |

Each product uses the product-card component (see Section 16).

### 3.4 Newsletter Signup Section

**Livewire Component:** NewsletterSignup

**Layout:**

```
+--------------------------------------------------------------+
|  [Heading: "Stay in the loop"]                                |
|  [Subtext: "Subscribe for exclusive offers and updates."]     |
|  [Email input] [Subscribe button]                             |
+--------------------------------------------------------------+
```

| Attribute | Description |
|---|---|
| Background | Light gray (dark mode: darker gray), generous vertical padding |
| Content | Centered, constrained to a comfortable width |
| Heading | H2, medium-large, bold |
| Subtext | Muted color, small top margin |
| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |
| Input | Email type, placeholder "Enter your email", required |
| Button | Primary style, "Subscribe" text |
| Success state | Replaces form with "Thanks for subscribing!" message |
| Error state | Inline error below input in red, small text |
| Loading state | Button text changes to "Subscribing..." during submission |

**Accessibility:**

- Label associated with email input (visually hidden if using placeholder)
- Success/error messages in a polite live region for screen readers

### 3.5 Rich Text Section

Simple section rendering customizable content from theme settings.

| Attribute | Description |
|---|---|
| Container | Constrained to comfortable reading width, centered, vertical padding |
| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |
| Dark mode | Inverted prose styling |

---

## 4. Collection Page (GET /collections/{handle})

**Livewire Component:** CollectionPage

### 4.1 Collection Header

| Element | Description |
|---|---|
| Breadcrumbs | Home > Collections > {Collection Title} |
| Title | H1, large, bold |
| Description | Rendered HTML from collection description, styled with prose/typography, constrained width |
| Spacing | Generous vertical padding |

### 4.2 Toolbar

Horizontal bar between the header and the product grid.

**Desktop:**

```
+--------------------------------------------------------------+
| [Filter toggle button]          [X products] [Sort dropdown]  |
+--------------------------------------------------------------+
```

**Mobile:**

```
+--------------------------------------------------------------+
| [Filter button]                           [Sort dropdown]     |
+--------------------------------------------------------------+
```

| Element | Description |
|---|---|
| Product count | Small muted text, e.g., "24 products" |
| Sort dropdown | Select element with live Livewire binding |

**Sort Options:**

| Option | Description |
|---|---|
| Featured | Default sort order |
| Price: Low to High | Ascending by price |
| Price: High to Low | Descending by price |
| Newest | Descending by creation date |
| Best Selling | Descending by sales count |

### 4.3 Filter Sidebar

**Desktop (large breakpoint and above):** Persistent sidebar on the left, approximately 256px wide.
**Mobile (below large breakpoint):** Slide-out drawer from the left, triggered by "Filter" button with filter icon.

**Filter Groups:**

Each filter group is a collapsible section with a heading and toggle chevron.

| Filter Group | Type | Binding | Description |
|---|---|---|---|
| Availability | Single checkbox | Live | "In stock" toggle |
| Price Range | Two number inputs | Live with 500ms debounce | "Min" and "Max" inputs with currency symbol prefix, displayed side by side |
| Product Type | Checkbox list | Live | Dynamically sourced from products in the collection |
| Vendor | Checkbox list | Live | Dynamically sourced from products in the collection |

**Clear All:**

- Link at the top of the filter sidebar: "Clear all filters"
- Only visible when at least one filter is active
- Small, blue, interactive text

**Active Filter Pills:**

- Displayed above the product grid (below toolbar) when filters are active
- Each pill: rounded tag with filter value text and a remove (X) button
- "Clear all" link at the end of the pills row

**Mobile Filter Drawer:**

| Attribute | Description |
|---|---|
| Position | Full-height overlay from left side |
| Header | "Filters" title + close button (X) |
| Content | Same filter groups as desktop, stacked vertically |
| Footer | Sticky "Apply" button at bottom that closes the drawer |
| Clear | "Clear all" link in header area |
| Focus | Trapped within drawer while open |

### 4.4 Product Grid

| Attribute | Description |
|---|---|
| Layout | Fills remaining width next to the sidebar |
| Columns | 2 on mobile, 3 on tablet, 3 on desktop (when sidebar visible). Without sidebar: 2 mobile, 3 tablet, 4 desktop |
| Loading state | Subtle opacity reduction on the grid while results update |

### 4.5 Product Card (Component)

The product card is a reusable component used on collection pages, search results, and featured products sections.

```
+---------------------------+
|  [Product Image]          |
|  1:1 aspect ratio         |
|  cover-fit, rounded       |
|  (hover: show 2nd image)  |
|                           |
|  [Sale badge]  [Sold out] |
+---------------------------+
|  [Product Title]          |
|  [Price]  [Compare price] |
|  [Quick Add / Options]    |
+---------------------------+
```

**Image Behavior:**

| Attribute | Description |
|---|---|
| Container | Square aspect ratio, rounded corners, overflow hidden, gray placeholder background |
| Primary image | Cover-fit, fills the container |
| Secondary image | On hover (desktop with hover capability only), crossfades to the second product image |
| Fallback | If no image, show a centered shopping bag placeholder icon |
| Loading | Lazy-loaded |

**Badges:**

| Badge | Condition | Position |
|---|---|---|
| "Sale" | compare_at_amount > price_amount | Top-left of image, stacked vertically |
| "Sold out" | All variants out of stock with deny policy | Top-left of image, stacked vertically |

**Text Content:**

| Element | Description |
|---|---|
| Title | H3 (or configurable heading level), small semi-bold text. Truncated to 2 lines |
| Price | Current price in small semi-bold text. Compare-at price shown with strikethrough if applicable |
| Quick Add | Single variant: "Add to cart" button. Multiple variants: "Choose options" link text. Hidden by default on desktop (shown on hover), always visible on mobile |

**Accessibility:**

- Card link wraps the entire card; title serves as the accessible name
- Badges have screen reader labels (e.g., "On sale")
- Price changes announced via parent live region when filtering changes results

### 4.6 Pagination

| Attribute | Description |
|---|---|
| Style | Numbered pagination with previous/next arrow buttons |
| Current page | Highlighted with primary color background and white text |
| Other pages | Muted text, hover background |
| Disabled | Reduced opacity and not-allowed cursor on previous (at first page) or next (at last page) |
| Truncation | Ellipsis for large page counts |
| Mobile | Simplified: previous/next only with current page indicator |

### 4.7 Empty State

When no products match the current filters:

```
+--------------------------------------------------------------+
|  [Search icon - large, muted]                                 |
|  "No products found"                                          |
|  "Try adjusting your filters or browse our full collection."  |
|  [Clear filters button]                                       |
+--------------------------------------------------------------+
```

| Element | Description |
|---|---|
| Icon | Large, muted search icon |
| Heading | Medium, semi-bold |
| Subtext | Muted, descriptive guidance |
| Button | Secondary style, "Clear filters" |
| Position | Centered in the grid area with generous vertical padding |

---

## 5. Product Page (GET /products/{handle})

**Livewire Component:** ProductPage

### 5.1 Layout

**Desktop (large breakpoint and above):** Two-column, 50/50 split. Left column (images) is sticky while the right column (product info) scrolls.

**Mobile:** Single column, stacked. Images first, then product info.

```
Desktop:
+-------------------------------+-------------------------------+
|  [Image Gallery]              |  [Product Info]               |
|  (sticky while scrolling)     |  (scrollable)                 |
+-------------------------------+-------------------------------+

Mobile:
+--------------------------------------------------------------+
|  [Image Gallery - horizontal scroll]                          |
+--------------------------------------------------------------+
|  [Product Info - full width]                                  |
+--------------------------------------------------------------+
```

Container constrained to standard max-width with horizontal padding and vertical spacing.

### 5.2 Image Gallery (Left Column)

**Desktop:**

```
+-------------------------------+
|                               |
|  [Main Image - large]         |
|  1:1 aspect ratio, rounded    |
|                               |
+-------------------------------+
| [Thumb] [Thumb] [Thumb] ...  |
+-------------------------------+
```

| Attribute | Description |
|---|---|
| Main image | Square aspect ratio, large rounded corners, cover-fit. Gray placeholder background |
| Zoom on hover | Optional (theme setting): image scales to 150% and follows cursor position |
| Thumbnail strip | Horizontal row with overflow scroll, small gap between thumbnails |
| Each thumbnail | 64x64px, rounded, cover-fit, pointer cursor. Active thumbnail has a colored border |
| Variant images | When a variant is selected that has specific images, those images are shown |

**Mobile:**

| Attribute | Description |
|---|---|
| Gallery | Horizontal scrollable row with snap-scroll behavior |
| Each image | Full-width, square aspect ratio, rounded, cover-fit |
| Scroll indicators | Row of dots below the gallery. Active dot is dark (light in dark mode), inactive dots are gray |

**Accessibility:**

| Requirement | Description |
|---|---|
| Alt text | Main image has descriptive alt text from product media data |
| Thumbnails | Rendered as buttons with labels "View image X of Y" and current state indicator |
| Region | Gallery wrapped in a labeled region ("Product images") |