Output:
Wall time: 5.0743 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Components Pagination > Large result set\nComponents Pagination | livewire/flux@2.x | Source: https://fluxui.dev/components/pagination#livewire/flux\n\n
Large result set

When working with large result sets, the pagination component automatically adapts to show a reasonable number of page links. It shows the first and last pages, along with a window of pages around the current page, and adds ellipses for any gaps to ensure efficient navigation through numerous pages.

<flux:pagination :paginator=\"$orders\" />

\n\n---\n\n## Components Checkbox > Buttons\nComponents Checkbox | livewire/flux@2.x | Source: https://fluxui.dev/components/checkbox#livewire/flux\n\n
Buttons

Button-style checkbox options that look like a toolbar. Perfect for toggle controls and feature selections where you want a more prominent visual style.

<flux:checkbox.group wire:model=\"features\" label=\"Features\" variant=\"buttons\">    <flux:checkbox value=\"notifications\" icon=\"bell\" label=\"Notifications\" />    <flux:checkbox value=\"analytics\" icon=\"chart-bar\" label=\"Analytics\" />    <flux:checkbox value=\"backups\" icon=\"cloud-arrow-up\" label=\"Backups\" /></flux:checkbox.group>

\n\n---\n\n## Telescope Available Watchers Job Watcher\nTelescope | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/telescope.md\n\n\n### Job Watcher\n\nThe job watcher records the data and status of any [jobs](/docs/{{version}}/queues) dispatched by your application.\n\n---\n\n## Redirecting Redirecting to full-page components\nRedirecting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/redirecting\n\n## Redirecting to full-page components\n\nBecause Livewire uses Laravel's built-in redirection feature, you can use all of the redirection methods available to you in a typical Laravel application.\n\nFor example, if you are using a Livewire component as a full-page component for a route like so:\n\n```php\nRoute::livewire('/posts', 'pages::show-posts');\n```\n\nYou can redirect to it simply by using the route path:\n\n```php\npublic function save()\n{\n    // ...\n\n    $this->redirect('/posts');\n}\n```\n\n---\n\n## Lifecycle Hooks Hydrate & Dehydrate\nLifecycle Hooks | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/lifecycle-hooks\n\n## Hydrate & Dehydrate\n\nHydrate and dehydrate are lesser-known and lesser-utilized hooks. However, there are specific scenarios where they can be powerful.\n\nThe terms \"dehydrate\" and \"hydrate\" refer to a Livewire component being serialized to JSON for the client-side and then unserialized back into a PHP object on the subsequent request.\n\nWe often use the terms \"hydrate\" and \"dehydrate\" to refer to this process throughout Livewire's codebase and the documentation. If you'd like more clarity on these terms, you can learn more by [consulting our hydration documentation](/docs/4.x/hydration).\n\nLet's look at an example that uses both `mount()` , `hydrate()`, and `dehydrate()` all together to support using a custom [data transfer object (DTO)](https://en.wikipedia.org/wiki/Data_transfer_object) instead of an Eloquent model to store the post data in the component:\n\n```php\n<?php // resources/views/components/post/⚡show.blade.php\n\nuse Livewire\\Component;\n\nnew class extends Component {\n    public $post;\n\n    public function mount($title, $content)\n    {\n        // Runs at the beginning of the first initial request...\n\n        $this->post = new PostDto([\n            'title' => $title,\n            'content' => $content,\n        ]);\n    }\n\n    public function hydrate()\n    {\n        // Runs at the beginning of every \"subsequent\" request...\n        // This doesn't run on the initial request (\"mount\" does)...\n\n        $this->post = new PostDto($this->post);\n    }\n\n    public function dehydrate()\n    {\n        // Runs at the end of every single request...\n\n        $this->post = $this->post->toArray();\n    }\n\n    // ...\n};\n```\n\nNow, from actions and other places inside your component, you can access the `PostDto` object instead of the primitive data.\n\nThe above example mainly demonstrates the abilities and nature of the `hydrate()` and `dehydrate()` hooks. However, it is recommended that you use [Wireables or Synthesizers](/docs/4.x/properties#supporting-custom-types) to accomplish this instead.\n\n---\n\n## Attribute Json See also\nAttribute Json | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-json\n\n## See also\n\n- **[Actions](/docs/4.x/actions)** — Learn about invoking methods and receiving return values\n- **[Validation](/docs/4.x/validation)** — Server-side validation for Livewire components\n- **[Async Attribute](/docs/4.x/attribute-async)** — Run actions in parallel without blocking\n- **[Renderless Attribute](/docs/4.x/attribute-renderless)** — Skip re-rendering after an action\n\n---\n\n## Components Pagination > Scroll to top\nComponents Pagination | livewire/flux@2.x | Source: https://fluxui.dev/components/pagination#livewire/flux\n\n
\n            Scroll to top\n

Use the scroll-to prop to scroll the page when a pagination button is clicked. By default, it scrolls to the body element.

<flux:pagination :paginator=\"$orders\" scroll-to />

You can also target a specific element by passing a CSS selector.

<flux:pagination :paginator=\"$orders\" scroll-to=\"#table\" />

\n\n---\n\n## Search Full-Text Search\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n## Full-Text Search\n\nWhile `LIKE` queries work well for simple substring matching, they don't understand language. A `LIKE` search for \"running\" won't find a record containing \"run\", and results aren't ranked by relevance — they're simply returned in whatever order the database finds them. Full-text search solves both of these problems by using specialized indexes that understand word boundaries, stemming, and relevance scoring, allowing the database to return the most relevant results first.\n\nFast full-text search is built into MariaDB, MySQL, and PostgreSQL — no external search service is required. You only need to add a full-text index to the columns you want to search, and then use the `whereFullText` query builder method to search against them.\n\n> [!WARNING]\n> Full-text search is currently supported by MariaDB, MySQL, and PostgreSQL.\n\n---\n\n## Lazy Full-page lazy loading\nLazy | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/lazy\n\n## Full-page lazy loading\n\nYou can lazy load or defer full-page Livewire components using route methods.\n\n### Lazy loading full pages\n\nUse `->lazy()` to load the component when it enters the viewport:\n\n```php\nRoute::livewire('/dashboard', 'pages::dashboard')->lazy();\n```\n\n### Deferring full pages\n\nUse `->defer()` to load the component immediately after the page loads:\n\n```php\nRoute::livewire('/dashboard', 'pages::dashboard')->defer();\n```\n\n### Disabling lazy/defer loading\n\nIf a component is lazy or deferred by default (via the `#[Lazy]` or `#[Defer]` attribute), you can opt-out using `enabled: false`:\n\n```php\nRoute::livewire('/dashboard', 'pages::dashboard')->lazy(enabled: false);\nRoute::livewire('/dashboard', 'pages::dashboard')->defer(enabled: false);\n```\n\n---\n\n## Routing Rate Limiting Attaching Rate Limiters to Routes\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n### Attaching Rate Limiters to Routes\n\nRate limiters may be attached to routes or route groups using the `throttle` [middleware](/docs/{{version}}/middleware). The throttle middleware accepts the name of the rate limiter you wish to assign to the route:\n\n```php\nRoute::middleware(['throttle:uploads'])->group(function () {\n    Route::post('/audio', function () {\n        // ...\n    });\n\n    Route::post('/video', function () {\n        // ...\n    });\n});\n```\n\n---\n\n## Attribute Json When to use\nAttribute Json | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-json\n\n## When to use\n\nUse `#[Json]` when:\n\n* **Building dynamic search/autocomplete** - Fetching results for a dropdown or suggestion list\n* **Loading data into JavaScript** - Populating charts, maps, or other JS-driven UI\n* **Submitting forms with JS handling** - When you want to handle success/error states in JavaScript\n* **Integrating with third-party libraries** - Providing data to libraries that manage their own rendering\n\n> [!warning] Validation errors are isolated\n> Validation errors from JSON methods are only returned via promise rejection. They don't appear in `$wire.$errors` or the component's error bag. This is intentional—JSON methods are self-contained and don't affect the component's rendered state.\n\n---\n\n## Stress Testing The Stress function Requests Upload Data Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Upload Data Rate\n\nReturns the request upload data rate in bytes per second.\n\n```php\n$result->requests()->upload()->data()->rate();\n```\n\n---\n\n## Wire Model Input fields\nWire Model | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-model\n\n## Input fields\n\nLivewire supports most native input elements out of the box. Meaning you should just be able to attach `wire:model` to any input element in the browser and easily bind properties to them.\n\nHere's a comprehensive list of the different available input types and how you use them in a Livewire context.\n\n### Text inputs\n\nFirst and foremost, text inputs are the bedrock of most forms. Here's how to bind a property named \"title\" to one:\n\n```blade\n<input type=\"text\" wire:model=\"title\">\n```\n\n### Textarea inputs\n\nTextarea elements are similarly straightforward. Simply add `wire:model` to a textarea and the value will be bound:\n\n```blade\n<textarea type=\"text\" wire:model=\"content\"></textarea>\n```\n\nIf the \"content\" value is initialized with a string, Livewire will fill the textarea with that value - there's no need to do something like the following:\n\n```blade\n<!-- Warning: This snippet demonstrates what NOT to do... -->\n\n<textarea type=\"text\" wire:model=\"content\">{{ $content }}</textarea>\n```\n\n### Checkboxes\n\nCheckboxes can be used for single values, such as when toggling a boolean property. Or, checkboxes may be used to toggle a single value in a group of related values. We'll discuss both scenarios:\n\n#### Single checkbox\n\nAt the end of a signup form, you might have a checkbox allowing the user to opt-in to email updates. You might call this property `$receiveUpdates`. You can easily bind this value to the checkbox using `wire:model`:\n\n```blade\n<input type=\"checkbox\" wire:model=\"receiveUpdates\">\n```\n\nNow when the `$receiveUpdates` value is `false`, the checkbox will be unchecked. Of course, when the value is `true`, the checkbox will be checked.\n\n#### Multiple checkboxes\n\nNow, let's say in addition to allowing the user to decide to receive updates, you have an array property in your class called `$updateTypes`, allowing the user to choose from a variety of update types:\n\n```php\npublic $updateTypes = [];\n```\n\nBy binding multiple checkboxes to the `$updateTypes` property, the user can select multiple update types and they will be added to the `$updateTypes` array property:\n\n```blade\n<input type=\"checkbox\" value=\"email\" wire:model=\"updateTypes\">\n<input type=\"checkbox\" value=\"sms\" wire:model=\"updateTypes\">\n<input type=\"checkbox\" value=\"notification\" wire:model=\"updateTypes\">\n```\n\nFor example, if the user checks the first two boxes but not the third, the value of `$updateTypes` will be: `[\"email\", \"sms\"]`\n\n### Radio buttons\n\nTo toggle between two different values for a single property, you may use radio buttons:\n\n```blade\n<input type=\"radio\" value=\"yes\" wire:model=\"receiveUpdates\">\n<input type=\"radio\" value=\"no\" wire:model=\"receiveUpdates\">\n```\n\n### Select dropdowns\n\nLivewire makes it simple to work with `<select>` dropdowns. When adding `wire:model` to a dropdown, the currently selected value will be bound to the provided property name and vice versa.\n\nIn addition, there's no need to manually add `selected` to the option that will be selected - Livewire handles that for you automatically.\n\nBelow is an example of a select dropdown filled with a static list of states:\n\n```blade\n<select wire:model=\"state\">\n    <option value=\"AL\">Alabama</option>\n    <option value=\"AK\">Alaska</option>\n    <option value=\"AZ\">Arizona</option>\n    ...\n</select>\n```\n\nWhen a specific state is selected, for example, \"Alaska\", the `$state` property on the component will be set to `AK`. If you would prefer the value to be set to \"Alaska\" instead of \"AK\", you can leave the `value=\"\"` attribute off the `<option>` element entirely.\n\nOften, you may build your dropdown options dynamically using Blade:\n\n```blade\n<select wire:model=\"state\">\n    @foreach (\\App\\Models\\State::all() as $state)\n        <option value=\"{{ $state->id }}\">{{ $state->label }}</option>\n    @endforeach\n</select>\n```\n\nIf you don't have a specific option selected by default, you may want to show a muted placeholder option by default, such as \"Select a state\":\n\n```blade\n<select wire:model=\"state\">\n    <option disabled value=\"\">Select a state...</option>\n\n    @foreach (\\App\\Models\\State::all() as $state)\n        <option value=\"{{ $state->id }}\">{{ $state->label }}</option>\n    @endforeach\n</select>\n```\n\nAs you can see, there is no \"placeholder\" attribute for a select menu like there is for text inputs. Instead, you have to add a `disabled` option element as the first option in the list.\n\n### Dependent select dropdowns\n\nSometimes you may want one select menu to be dependent on another. For example, a list of cities that changes based on which state is selected.\n\nFor the most part, this works as you'd expect, however there is one important gotcha: You must add a `wire:key` to the changing select so that Livewire properly refreshes its value when the options change.\n\nHere's an example of two selects, one for states, one for cities. When the state select changes, the options in the city select will change properly:\n\n```blade\n<!-- States select menu... -->\n<select wire:model.live=\"selectedState\">\n    @foreach (State::all() as $state)\n        <option value=\"{{ $state->id }}\">{{ $state->label }}</option>\n    @endforeach\n</select>\n\n<!-- Cities dependent select menu... -->\n<select wire:model.live=\"selectedCity\" wire:key=\"{{ $selectedState }}\"> <!-- [tl! highlight] -->\n    @foreach (City::whereStateId($selectedState->id)->get() as $city)\n        <option value=\"{{ $city->id }}\">{{ $city->label }}</option>\n    @endforeach\n</select>\n```\n\nAgain, the only thing non-standard here is the `wire:key` that has been added to the second select. This ensures that when the state changes, the \"selectedCity\" value will be reset properly.\n\n### Multi-select dropdowns\n\nIf you are using a \"multiple\" select menu, Livewire works as expected. In this example, states will be added to the `$states` array property when they are selected and removed if they are deselected:\n\n```blade\n<select wire:model=\"states\" multiple>\n    <option value=\"AL\">Alabama</option>\n    <option value=\"AK\">Alaska</option>\n    <option value=\"AZ\">Arizona</option>\n    ...\n</select>\n```\n\n---\n\n## Structure The App Directory The Jobs Directory\nStructure | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/structure.md\n\n\n### The Jobs Directory\n\nThis directory does not exist by default, but will be created for you if you execute the `make:job` Artisan command. The `Jobs` directory houses the [queueable jobs](/docs/{{version}}/queues) for your application. Jobs may be queued by your application or run synchronously within the current request lifecycle. Jobs that run synchronously during the current request are sometimes referred to as \"commands\" since they are an implementation of the [command pattern](https://en.wikipedia.org/wiki/Command_pattern).\n\n---\n\n## Pagination Displaying Pagination Results Converting Results to JSON\nPagination | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pagination.md\n\n\n### Converting Results to JSON\n\nThe Laravel paginator classes implement the `Illuminate\\Contracts\\Support\\Jsonable` Interface contract and expose the `toJson` method, so it's very easy to convert your pagination results to JSON. You may also convert a paginator instance to JSON by returning it from a route or controller action:\n\n```php\nuse App\\Models\\User;\n\nRoute::get('/users', function () {\n    return User::paginate();\n});\n```\n\nThe JSON from the paginator will include meta information such as `total`, `current_page`, `last_page`, and more. The result records are available via the `data` key in the JSON array. Here is an example of the JSON created by returning a paginator instance from a route:\n\n```json\n{\n   \"total\": 50,\n   \"per_page\": 15,\n   \"current_page\": 1,\n   \"last_page\": 4,\n   \"current_page_url\": \"http://laravel.app?page=1\",\n   \"first_page_url\": \"http://laravel.app?page=1\",\n   \"last_page_url\": \"http://laravel.app?page=4\",\n   \"next_page_url\": \"http://laravel.app?page=2\",\n   \"prev_page_url\": null,\n   \"path\": \"http://laravel.app\",\n   \"from\": 1,\n   \"to\": 15,\n   \"data\":[\n        {\n            // Record...\n        },\n        {\n            // Record...\n        }\n   ]\n}\n```\n\n---\n\n## Components Table > Pagination\nComponents Table | livewire/flux@2.x | Source: https://fluxui.dev/components/table#livewire/flux\n\n
Pagination

Allow users to navigate through different pages of data by passing in any model paginator to the paginate prop.

<flux:table :paginate=\"$orders\">    </flux:table>

\n\n---\n\n## Scheduling Defining Schedules Scheduling Queued Jobs\nScheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md\n\n\n### Scheduling Queued Jobs\n\nThe `job` method may be used to schedule a [queued job](/docs/{{version}}/queues). This method provides a convenient way to schedule queued jobs without using the `call` method to define closures to queue the job:\n\n```php\nuse App\\Jobs\\Heartbeat;\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::job(new Heartbeat)->everyFiveMinutes();\n```\n\nOptional second and third arguments may be provided to the `job` method which specifies the queue name and queue connection that should be used to queue the job:\n\n```php\nuse App\\Jobs\\Heartbeat;\nuse Illuminate\\Support\\Facades\\Schedule;\n\n// Dispatch the job to the \"heartbeats\" queue on the \"sqs\" connection...\nSchedule::job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();\n```\n\n---\n\n## Components Pagination > flux:pagination\nComponents Pagination | livewire/flux@2.x | Source: https://fluxui.dev/components/pagination#livewire/flux\n\n
flux:pagination

<ui-table-scroll-area>

Prop

Description

\n    paginator\n

The paginator instance to display.

\n    scroll-to\n

Scroll to an element when a pagination button is clicked. Pass a CSS selector to target a specific element. Default: body.

</ui-table-scroll-area>

\n\n---\n\n## Queues Job Batching Pruning Batches\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n### Pruning Batches\n\nWithout pruning, the `job_batches` table can accumulate records very quickly. To mitigate this, you should [schedule](/docs/{{version}}/scheduling) the `queue:prune-batches` Artisan command to run daily:\n\n```php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('queue:prune-batches')->daily();\n```\n\nBy default, all finished batches that are more than 24 hours old will be pruned. You may use the `hours` option when calling the command to determine how long to retain batch data. For example, the following command will delete all batches that finished over 48 hours ago:\n\n```php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('queue:prune-batches --hours=48')->daily();\n```\n\nSometimes, your `job_batches` table may accumulate batch records for batches that never completed successfully, such as batches where a job failed and that job was never retried successfully. You may instruct the `queue:prune-batches` command to prune these unfinished batch records using the `unfinished` option:\n\n```php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();\n```\n\nLikewise, your `job_batches` table may also accumulate batch records for cancelled batches. You may instruct the `queue:prune-batches` command to prune these cancelled batch records using the `cancelled` option:\n\n```php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('queue:prune-batches --hours=48 --cancelled=72')->daily();\n```\n\n---\n\n## Components Field > With badge\nComponents Field | livewire/flux@2.x | Source: https://fluxui.dev/components/field#livewire/flux\n\n
With badge

Badges allow you to enhance a field with additional information such as being \"required\" or \"optional\" when it might not be expected.

<flux:field>    <flux:label badge=\"Required\">Email</flux:label>    <flux:input type=\"email\" required />    <flux:error name=\"email\" /></flux:field><flux:field>    <flux:label badge=\"Optional\">Phone number</flux:label>    <flux:input type=\"phone\" placeholder=\"(555) 555-5555\" mask=\"(999) 999-9999\"  />    <flux:error name=\"phone\" /></flux:field>

\n\n---\n\n## Search Full-Text Search Running Full-Text Queries\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n### Running Full-Text Queries\n\nOnce the index is in place, use the `whereFullText` query builder method to search against it. Laravel will generate the appropriate SQL for your database driver — for example, `MATCH(...) AGAINST(...)` on MariaDB and MySQL, and `to_tsvector(...) @@ plainto_tsquery(...)` on PostgreSQL:\n\n```php\n$articles = Article::whereFullText('body', 'web developer')->get();\n```\n\nWhen using MariaDB and MySQL, results are automatically ordered by relevance score. On PostgreSQL, `whereFullText` filters matching records but does not order them by relevance — if you need automatic relevance ordering on PostgreSQL, consider using [Scout's database engine](#database-engine), which handles this for you.\n\nIf you created a composite full-text index across multiple columns, you may search against all of them by passing the same array of columns to `whereFullText`:\n\n```php\n$articles = Article::whereFullText(\n    ['title', 'body'], 'web developer'\n)->get();\n```\n\nThe `orWhereFullText` method may be used to add a full-text search clause as an \"or\" condition. For complete details, consult the [query builder documentation](/docs/{{version}}/queries#full-text-where-clauses).\n\n---\n\n## Attribute Title Only for full-page components\nAttribute Title | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-title\n\n## Only for full-page components\n\n> [!info] Full-page components only\n> The `#[Title]` attribute only works for full-page components accessed via routes. Regular components rendered within other views don't use titles—they inherit the parent page's title.\n\n---\n\n## Scheduling Defining Schedules Running Tasks on One Server Naming Single Server Jobs\nScheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md\n\n\n#### Naming Single Server Jobs\n\nSometimes you may need to schedule the same job to be dispatched with different parameters, while still instructing Laravel to run each permutation of the job on a single server. To accomplish this, you may assign each schedule definition a unique name via the `name` method:\n\n```php\nSchedule::job(new CheckUptime('https://laravel.com'))\n    ->name('check_uptime:laravel.com')\n    ->everyFiveMinutes()\n    ->onOneServer();\n\nSchedule::job(new CheckUptime('https://vapor.laravel.com'))\n    ->name('check_uptime:vapor.laravel.com')\n    ->everyFiveMinutes()\n    ->onOneServer();\n```\n\nSimilarly, scheduled closures must be assigned a name if they are intended to be run on one server:\n\n```php\nSchedule::call(fn () => User::resetApiRequestCount())\n    ->name('reset-api-request-count')\n    ->daily()\n    ->onOneServer();\n```\n\n---\n\n## Stress Testing The Stress function Requests Download Data Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Download Data Rate\n\nReturns the request download data rate in bytes per second.\n\n```php\n$result->requests()->download()->data()->rate();\n```\n\n---\n\n## Attribute Json Behavior\nAttribute Json | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-json\n\n## Behavior\n\nThe `#[Json]` attribute automatically applies two behaviors:\n\n1. **Skips rendering** - The component doesn't re-render after the action completes, since the response is consumed by JavaScript\n2. **Runs asynchronously** - The action executes in parallel without blocking other requests\n\nThese behaviors match what you'd expect from an API-style endpoint.\n\n---\n\n## Wire Confirm Prompting users for input\nWire Confirm | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-confirm\n\n## Prompting users for input\n\nFor even more dangerous actions such as deleting a user's account entirely, you may want to present them with a confirmation prompt which they would need to type in a specific string of characters to confirm the action.\n\nLivewire provides a helpful `.prompt` modifier, that when applied to `wire:confirm`, it will prompt the user for input and only confirm the action if the input matches (case-sensitive) the provided string (designated by a \"|\" (pipe) character at the end if the `wire:confirm` value):\n\n```blade\n<button\n    type=\"button\"\n    wire:click=\"delete\"\n    wire:confirm.prompt=\"Are you sure?\\n\\nType DELETE to confirm|DELETE\"\n>\n    Delete account <!-- [tl! highlight:-2,1] -->\n</button>\n```\n\nWhen a user presses \"Delete account\", the action will only be performed if \"DELETE\" is entered into the prompt, otherwise, the action will be cancelled.\n\n---\n\n## Components Button > Full width\nComponents Button | livewire/flux@2.x | Source: https://fluxui.dev/components/button#livewire/flux\n\n
Full width

A button that spans the full width of the container.

<flux:button variant=\"primary\" class=\"w-full\">Send invite</flux:button>

\n\n---\n\n## Routing Rate Limiting Defining Rate Limiters\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n### Defining Rate Limiters\n\nLaravel includes powerful and customizable rate limiting services that you may utilize to restrict the amount of traffic for a given route or group of routes. To get started, you should define rate limiter configurations that meet your application's needs.\n\nRate limiters may be defined within the `boot` method of your application's `App\\Providers\\AppServiceProvider` class:\n\n```php\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\RateLimiter;\n\n/**\n * Bootstrap any application services.\n */\npublic function boot(): void\n{\n    RateLimiter::for('api', function (Request $request) {\n        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());\n    });\n}\n```\n\nRate limiters are defined using the `RateLimiter` facade's `for` method. The `for` method accepts a rate limiter name and a closure that returns the limit configuration that should apply to routes that are assigned to the rate limiter. Limit configuration are instances of the `Illuminate\\Cache\\RateLimiting\\Limit` class. This class contains helpful \"builder\" methods so that you can quickly define your limit. The rate limiter name may be any string you wish:\n\n```php\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\RateLimiter;\n\n/**\n * Bootstrap any application services.\n */\npublic function boot(): void\n{\n    RateLimiter::for('global', function (Request $request) {\n        return Limit::perMinute(1000);\n    });\n}\n```\n\nIf the incoming request exceeds the specified rate limit, a response with a 429 HTTP status code will automatically be returned by Laravel. If you would like to define your own response that should be returned by a rate limit, you may use the `response` method:\n\n```php\nRateLimiter::for('global', function (Request $request) {\n    return Limit::perMinute(1000)->response(function (Request $request, array $headers) {\n        return response('Custom response...', 429, $headers);\n    });\n});\n```\n\nSince rate limiter callbacks receive the incoming HTTP request instance, you may build the appropriate rate limit dynamically based on the incoming request or authenticated user:\n\n```php\nRateLimiter::for('uploads', function (Request $request) {\n    return $request->user()->vipCustomer()\n        ? Limit::none()\n        : Limit::perHour(10);\n});\n```\n\n---\n\n## Attribute Json Handling responses\nAttribute Json | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-json\n\n## Handling responses\n\nJSON methods resolve with the return value on success, and reject on validation failure:\n\n**On success:**\n```js\nlet data = await $wire.search('query')\n// data = [ { id: 1, title: '...' }, ...]\n```\n\n**On validation failure:**\n```js\ntry {\n    let data = await $wire.save()\n} catch (e) {\n    // e.status = 422\n    // e.errors = { name: ['The name field is required.'] }\n}\n```\n\nOr using `.catch()`:\n```js\n$wire.save()\n    .then(data => {\n        // Handle success\n        console.log(data)\n    })\n    .catch(e => {\n        if (e.status === 422) {\n            // Handle validation errors\n            console.log(e.errors)\n        }\n    })\n```\n\n---\n\n## Stress Testing The Stress function Requests Failed Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Failed Rate\n\nReturns the number of requests that failed per second.\n\n```php\n$result->requests()->failed()->rate();\n```\n\n---\n\n## Attribute Json Error rejection shape\nAttribute Json | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-json\n\n## Error rejection shape\n\nWhen a promise is rejected, the error object has this structure:\n\n```js\n{\n    status: 422,    // HTTP status code (422 for validation errors)\n    body: null,     // Raw response body (null for validation errors)\n    json: null,     // Parsed JSON (null for validation errors)\n    errors: {...}   // Validation errors object\n}\n```\n\nFor HTTP errors (500, etc.), the shape is the same but with the actual response data:\n\n```js\n{\n    status: 500,\n    body: '<html>...</html>',\n    json: null,\n    errors: null\n}\n```\n\n---\n\n## Search Introduction Full-Text Search\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n#### Full-Text Search\n\nWhen you need keyword relevance ranking — where the database scores and sorts results based on how well they match the search terms — Laravel's `whereFullText` query builder method leverages native full-text indexes on MariaDB, MySQL, and PostgreSQL. Full-text search understands word boundaries and stemming, so a search for \"running\" can match records containing \"run\". No external service is required.\n\n---\n\n## Forms Throttling input\nForms | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/forms\n\n## Throttling input\n\nAs stated previously, when an input debounce is applied to a field, a network request will not be sent until the user has stopped typing for a certain amount of time. This means if the user continues typing a long message, a network request won't be sent until the user is finished.\n\nSometimes this isn't the desired behavior, and you would rather send a request as the user types, not when they've finished or taken a break.\n\nIn these cases, you can instead use `.throttle` to signify a time interval to send network requests:\n\n```blade\n<input type=\"text\" wire:model.live.throttle.150ms=\"title\" >\n```\n\nIn the above example, as a user is typing continuously in the \"title\" field, a network request will be sent every 150 milliseconds until the user is finished.\n\n---\n\n## Search Full-Text Search Adding Full-Text Indexes\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n### Adding Full-Text Indexes\n\nTo use full-text search, first add a full-text index to the columns you want to search. You may add the index to a single column, or pass an array of columns to create a composite index that searches across multiple fields at once:\n\n```php\nSchema::create('articles', function (Blueprint $table) {\n    $table->id();\n    $table->string('title');\n    $table->text('body');\n    $table->timestamps();\n\n    $table->fullText(['title', 'body']);\n});\n```\n\nOn PostgreSQL, you may specify a language configuration for the index, which controls how words are stemmed:\n\n```php\n$table->fullText('body')->language('english');\n```\n\nFor more information on creating indexes, consult the [migration documentation](/docs/{{version}}/migrations#available-index-types).\n\n---\n\n## Http Tests Testing JSON APIs\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n## Testing JSON APIs\n\nLaravel also provides several helpers for testing JSON APIs and their responses. For example, the `json`, `getJson`, `postJson`, `putJson`, `patchJson`, `deleteJson`, and `optionsJson` methods may be used to issue JSON requests with various HTTP verbs. You may also easily pass data and headers to these methods. To get started, let's write a test to make a `POST` request to `/api/user` and assert that the expected JSON data was returned:\n\n```php tab=Pest\n<?php\n\ntest('making an api request', function () {\n    $response = $this->postJson('/api/user', ['name' => 'Sally']);\n\n    $response\n        ->assertStatus(201)\n        ->assertJson([\n            'created' => true,\n        ]);\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    /**\n     * A basic functional test example.\n     */\n    public function test_making_an_api_request(): void\n    {\n        $response = $this->postJson('/api/user', ['name' => 'Sally']);\n\n        $response\n            ->assertStatus(201)\n            ->assertJson([\n                'created' => true,\n            ]);\n    }\n}\n```\n\nIn addition, JSON response data may be accessed as array variables on the response, making it convenient for you to inspect the individual values returned within a JSON response:\n\n```php tab=Pest\nexpect($response['created'])->toBeTrue();\n```\n\n```php tab=PHPUnit\n$this->assertTrue($response['created']);\n```\n\n> [!NOTE]\n> The `assertJson` method converts the response to an array to verify that the given array exists within the JSON response returned by the application. So, if there are other properties in the JSON response, this test will still pass as long as the given fragment is present.\n\n---\n\n## Queues Job Events\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n## Job Events\n\nUsing the `before` and `after` methods on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks to be executed before or after a queued job is processed. These callbacks are a great opportunity to perform additional logging or increment statistics for a dashboard. Typically, you should call these methods from the `boot` method of a [service provider](/docs/{{version}}/providers). For example, we may use the `AppServiceProvider` that is included with Laravel:\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\Facades\\Queue;\nuse Illuminate\\Support\\ServiceProvider;\nuse Illuminate\\Queue\\Events\\JobProcessed;\nuse Illuminate\\Queue\\Events\\JobProcessing;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        // ...\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        Queue::before(function (JobProcessing $event) {\n            // $event->connectionName\n            // $event->job\n            // $event->job->payload()\n        });\n\n        Queue::after(function (JobProcessed $event) {\n            // $event->connectionName\n            // $event->job\n            // $event->job->payload()\n        });\n    }\n}\n```\n\nUsing the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Queue;\n\nQueue::looping(function () {\n    while (DB::transactionLevel() > 0) {\n        DB::rollBack();\n    }\n});\n```\n\n---\n\n## Routing Rate Limiting Defining Rate Limiters Segmenting Rate Limits\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n#### Segmenting Rate Limits\n\nSometimes you may wish to segment rate limits by some arbitrary value. For example, you may wish to allow users to access a given route 100 times per minute per IP address. To accomplish this, you may use the `by` method when building your rate limit:\n\n```php\nRateLimiter::for('uploads', function (Request $request) {\n    return $request->user()->vipCustomer()\n        ? Limit::none()\n        : Limit::perMinute(100)->by($request->ip());\n});\n```\n\nTo illustrate this feature using another example, we can limit access to the route to 100 times per minute per authenticated user ID or 10 times per minute per IP address for guests:\n\n```php\nRateLimiter::for('uploads', function (Request $request) {\n    return $request->user()\n        ? Limit::perMinute(100)->by($request->user()->id)\n        : Limit::perMinute(10)->by($request->ip());\n});\n```\n\n---\n\n## Stress Testing The Stress function Requests Rate\nStress Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/stress-testing.md\n\n\n### Requests Rate\n\nReturns the number of requests made per second.\n\n```php\n$result->requests()->rate();\n```\n\n---\n\n## Queries Advanced Where Clauses Full Text Where Clauses\nQueries | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queries.md\n\n\n### Full Text Where Clauses\n\n> [!WARNING]\n> Full text where clauses are currently supported by MariaDB, MySQL, and PostgreSQL.\n\nThe `whereFullText` and `orWhereFullText` methods may be used to add full text \"where\" clauses to a query for columns that have [full text indexes](/docs/{{version}}/migrations#available-index-types). These methods will be transformed into the appropriate SQL for the underlying database system by Laravel. For example, a `MATCH AGAINST` clause will be generated for applications utilizing MariaDB or MySQL:\n\n```php\n$users = DB::table('users')\n    ->whereFullText('bio', 'web developer')\n    ->get();\n```\n\n---\n\n## Routing Rate Limiting Attaching Rate Limiters to Routes Throttling With Redis\nRouting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md\n\n\n#### Throttling With Redis\n\nBy default, the `throttle` middleware is mapped to the `Illuminate\\Routing\\Middleware\\ThrottleRequests` class. However, if you are using Redis as your application's cache driver, you may wish to instruct Laravel to use Redis to manage rate limiting. To do so, you should use the `throttleWithRedis` method in your application's `bootstrap/app.php` file. This method maps the `throttle` middleware to the `Illuminate\\Routing\\Middleware\\ThrottleRequestsWithRedis` middleware class:\n\n```php\n->withMiddleware(function (Middleware $middleware): void {\n    $middleware->throttleWithRedis();\n    // ...\n})\n```\n\n---\n\n## Forms Extracting input fields to Blade components\nForms | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/forms\n\n## Extracting input fields to Blade components\n\nEven in a small component such as the `post.create` example we've been discussing, we end up duplicating lots of form field boilerplate like validation messages and labels.\n\nIt can be helpful to extract repetitive UI elements such as these into dedicated [Blade components](https://laravel.com/docs/blade#components) to be shared across your application.\n\nFor example, below is the original Blade template from the `post.create` component. We will be extracting the following two text inputs into dedicated Blade components:\n\n```blade\n<form wire:submit=\"save\">\n    <input type=\"text\" wire:model=\"title\"> <!-- [tl! highlight:3] -->\n    <div>\n        @error('title') <span class=\"error\">{{ $message }}</span> @enderror\n    </div>\n\n    <input type=\"text\" wire:model=\"content\"> <!-- [tl! highlight:3] -->\n    <div>\n        @error('content') <span class=\"error\">{{ $message }}</span> @enderror\n    </div>\n\n    <button type=\"submit\">Save</button>\n</form>\n```\n\nHere's what the template will look like after extracting a re-usable Blade component called `<x-input-text>`:\n\n```blade\n<form wire:submit=\"save\">\n    <x-input-text name=\"title\" wire:model=\"title\" /> <!-- [tl! highlight] -->\n\n    <x-input-text name=\"content\" wire:model=\"content\" /> <!-- [tl! highlight] -->\n\n    <button type=\"submit\">Save</button>\n</form>\n```\n\nNext, here's the source for the `x-input-text` component:\n\n```blade\n<!-- resources/views/components/input-text.blade.php -->\n\n@props(['name'])\n\n<input type=\"text\" name=\"{{ $name }}\" {{ $attributes }}>\n\n<div>\n    @error($name) <span class=\"error\">{{ $message }}</span> @enderror\n</div>\n```\n\nAs you can see, we took the repetitive HTML and placed it inside a dedicated Blade component.\n\nFor the most part, the Blade component contains only the extracted HTML from the original component. However, we have added two things:\n\n* The `@props` directive\n* The `{{ $attributes }}` statement on the input\n\nLet's discuss each of these additions:\n\nBy specifying `name` as a \"prop\" using `@props(['name'])` we are telling Blade: if an attribute called \"name\" is set on this component, take its value and make it available inside this component as `$name`.\n\nFor other attributes that don't have an explicit purpose, we used the `{{ $attributes }}` statement. This is used for \"attribute forwarding\", or in other words, taking any HTML attributes written on the Blade component and forwarding them onto an element within the component.\n\nThis ensures `wire:model=\"title\"` and any other extra attributes such as `disabled`, `class=\"...\"`, or `required` still get forwarded to the actual `<input>` element.\n\n### Custom form controls\n\nIn the previous example, we \"wrapped\" an input element into a re-usable Blade component we can use as if it was a native HTML input element.\n\nThis pattern is very useful; however, there might be some cases where you want to create an entire input component from scratch (without an underlying native input element), but still be able to bind its value to Livewire properties using `wire:model`.\n\nFor example, let's imagine you wanted to create an `<x-input-counter />` component that was a simple \"counter\" input written in Alpine.\n\nBefore we create a Blade component, let's first look at a simple, pure-Alpine, \"counter\" component for reference:\n\n```blade\n<div x-data=\"{ count: 0 }\">\n    <button x-on:click=\"count--\">-</button>\n\n    <span x-text=\"count\"></span>\n\n    <button x-on:click=\"count++\">+</button>\n</div>\n```\n\nAs you can see, the component above shows a number alongside two buttons to increment and decrement that number.\n\nNow, let's imagine we want to extract this component into a Blade component called `<x-input-counter />` that we would use within a component like so:\n\n```blade\n<x-input-counter wire:model=\"quantity\" />\n```\n\nCreating this component is mostly simple. We take the HTML of the counter and place it inside a Blade component template like `resources/views/components/input-counter.blade.php`.\n\nHowever, making it work with `wire:model=\"quantity\"` so that you can easily bind data from your Livewire component to the \"count\" inside this Alpine component needs one extra step.\n\nHere's the source for the component:\n\n```blade\n<!-- resources/view/components/input-counter.blade.php -->\n\n<div x-data=\"{ count: 0 }\" x-modelable=\"count\" {{ $attributes}}>\n    <button x-on:click=\"count--\">-</button>\n\n    <span x-text=\"count\"></span>\n\n    <button x-on:click=\"count++\">+</button>\n</div>\n```\n\nAs you can see, the only different bit about this HTML is the `x-modelable=\"count\"` and `{{ $attributes }}`.\n\n`x-modelable` is a utility in Alpine that tells Alpine to make a certain piece of data available for binding from outside. [The Alpine documentation has more information on this directive.](https://alpinejs.dev/directives/modelable)\n\n`{{ $attributes }}`, as we explored earlier, forwards any attributes passed into the Blade component from outside. In this case, the `wire:model` directive will be forwarded.\n\nBecause of `{{ $attributes }}`, when the HTML is rendered in the browser, `wire:model=\"quantity\"` will be rendered alongside `x-modelable=\"count\"` on the root `<div>` of the Alpine component like so:\n\n```blade\n<div x-data=\"{ count: 0 }\" x-modelable=\"count\" wire:model=\"quantity\">\n```\n\n`x-modelable=\"count\"` tells Alpine to look for any `x-model` or `wire:model` statements and use \"count\" as the data to bind them to.\n\nBecause `x-modelable` works for both `wire:model` and `x-model`, you can also use this Blade component interchangeably with Livewire and Alpine. Here’s an example of using this Blade component in a purely Alpine context:\n\n```blade\n<x-input-counter x-model=\"quantity\" />\n```\n\nCreating custom input elements in your application is extremely powerful but requires a deeper understanding of the utilities Livewire and Alpine provide and how they interact with each other.\n\n_[Truncated]_"}]


rg --files app database routes resources/views tests | sort


sed -n '850,940p' specs/09-IMPLEMENTATION-ROADMAP.md


sed -n '1010,1215p' specs/02-API-ROUTES.md


sed -n '1520,1625p' specs/05-BUSINESS-LOGIC.md


sed -n '1520,1625p' specs/05-BUSINESS-LOGIC.md

- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`
- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
- On failure: set `status = 'failed'` and log the error. Do not delete the original file.
- Maximum 3 retry attempts before permanent failure.

### 15.3 Cleanup

When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).

---

## 16. Search (SQLite FTS5)

### 16.1 Index Setup

Create an FTS5 virtual table named `products_fts` with these columns:

| Column | Indexed | Description |
|---|---|---|
| `store_id` | No (UNINDEXED) | Used for filtering, not searching |
| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |
| `title` | Yes | Product title, fully searchable |
| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |
| `vendor` | Yes | Vendor name |
| `product_type` | Yes | Product type |
| `tags` | Yes | Space-separated tag values |

### 16.2 Index Synchronization

**Observer:** `App\Observers\ProductObserver`

| Model Event | FTS Action |
|---|---|
| `created` | Insert row into products_fts |
| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |
| `deleted` | Delete row from products_fts |

**Data preparation before insert:**
- Strip HTML tags from `description_html` to get plain text.
- Convert JSON tags array to space-separated string.
- Use empty string for null vendor and product_type fields.

### 16.3 Search Query

**Service:** `App\Services\SearchService`

**Search algorithm:**

```
PROCEDURE search(store, queryString, limit = 20):
    1. Sanitize queryString: escape FTS5 special characters
    2. Append '*' to last token for prefix matching (autocomplete)
       Example: "running sh" becomes "running sh*"
    3. Execute FTS5 MATCH query:
       - Filter by store_id
       - Order by FTS5 relevance rank
       - Limit results
    4. Load matching Product models
    5. Exclude products with status != 'active' or published_at IS NULL
    6. RETURN ordered collection
```

### 16.4 Search Logging

Each search query is logged to `search_queries` with: store_id, query text, results_count, created_at. This data feeds into analytics and can inform autocomplete suggestions.

---

## 17. Event and Listener Registry

Summary of all domain events and their listeners/side effects:

| Event | Listeners / Side Effects |
|---|---|
| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
| `CheckoutAddressed` | Recalculate pricing |
| `CheckoutShippingSelected` | Recalculate pricing |
| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
| `CheckoutExpired` | Release reserved inventory |
| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |
| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |
| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |
| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |
| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |
| `FulfillmentCreated` | Update order fulfillment status |
| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |
| `OrderCancelled` | Release reserved inventory, send cancellation email |

All events should be placed in `App\Events`. All listeners in `App\Listeners`.

---

## 18. Scheduled Jobs Registry

| Job Class | Schedule | Description |
|---|---|---|
| `App\Jobs\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |
| `App\Jobs\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |
| `App\Jobs\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |
| `App\Jobs\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |

**Queued jobs (dispatched on demand, not scheduled):**

| Job Class | Description |


sed -n '1010,1215p' specs/02-API-ROUTES.md

**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,
  "price_min": 1000,
  "price_max": 5000,
  "in_stock": true,
  "tags": ["organic", "cotton"],
  "vendor": "Acme Apparel"
}
```

**Success response:** `200 OK`

```json
{
  "query": "t-shirt",
  "results": [
    {
      "id": 10,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "image_url": "https://cdn.example.com/products/tshirt.jpg",
      "in_stock": true,
      "tags": ["organic", "cotton"]
    }
  ],
  "facets": {
    "vendors": [
      { "value": "Acme Apparel", "count": 12 }
    ],
    "tags": [
      { "value": "organic", "count": 8 },
      { "value": "cotton", "count": 15 }
    ],
    "price_range": {
      "min": 1500,
      "max": 8500
    }
  },
  "pagination": {
    "current_page": 1,
    "per_page": 24,
    "total": 42,
    "last_page": 2
  }
}
```

**Error responses:**
- `400` - Query too long (max 200 characters) or malformed filters
- `422` - Validation error

---

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

Autocomplete suggestions as the user types.

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

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |
| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |

**Success response:** `200 OK`

```json
{
  "query": "t-sh",
  "suggestions": [
    {
      "type": "product",
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "image_url": "https://cdn.example.com/products/tshirt-thumb.jpg",
      "price_amount": 2500,
      "currency": "EUR"
    },
    {
      "type": "collection",
      "title": "T-Shirts",
      "handle": "t-shirts",
      "image_url": null
    }
  ]
}
```

**Error responses:**
- `400` - Query too long
- `422` - Validation error

---

### 2.6 Analytics Endpoints

#### POST /api/storefront/v1/analytics/events

Batch submit storefront analytics events.

- **Authentication:** None
- **Rate limit:** `analytics` (60/min per IP)

**Request body:**

```json
{
  "events": [
    {
      "type": "page_view",
      "session_id": "sess_abc123",
      "client_event_id": "evt_001",
      "properties": {
        "url": "/products/classic-t-shirt",
        "referrer": "https://google.com",
        "user_agent": "Mozilla/5.0..."
      },
      "occurred_at": "2026-02-11T10:00:00Z"
    },
    {
      "type": "add_to_cart",
      "session_id": "sess_abc123",
      "client_event_id": "evt_002",
      "properties": {
        "product_id": 10,
        "variant_id": 101,
        "quantity": 1,
        "price_amount": 2500,
        "currency": "EUR"
      },
      "occurred_at": "2026-02-11T10:01:00Z"
    }
  ]
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `events` | array | Yes | Min: 1, max: 50 events per batch. |
| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
| `events[].session_id` | string | Yes | Max: 100 characters. |
| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |
| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |
| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |

**Success response:** `202 Accepted`

```json
{
  "accepted": 2,
  "rejected": 0
}
```

**Error responses:**
- `422` - Validation errors (invalid event types, too many events, etc.)

---

## 3. Admin REST API

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

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.


sed -n '850,940p' specs/09-IMPLEMENTATION-ROADMAP.md

| `Admin\Apps\Index` | App directory |
| `Admin\Apps\Show` | Installed app detail |
| `Admin\Developers\Index` | API token management, webhook subscriptions |

---

### Phase 8: Search

**Priority:** LOW - enhances product discovery.

#### Step 8.1: Migrations

1. `create_search_settings_table` - PK: `store_id`
2. `create_search_queries_table` - FK: `store_id`
3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table

#### Step 8.2: Search Service

**File to create:** `App\Services\SearchService`

| Method | Description |
|--------|-------------|
| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |
| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |
| `syncProduct(Product $product): void` | Upsert into FTS5 index |
| `removeProduct(int $productId): void` | Delete from FTS5 index |

**File to create:** `App\Observers\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.

#### Step 8.3: Search UI

**Files to create:**

| Component | Description |
|-----------|-------------|
| `Storefront\Search\Modal` | Search-as-you-type modal with autocomplete results |
| `Storefront\Search\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |

---

### Phase 9: Analytics

**Priority:** LOW - reporting and insights.

#### Step 9.1: Migrations

1. `create_analytics_events_table` - FK: `store_id`, `customer_id`
2. `create_analytics_daily_table` - composite PK: `store_id` + `date`

#### Step 9.2: Analytics Service

**File to create:** `App\Services\AnalyticsService`

| Method | Description |
|--------|-------------|
| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |
| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |

Event types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.

**File to create:** `App\Jobs\AggregateAnalytics`

Requirements:
- Runs daily via `routes/console.php`
- Aggregates raw events into `analytics_daily` rows
- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count

---

### Phase 10: Apps and Webhooks

**Priority:** LOW - extensibility.

#### Step 10.1: Migrations

1. `create_apps_table`
2. `create_app_installations_table` - FK: `store_id`, `app_id`
3. `create_oauth_clients_table` - FK: `app_id`
4. `create_oauth_tokens_table` - FK: `installation_id`
5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`
6. `create_webhook_deliveries_table` - FK: `subscription_id`

#### Step 10.2: Webhook Service

**File to create:** `App\Services\WebhookService`

| Method | Description |
|--------|-------------|
| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |
| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |
| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |


rg --files app database routes resources/views tests | sort

app/Actions/Fortify/CreateNewUser.php
app/Actions/Fortify/ResetUserPassword.php
app/Auth/CustomerUserProvider.php
app/Concerns/PasswordValidationRules.php
app/Concerns/ProfileValidationRules.php
app/Contracts/PaymentProvider.php
app/Contracts/TaxProvider.php
app/Enums/CartStatus.php
app/Enums/CheckoutStatus.php
app/Enums/CollectionStatus.php
app/Enums/CollectionType.php
app/Enums/DiscountStatus.php
app/Enums/DiscountType.php
app/Enums/DiscountValueType.php
app/Enums/FinancialStatus.php
app/Enums/FulfillmentShipmentStatus.php
app/Enums/FulfillmentStatus.php
app/Enums/InventoryPolicy.php
app/Enums/MediaStatus.php
app/Enums/MediaType.php
app/Enums/NavigationItemType.php
app/Enums/OrderStatus.php
app/Enums/PageStatus.php
app/Enums/PaymentMethod.php
app/Enums/PaymentStatus.php
app/Enums/ProductStatus.php
app/Enums/RefundStatus.php
app/Enums/ShippingRateType.php
app/Enums/StoreDomainType.php
app/Enums/StoreStatus.php
app/Enums/StoreUserRole.php
app/Enums/TaxMode.php
app/Enums/ThemeStatus.php
app/Enums/VariantStatus.php
app/Events/FulfillmentCreated.php
app/Events/FulfillmentDelivered.php
app/Events/FulfillmentShipped.php
app/Events/OrderCancelled.php
app/Events/OrderCreated.php
app/Events/OrderPaid.php
app/Events/OrderRefunded.php
app/Events/ProductStatusChanged.php
app/Exceptions/CartVersionMismatchException.php
app/Exceptions/InsufficientInventoryException.php
app/Exceptions/InvalidCartOperationException.php
app/Exceptions/InvalidCheckoutTransitionException.php
app/Exceptions/InvalidDiscountException.php
app/Exceptions/InvalidFulfillmentOperationException.php
app/Exceptions/InvalidOrderOperationException.php
app/Exceptions/InvalidProductTransitionException.php
app/Exceptions/InvalidRefundOperationException.php
app/Exceptions/PaymentFailedException.php
app/Exceptions/UnserviceableShippingAddressException.php
app/Http/Controllers/Api/Admin/V1/OrderController.php
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php
app/Http/Controllers/Api/Storefront/V1/CartController.php
app/Http/Controllers/Api/Storefront/V1/CartLineController.php
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php
app/Http/Controllers/Api/Storefront/V1/OrderController.php
app/Http/Controllers/Controller.php
app/Http/Middleware/CheckStoreRole.php
app/Http/Middleware/EnsureUserEmailIsVerified.php
app/Http/Middleware/ResolveStore.php
app/Http/Requests/Api/Admin/V1/CreateOrderFulfillmentRequest.php
app/Http/Requests/Api/Admin/V1/CreateOrderRefundRequest.php
app/Http/Requests/Api/Storefront/V1/ApplyCheckoutDiscountRequest.php
app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php
app/Http/Requests/Api/Storefront/V1/DestroyCartLineRequest.php
app/Http/Requests/Api/Storefront/V1/SelectCheckoutPaymentRequest.php
app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php
app/Http/Requests/Api/Storefront/V1/SetCheckoutShippingRequest.php
app/Http/Requests/Api/Storefront/V1/StoreCartLineRequest.php
app/Http/Requests/Api/Storefront/V1/StoreCartRequest.php
app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php
app/Http/Requests/Api/Storefront/V1/UpdateCartLineRequest.php
app/Http/Resources/Admin/V1/FulfillmentResource.php
app/Http/Resources/Admin/V1/OrderResource.php
app/Http/Resources/Admin/V1/RefundResource.php
app/Http/Resources/Storefront/V1/CartLineResource.php
app/Http/Resources/Storefront/V1/CartResource.php
app/Http/Resources/Storefront/V1/CheckoutResource.php
app/Http/Resources/Storefront/V1/OrderResource.php
app/Http/Resources/Storefront/V1/ShippingRateResource.php
app/Jobs/CancelUnpaidBankTransferOrders.php
app/Jobs/CleanupAbandonedCarts.php
app/Jobs/ExpireAbandonedCheckouts.php
app/Jobs/ProcessMediaUpload.php
app/Livewire/Actions/Logout.php
app/Livewire/Admin/Auth/Login.php
app/Livewire/Admin/Collections/Form.php
app/Livewire/Admin/Collections/Index.php
app/Livewire/Admin/Customers/Index.php
app/Livewire/Admin/Customers/Show.php
app/Livewire/Admin/Dashboard.php
app/Livewire/Admin/Discounts/Form.php
app/Livewire/Admin/Discounts/Index.php
app/Livewire/Admin/Inventory/Index.php
app/Livewire/Admin/Navigation/Index.php
app/Livewire/Admin/Orders/Index.php
app/Livewire/Admin/Orders/Show.php
app/Livewire/Admin/Pages/Form.php
app/Livewire/Admin/Pages/Index.php
app/Livewire/Admin/Products/Form.php
app/Livewire/Admin/Products/Index.php
app/Livewire/Admin/Settings/Index.php
app/Livewire/Admin/Settings/Shipping.php
app/Livewire/Admin/Settings/Taxes.php
app/Livewire/Admin/Themes/Editor.php
app/Livewire/Admin/Themes/Index.php
app/Livewire/Settings/Appearance.php
app/Livewire/Settings/DeleteUserForm.php
app/Livewire/Settings/Password.php
app/Livewire/Settings/Profile.php
app/Livewire/Settings/TwoFactor.php
app/Livewire/Settings/TwoFactor/RecoveryCodes.php
app/Livewire/Storefront/Account/Auth/Login.php
app/Livewire/Storefront/Account/Auth/Register.php
app/Livewire/Storefront/Account/Orders/Index.php
app/Livewire/Storefront/Account/Orders/Show.php
app/Li…9216 chars truncated…s/auth/simple.blade.php
resources/views/layouts/auth/split.blade.php
resources/views/layouts/storefront.blade.php
resources/views/livewire/admin/auth/login.blade.php
resources/views/livewire/admin/collections/form.blade.php
resources/views/livewire/admin/collections/index.blade.php
resources/views/livewire/admin/customers/index.blade.php
resources/views/livewire/admin/customers/show.blade.php
resources/views/livewire/admin/dashboard.blade.php
resources/views/livewire/admin/discounts/form.blade.php
resources/views/livewire/admin/discounts/index.blade.php
resources/views/livewire/admin/inventory/index.blade.php
resources/views/livewire/admin/navigation/index.blade.php
resources/views/livewire/admin/orders/index.blade.php
resources/views/livewire/admin/orders/show.blade.php
resources/views/livewire/admin/pages/form.blade.php
resources/views/livewire/admin/pages/index.blade.php
resources/views/livewire/admin/products/form.blade.php
resources/views/livewire/admin/products/index.blade.php
resources/views/livewire/admin/settings/index.blade.php
resources/views/livewire/admin/settings/shipping.blade.php
resources/views/livewire/admin/settings/taxes.blade.php
resources/views/livewire/admin/themes/editor.blade.php
resources/views/livewire/admin/themes/index.blade.php
resources/views/livewire/auth/confirm-password.blade.php
resources/views/livewire/auth/forgot-password.blade.php
resources/views/livewire/auth/login.blade.php
resources/views/livewire/auth/register.blade.php
resources/views/livewire/auth/reset-password.blade.php
resources/views/livewire/auth/two-factor-challenge.blade.php
resources/views/livewire/auth/verify-email.blade.php
resources/views/livewire/settings/appearance.blade.php
resources/views/livewire/settings/delete-user-form.blade.php
resources/views/livewire/settings/password.blade.php
resources/views/livewire/settings/profile.blade.php
resources/views/livewire/settings/two-factor.blade.php
resources/views/livewire/settings/two-factor/recovery-codes.blade.php
resources/views/livewire/storefront/account/auth/login.blade.php
resources/views/livewire/storefront/account/auth/register.blade.php
resources/views/livewire/storefront/account/orders/index.blade.php
resources/views/livewire/storefront/account/orders/show.blade.php
resources/views/livewire/storefront/cart-drawer.blade.php
resources/views/livewire/storefront/cart/show.blade.php
resources/views/livewire/storefront/checkout/confirmation.blade.php
resources/views/livewire/storefront/checkout/show.blade.php
resources/views/livewire/storefront/collections/index.blade.php
resources/views/livewire/storefront/collections/show.blade.php
resources/views/livewire/storefront/home.blade.php
resources/views/livewire/storefront/pages/show.blade.php
resources/views/livewire/storefront/products/show.blade.php
resources/views/livewire/storefront/search/index.blade.php
resources/views/partials/head.blade.php
resources/views/partials/settings-heading.blade.php
resources/views/storefront/account/dashboard.blade.php
resources/views/welcome.blade.php
routes/api.php
routes/console.php
routes/settings.php
routes/web.php
tests/Feature/Admin/ContentManagementTest.php
tests/Feature/Admin/CustomerManagementTest.php
tests/Feature/Admin/DashboardTest.php
tests/Feature/Admin/DiscountManagementTest.php
tests/Feature/Admin/OrderManagementTest.php
tests/Feature/Admin/SettingsManagementTest.php
tests/Feature/Api/AdminOrderApiTest.php
tests/Feature/Api/StorefrontCartApiTest.php
tests/Feature/Api/StorefrontCheckoutApiTest.php
tests/Feature/Api/StorefrontOrderApiTest.php
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Auth/EmailVerificationTest.php
tests/Feature/Auth/PasswordConfirmationTest.php
tests/Feature/Auth/PasswordResetTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/TwoFactorChallengeTest.php
tests/Feature/Cart/CartServiceTest.php
tests/Feature/Catalog/CatalogFoundationTest.php
tests/Feature/Catalog/CatalogUiTest.php
tests/Feature/Catalog/InventoryServiceTest.php
tests/Feature/Catalog/MediaProcessingTest.php
tests/Feature/Catalog/ProductServiceTest.php
tests/Feature/Catalog/VariantMatrixServiceTest.php
tests/Feature/Checkout/CheckoutServiceTest.php
tests/Feature/Checkout/PricingServicesTest.php
tests/Feature/DashboardTest.php
tests/Feature/ExampleTest.php
tests/Feature/Foundation/AuthorizationTest.php
tests/Feature/Foundation/CustomerAuthTest.php
tests/Feature/Foundation/TenancyTest.php
tests/Feature/Orders/BankTransferOrderTest.php
tests/Feature/Orders/FulfillmentServiceTest.php
tests/Feature/Orders/OrderServiceTest.php
tests/Feature/Orders/RefundServiceTest.php
tests/Feature/Payments/MockPaymentProviderTest.php
tests/Feature/Settings/PasswordUpdateTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Settings/TwoFactorAuthenticationTest.php
tests/Feature/Storefront/CartCheckoutUiTest.php
tests/Feature/Storefront/OrderViewsTest.php
tests/Feature/Storefront/ThemeDataTest.php
tests/Pest.php
tests/TestCase.php
tests/Unit/ExampleTest.php


Output:
Chunk ID: f289e4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4804
Output:
app/Actions/Fortify/CreateNewUser.php
app/Actions/Fortify/ResetUserPassword.php
app/Auth/CustomerUserProvider.php
app/Concerns/PasswordValidationRules.php
app/Concerns/ProfileValidationRules.php
app/Contracts/PaymentProvider.php
app/Contracts/TaxProvider.php
app/Enums/CartStatus.php
app/Enums/CheckoutStatus.php
app/Enums/CollectionStatus.php
app/Enums/CollectionType.php
app/Enums/DiscountStatus.php
app/Enums/DiscountType.php
app/Enums/DiscountValueType.php
app/Enums/FinancialStatus.php
app/Enums/FulfillmentShipmentStatus.php
app/Enums/FulfillmentStatus.php
app/Enums/InventoryPolicy.php
app/Enums/MediaStatus.php
app/Enums/MediaType.php
app/Enums/NavigationItemType.php
app/Enums/OrderStatus.php
app/Enums/PageStatus.php
app/Enums/PaymentMethod.php
app/Enums/PaymentStatus.php
app/Enums/ProductStatus.php
app/Enums/RefundStatus.php
app/Enums/ShippingRateType.php
app/Enums/StoreDomainType.php
app/Enums/StoreStatus.php
app/Enums/StoreUserRole.php
app/Enums/TaxMode.php
app/Enums/ThemeStatus.php
app/Enums/VariantStatus.php
app/Events/FulfillmentCreated.php
app/Events/FulfillmentDelivered.php
app/Events/FulfillmentShipped.php
app/Events/OrderCancelled.php
app/Events/OrderCreated.php
app/Events/OrderPaid.php
app/Events/OrderRefunded.php
app/Events/ProductStatusChanged.php
app/Exceptions/CartVersionMismatchException.php
app/Exceptions/InsufficientInventoryException.php
app/Exceptions/InvalidCartOperationException.php
app/Exceptions/InvalidCheckoutTransitionException.php
app/Exceptions/InvalidDiscountException.php
app/Exceptions/InvalidFulfillmentOperationException.php
app/Exceptions/InvalidOrderOperationException.php
app/Exceptions/InvalidProductTransitionException.php
app/Exceptions/InvalidRefundOperationException.php
app/Exceptions/PaymentFailedException.php
app/Exceptions/UnserviceableShippingAddressException.php
app/Http/Controllers/Api/Admin/V1/OrderController.php
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php
app/Http/Controllers/Api/Storefront/V1/CartController.php
app/Http/Controllers/Api/Storefront/V1/CartLineController.php
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php
app/Http/Controllers/Api/Storefront/V1/OrderController.php
app/Http/Controllers/Controller.php
app/Http/Middleware/CheckStoreRole.php
app/Http/Middleware/EnsureUserEmailIsVerified.php
app/Http/Middleware/ResolveStore.php
app/Http/Requests/Api/Admin/V1/CreateOrderFulfillmentRequest.php
app/Http/Requests/Api/Admin/V1/CreateOrderRefundRequest.php
app/Http/Requests/Api/Storefront/V1/ApplyCheckoutDiscountRequest.php
app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php
app/Http/Requests/Api/Storefront/V1/DestroyCartLineRequest.php
app/Http/Requests/Api/Storefront/V1/SelectCheckoutPaymentRequest.php
app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php
app/Http/Requests/Api/Storefront/V1/SetCheckoutShippingRequest.php
app/Http/Requests/Api/Storefront/V1/StoreCartLineRequest.php
app/Http/Requests/Api/Storefront/V1/StoreCartRequest.php
app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php
app/Http/Requests/Api/Storefront/V1/UpdateCartLineRequest.php
app/Http/Resources/Admin/V1/FulfillmentResource.php
app/Http/Resources/Admin/V1/OrderResource.php
app/Http/Resources/Admin/V1/RefundResource.php
app/Http/Resources/Storefront/V1/CartLineResource.php
app/Http/Resources/Storefront/V1/CartResource.php
app/Http/Resources/Storefront/V1/CheckoutResource.php
app/Http/Resources/Storefront/V1/OrderResource.php
app/Http/Resources/Storefront/V1/ShippingRateResource.php
app/Jobs/CancelUnpaidBankTransferOrders.php
app/Jobs/CleanupAbandonedCarts.php
app/Jobs/ExpireAbandonedCheckouts.php
app/Jobs/ProcessMediaUpload.php
app/Livewire/Actions/Logout.php
app/Livewire/Admin/Auth/Login.php
app/Livewire/Admin/Collections/Form.php
app/Livewire/Admin/Collections/Index.php
app/Livewire/Admin/Customers/Index.php
app/Livewire/Admin/Customers/Show.php
app/Livewire/Admin/Dashboard.php
app/Livewire/Admin/Discounts/Form.php
app/Livewire/Admin/Discounts/Index.php
app/Livewire/Admin/Inventory/Index.php
app/Livewire/Admin/Navigation/Index.php
app/Livewire/Admin/Orders/Index.php
app/Livewire/Admin/Orders/Show.php
app/Livewire/Admin/Pages/Form.php
app/Livewire/Admin/Pages/Index.php
app/Livewire/Admin/Products/Form.php
app/Livewire/Admin/Products/Index.php
app/Livewire/Admin/Settings/Index.php
app/Livewire/Admin/Settings/Shipping.php
app/Livewire/Admin/Settings/Taxes.php
app/Livewire/Admin/Themes/Editor.php
app/Livewire/Admin/Themes/Index.php
app/Livewire/Settings/Appearance.php
app/Livewire/Settings/DeleteUserForm.php
app/Livewire/Settings/Password.php
app/Livewire/Settings/Profile.php
app/Livewire/Settings/TwoFactor.php
app/Livewire/Settings/TwoFactor/RecoveryCodes.php
app/Livewire/Storefront/Account/Auth/Login.php
app/Livewire/Storefront/Account/Auth/Register.php
app/Livewire/Storefront/Account/Orders/Index.php
app/Livewire/Storefront/Account/Orders/Show.php
app/Livewire/Storefront/Cart/Show.php
app/Livewire/Storefront/CartDrawer.php
app/Livewire/Storefront/Checkout/Confirmation.php
app/Livewire/Storefront/Checkout/Show.php
app/Livewire/Storefront/Collections/Index.php
app/Livewire/Storefront/Collections/Show.php
app/Livewire/Storefront/Home.php
app/Livewire/Storefront/Pages/Show.php
app/Livewire/Storefront/Products/Show.php
app/Livewire/Storefront/Search/Index.php
app/Models/Cart.php
app/Models/CartLine.php
app/Models/Checkout.php
app/Models/Collection.php
app/Models/Concerns/BelongsToStore.php
app/Models/Customer.php
app/Models/CustomerAddress.php
app/Models/Discount.php
app/Models/Fulfillment.php
app/Models/FulfillmentLine.php
app/Models/InventoryItem.php
app/Models/NavigationItem.php
app/Models/NavigationMenu.php
app/Models/Order.php
app/Models/OrderLine.php
app/Models/Organization.php
app/Models/Page.php
app/Models/Payment.php
app/Models/Product.php
app/Models/ProductMedia.php
app/Models/ProductOption.php
app/Models/ProductOptionValue.php
app/Models/ProductVariant.php
app/Models/Refund.php
app/Models/Scopes/StoreScope.php
app/Models/ShippingRate.php
app/Models/ShippingZone.php
app/Models/Store.php
app/Models/StoreDomain.php
app/Models/StoreSettings.php
app/Models/StoreUser.php
app/Models/TaxSettings.php
app/Models/Theme.php
app/Models/ThemeFile.php
app/Models/ThemeSettings.php
app/Models/User.php
app/Policies/CollectionPolicy.php
app/Policies/CustomerPolicy.php
app/Policies/DiscountPolicy.php
app/Policies/FulfillmentPolicy.php
app/Policies/OrderPolicy.php
app/Policies/PagePolicy.php
app/Policies/ProductPolicy.php
app/Policies/RefundPolicy.php
app/Policies/StorePolicy.php
app/Policies/ThemePolicy.php
app/Providers/AppServiceProvider.php
app/Providers/FortifyServiceProvider.php
app/Services/CartService.php
app/Services/CheckoutService.php
app/Services/DiscountService.php
app/Services/FulfillmentService.php
app/Services/InventoryService.php
app/Services/NavigationService.php
app/Services/OrderService.php
app/Services/PaymentService.php
app/Services/Payments/MockPaymentProvider.php
app/Services/PricingEngine.php
app/Services/ProductService.php
app/Services/RefundService.php
app/Services/ShippingCalculator.php
app/Services/Tax/ManualTaxProvider.php
app/Services/Tax/StripeTaxProvider.php
app/Services/TaxCalculator.php
app/Services/ThemeSettingsService.php
app/Services/VariantMatrixService.php
app/Support/HandleGenerator.php
app/Support/Money.php
app/Support/OrderAccessToken.php
app/Traits/ChecksStoreRole.php
app/ValueObjects/DiscountResult.php
app/ValueObjects/PaymentResult.php
app/ValueObjects/PricingResult.php
app/ValueObjects/RefundResult.php
app/ValueObjects/TaxLine.php
app/ValueObjects/TaxResult.php
database/factories/CartFactory.php
database/factories/CartLineFactory.php
database/factories/CheckoutFactory.php
database/factories/CollectionFactory.php
database/factories/CustomerAddressFactory.php
database/factories/CustomerFactory.php
database/factories/DiscountFactory.php
database/factories/FulfillmentFactory.php
database/factories/FulfillmentLineFactory.php
database/factories/InventoryItemFactory.php
database/factories/NavigationItemFactory.php
database/factories/NavigationMenuFactory.php
database/factories/OrderFactory.php
database/factories/OrderLineFactory.php
database/factories/OrganizationFactory.php
database/factories/PageFactory.php
database/factories/PaymentFactory.php
database/factories/ProductFactory.php
database/factories/ProductMediaFactory.php
database/factories/ProductOptionFactory.php
database/factories/ProductOptionValueFactory.php
database/factories/ProductVariantFactory.php
database/factories/RefundFactory.php
database/factories/ShippingRateFactory.php
database/factories/ShippingZoneFactory.php
database/factories/StoreDomainFactory.php
database/factories/StoreFactory.php
database/factories/StoreSettingsFactory.php
database/factories/TaxSettingsFactory.php
database/factories/ThemeFactory.php
database/factories/ThemeFileFactory.php
database/factories/ThemeSettingsFactory.php
database/factories/UserFactory.php
database/migrations/0001_01_01_000000_create_users_table.php
database/migrations/0001_01_01_000001_create_cache_table.php
database/migrations/0001_01_01_000002_create_jobs_table.php
database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php
database/migrations/2026_05_03_211409_create_organizations_table.php
database/migrations/2026_05_03_211413_create_stores_table.php
database/migrations/2026_05_03_211416_create_store_domains_table.php
database/migrations/2026_05_03_211419_create_store_settings_table.php
database/migrations/2026_05_03_211425_create_store_users_table.php
database/migrations/2026_05_03_211556_create_customers_table.php
database/migrations/2026_05_03_212158_create_customer_password_reset_tokens_table.php
database/migrations/2026_05_03_213856_create_products_table.php
database/migrations/2026_05_03_213857_create_product_options_table.php
database/migrations/2026_05_03_213858_create_product_option_values_table.php
database/migrations/2026_05_03_213859_create_product_variants_table.php
database/migrations/2026_05_03_213900_create_variant_option_values_table.php
database/migrations/2026_05_03_213901_create_inventory_items_table.php
database/migrations/2026_05_03_213902_create_collections_table.php
database/migrations/2026_05_03_213903_create_collection_products_table.php
database/migrations/2026_05_03_213904_create_product_media_table.php
database/migrations/2026_05_03_223824_create_themes_table.php
database/migrations/2026_05_03_223830_create_theme_files_table.php
database/migrations/2026_05_03_223837_create_theme_settings_table.php
database/migrations/2026_05_03_223841_create_pages_table.php
database/migrations/2026_05_03_223844_create_navigation_menus_table.php
database/migrations/2026_05_03_223848_create_navigation_items_table.php
database/migrations/2026_05_03_225400_create_carts_table.php
database/migrations/2026_05_03_225405_create_cart_lines_table.php
database/migrations/2026_05_03_225409_create_checkouts_table.php
database/migrations/2026_05_03_225413_create_shipping_zones_table.php
database/migrations/2026_05_03_225418_create_shipping_rates_table.php
database/migrations/2026_05_03_225422_create_tax_settings_table.php
database/migrations/2026_05_03_225427_create_discounts_table.php
database/migrations/2026_05_04_000408_create_customer_addresses_table.php
database/migrations/2026_05_04_000408_create_orders_table.php
database/migrations/2026_05_04_000410_create_order_lines_table.php
database/migrations/2026_05_04_000411_create_payments_table.php
database/migrations/2026_05_04_000412_create_refunds_table.php
database/migrations/2026_05_04_000413_create_fulfillments_table.php
database/migrations/2026_05_04_000414_create_fulfillment_lines_table.php
database/seeders/CartLineSeeder.php
database/seeders/CartSeeder.php
database/seeders/CheckoutSeeder.php
database/seeders/CollectionSeeder.php
database/seeders/CustomerAddressSeeder.php
database/seeders/CustomerSeeder.php
database/seeders/DatabaseSeeder.php
database/seeders/DiscountSeeder.php
database/seeders/FulfillmentLineSeeder.php
database/seeders/FulfillmentSeeder.php
database/seeders/InventoryItemSeeder.php
database/seeders/NavigationItemSeeder.php
database/seeders/NavigationMenuSeeder.php
database/seeders/OrderLineSeeder.php
database/seeders/OrderSeeder.php
database/seeders/OrganizationSeeder.php
database/seeders/PageSeeder.php
database/seeders/PaymentSeeder.php
database/seeders/ProductMediaSeeder.php
database/seeders/ProductOptionSeeder.php
database/seeders/ProductOptionValueSeeder.php
database/seeders/ProductSeeder.php
database/seeders/ProductVariantSeeder.php
database/seeders/RefundSeeder.php
database/seeders/ShippingRateSeeder.php
database/seeders/ShippingZoneSeeder.php
database/seeders/StoreDomainSeeder.php
database/seeders/StoreSeeder.php
database/seeders/StoreSettingsSeeder.php
database/seeders/StoreUserSeeder.php
database/seeders/TaxSettingsSeeder.php
database/seeders/ThemeFileSeeder.php
database/seeders/ThemeSeeder.php
database/seeders/ThemeSettingsSeeder.php
database/seeders/UserSeeder.php
resources/views/components/action-message.blade.php
resources/views/components/app-logo-icon.blade.php
resources/views/components/app-logo.blade.php
resources/views/components/auth-header.blade.php
resources/views/components/auth-session-status.blade.php
resources/views/components/desktop-user-menu.blade.php
resources/views/components/placeholder-pattern.blade.php
resources/views/components/settings/layout.blade.php
resources/views/components/storefront/breadcrumbs.blade.php
resources/views/components/storefront/price.blade.php
resources/views/components/storefront/product-card.blade.php
resources/views/dashboard.blade.php
resources/views/flux/icon/book-open-text.blade.php
resources/views/flux/icon/chevrons-up-down.blade.php
resources/views/flux/icon/folder-git-2.blade.php
resources/views/flux/icon/layout-grid.blade.php
resources/views/flux/navlist/group.blade.php
resources/views/layouts/app.blade.php
resources/views/layouts/app/header.blade.php
resources/views/layouts/app/sidebar.blade.php
resources/views/layouts/auth.blade.php
resources/views/layouts/auth/card.blade.php
resources/views/layouts/auth/simple.blade.php
resources/views/layouts/auth/split.blade.php
resources/views/layouts/storefront.blade.php
resources/views/livewire/admin/auth/login.blade.php
resources/views/livewire/admin/collections/form.blade.php
resources/views/livewire/admin/collections/index.blade.php
resources/views/livewire/admin/customers/index.blade.php
resources/views/livewire/admin/customers/show.blade.php
resources/views/livewire/admin/dashboard.blade.php
resources/views/livewire/admin/discounts/form.blade.php
resources/views/livewire/admin/discounts/index.blade.php
resources/views/livewire/admin/inventory/index.blade.php
resources/views/livewire/admin/navigation/index.blade.php
resources/views/livewire/admin/orders/index.blade.php
resources/views/livewire/admin/orders/show.blade.php
resources/views/livewire/admin/pages/form.blade.php
resources/views/livewire/admin/pages/index.blade.php
resources/views/livewire/admin/products/form.blade.php
resources/views/livewire/admin/products/index.blade.php
resources/views/livewire/admin/settings/index.blade.php
resources/views/livewire/admin/settings/shipping.blade.php
resources/views/livewire/admin/settings/taxes.blade.php
resources/views/livewire/admin/themes/editor.blade.php
resources/views/livewire/admin/themes/index.blade.php
resources/views/livewire/auth/confirm-password.blade.php
resources/views/livewire/auth/forgot-password.blade.php
resources/views/livewire/auth/login.blade.php
resources/views/livewire/auth/register.blade.php
resources/views/livewire/auth/reset-password.blade.php
resources/views/livewire/auth/two-factor-challenge.blade.php
resources/views/livewire/auth/verify-email.blade.php
resources/views/livewire/settings/appearance.blade.php
resources/views/livewire/settings/delete-user-form.blade.php
resources/views/livewire/settings/password.blade.php
resources/views/livewire/settings/profile.blade.php
resources/views/livewire/settings/two-factor.blade.php
resources/views/livewire/settings/two-factor/recovery-codes.blade.php
resources/views/livewire/storefront/account/auth/login.blade.php
resources/views/livewire/storefront/account/auth/register.blade.php
resources/views/livewire/storefront/account/orders/index.blade.php
resources/views/livewire/storefront/account/orders/show.blade.php
resources/views/livewire/storefront/cart-drawer.blade.php
resources/views/livewire/storefront/cart/show.blade.php
resources/views/livewire/storefront/checkout/confirmation.blade.php
resources/views/livewire/storefront/checkout/show.blade.php
resources/views/livewire/storefront/collections/index.blade.php
resources/views/livewire/storefront/collections/show.blade.php
resources/views/livewire/storefront/home.blade.php
resources/views/livewire/storefront/pages/show.blade.php
resources/views/livewire/storefront/products/show.blade.php
resources/views/livewire/storefront/search/index.blade.php
resources/views/partials/head.blade.php
resources/views/partials/settings-heading.blade.php
resources/views/storefront/account/dashboard.blade.php
resources/views/welcome.blade.php
routes/api.php
routes/console.php
routes/settings.php
routes/web.php
tests/Feature/Admin/ContentManagementTest.php
tests/Feature/Admin/CustomerManagementTest.php
tests/Feature/Admin/DashboardTest.php
tests/Feature/Admin/DiscountManagementTest.php
tests/Feature/Admin/OrderManagementTest.php
tests/Feature/Admin/SettingsManagementTest.php
tests/Feature/Api/AdminOrderApiTest.php
tests/Feature/Api/StorefrontCartApiTest.php
tests/Feature/Api/StorefrontCheckoutApiTest.php
tests/Feature/Api/StorefrontOrderApiTest.php
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Auth/EmailVerificationTest.php
tests/Feature/Auth/PasswordConfirmationTest.php
tests/Feature/Auth/PasswordResetTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/TwoFactorChallengeTest.php
tests/Feature/Cart/CartServiceTest.php
tests/Feature/Catalog/CatalogFoundationTest.php
tests/Feature/Catalog/CatalogUiTest.php
tests/Feature/Catalog/InventoryServiceTest.php
tests/Feature/Catalog/MediaProcessingTest.php
tests/Feature/Catalog/ProductServiceTest.php
tests/Feature/Catalog/VariantMatrixServiceTest.php
tests/Feature/Checkout/CheckoutServiceTest.php
tests/Feature/Checkout/PricingServicesTest.php
tests/Feature/DashboardTest.php
tests/Feature/ExampleTest.php
tests/Feature/Foundation/AuthorizationTest.php
tests/Feature/Foundation/CustomerAuthTest.php
tests/Feature/Foundation/TenancyTest.php
tests/Feature/Orders/BankTransferOrderTest.php
tests/Feature/Orders/FulfillmentServiceTest.php
tests/Feature/Orders/OrderServiceTest.php
tests/Feature/Orders/RefundServiceTest.php
tests/Feature/Payments/MockPaymentProviderTest.php
tests/Feature/Settings/PasswordUpdateTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Settings/TwoFactorAuthenticationTest.php
tests/Feature/Storefront/CartCheckoutUiTest.php
tests/Feature/Storefront/OrderViewsTest.php
tests/Feature/Storefront/ThemeDataTest.php
tests/Pest.php
tests/TestCase.php
tests/Unit/ExampleTest.php


Output:
Chunk ID: 55f846
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 857
Output:
| `Admin\Apps\Index` | App directory |
| `Admin\Apps\Show` | Installed app detail |
| `Admin\Developers\Index` | API token management, webhook subscriptions |

---

### Phase 8: Search

**Priority:** LOW - enhances product discovery.

#### Step 8.1: Migrations

1. `create_search_settings_table` - PK: `store_id`
2. `create_search_queries_table` - FK: `store_id`
3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table

#### Step 8.2: Search Service

**File to create:** `App\Services\SearchService`

| Method | Description |
|--------|-------------|
| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |
| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |
| `syncProduct(Product $product): void` | Upsert into FTS5 index |
| `removeProduct(int $productId): void` | Delete from FTS5 index |

**File to create:** `App\Observers\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.

#### Step 8.3: Search UI

**Files to create:**

| Component | Description |
|-----------|-------------|
| `Storefront\Search\Modal` | Search-as-you-type modal with autocomplete results |
| `Storefront\Search\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |

---

### Phase 9: Analytics

**Priority:** LOW - reporting and insights.

#### Step 9.1: Migrations

1. `create_analytics_events_table` - FK: `store_id`, `customer_id`
2. `create_analytics_daily_table` - composite PK: `store_id` + `date`

#### Step 9.2: Analytics Service

**File to create:** `App\Services\AnalyticsService`

| Method | Description |
|--------|-------------|
| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |
| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |

Event types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.

**File to create:** `App\Jobs\AggregateAnalytics`

Requirements:
- Runs daily via `routes/console.php`
- Aggregates raw events into `analytics_daily` rows
- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count

---

### Phase 10: Apps and Webhooks

**Priority:** LOW - extensibility.

#### Step 10.1: Migrations

1. `create_apps_table`
2. `create_app_installations_table` - FK: `store_id`, `app_id`
3. `create_oauth_clients_table` - FK: `app_id`
4. `create_oauth_tokens_table` - FK: `installation_id`
5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`
6. `create_webhook_deliveries_table` - FK: `subscription_id`

#### Step 10.2: Webhook Service

**File to create:** `App\Services\WebhookService`

| Method | Description |
|--------|-------------|
| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |
| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |
| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |


Output:
Chunk ID: 6c6991
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1286
Output:

**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,
  "price_min": 1000,
  "price_max": 5000,
  "in_stock": true,
  "tags": ["organic", "cotton"],
  "vendor": "Acme Apparel"
}
```

**Success response:** `200 OK`

```json
{
  "query": "t-shirt",
  "results": [
    {
      "id": 10,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "image_url": "https://cdn.example.com/products/tshirt.jpg",
      "in_stock": true,
      "tags": ["organic", "cotton"]
    }
  ],
  "facets": {
    "vendors": [
      { "value": "Acme Apparel", "count": 12 }
    ],
    "tags": [
      { "value": "organic", "count": 8 },
      { "value": "cotton", "count": 15 }
    ],
    "price_range": {
      "min": 1500,
      "max": 8500
    }
  },
  "pagination": {
    "current_page": 1,
    "per_page": 24,
    "total": 42,
    "last_page": 2
  }
}
```

**Error responses:**
- `400` - Query too long (max 200 characters) or malformed filters
- `422` - Validation error

---

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

Autocomplete suggestions as the user types.

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

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search prefix. Min: 1, max: 100 characters. |
| `limit` | integer | No | Max suggestions. Default: 5, max: 10. |

**Success response:** `200 OK`

```json
{
  "query": "t-sh",
  "suggestions": [
    {
      "type": "product",
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "image_url": "https://cdn.example.com/products/tshirt-thumb.jpg",
      "price_amount": 2500,
      "currency": "EUR"
    },
    {
      "type": "collection",
      "title": "T-Shirts",
      "handle": "t-shirts",
      "image_url": null
    }
  ]
}
```

**Error responses:**
- `400` - Query too long
- `422` - Validation error

---

### 2.6 Analytics Endpoints

#### POST /api/storefront/v1/analytics/events

Batch submit storefront analytics events.

- **Authentication:** None
- **Rate limit:** `analytics` (60/min per IP)

**Request body:**

```json
{
  "events": [
    {
      "type": "page_view",
      "session_id": "sess_abc123",
      "client_event_id": "evt_001",
      "properties": {
        "url": "/products/classic-t-shirt",
        "referrer": "https://google.com",
        "user_agent": "Mozilla/5.0..."
      },
      "occurred_at": "2026-02-11T10:00:00Z"
    },
    {
      "type": "add_to_cart",
      "session_id": "sess_abc123",
      "client_event_id": "evt_002",
      "properties": {
        "product_id": 10,
        "variant_id": 101,
        "quantity": 1,
        "price_amount": 2500,
        "currency": "EUR"
      },
      "occurred_at": "2026-02-11T10:01:00Z"
    }
  ]
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `events` | array | Yes | Min: 1, max: 50 events per batch. |
| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
| `events[].session_id` | string | Yes | Max: 100 characters. |
| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |
| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |
| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |

**Success response:** `202 Accepted`

```json
{
  "accepted": 2,
  "rejected": 0
}
```

**Error responses:**
- `422` - Validation errors (invalid event types, too many events, etc.)

---

## 3. Admin REST API

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

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.