command: sed -n '1,180p' app/Livewire/Storefront/Search/Index.php; sed -n '1,160p' resources/views/livewire/storefront/search/index.blade.php; sed -n '1,160p' app/Livewire/Storefront/Collections/Show.php

Show.php

stdout:
<?php

namespace App\Livewire\Storefront\Search;

use App\Models\Product;
use Livewire\Component;

class Index extends Component
{
    public string $query = '';

    public function mount(?string $q = null): void
    {
        $this->query = $q ?? request()->string('q')->toString();
    }

    public function render(): mixed
    {
        $products = Product::query()->published()->with(['variants.inventory', 'media'])->when($this->query !== '', fn ($query) => $query->where(fn ($nested) => $nested->where('title', 'like', '%'.$this->query.'%')->orWhere('description', 'like', '%'.$this->query.'%')->orWhere('vendor', 'like', '%'.$this->query.'%')))->latest()->paginate(12);

        return view('livewire.storefront.search.index', compact('products'))->layout('layouts.storefront');
    }
}

Search
<form class="mt-8 flex max-w-xl gap-3" method="get" action="{{ route('search') }}">Search products<input id="search-query" name="q" value="{{ $query }}" placeholder="Search products" class="flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button class="rounded-full bg-zinc-900 px-6 py-3 font-semibold text-white dark:bg-white dark:text-zinc-900">Search</button></form>@if ($query !== '')
Results for “{{ $query }}”
@endif
@forelse ($products as $product)<x-storefront.product-card :product="$product" />@empty

No results

Try a different search.

@endforelse

{{ $products->links() }}

<?php

namespace App\Livewire\Storefront\Collections;

use App\Models\Collection as ProductCollection;
use Livewire\Component;

class Show extends Component
{
    public ProductCollection $collection;

    public string $sort = 'featured';

    public bool $inStock = false;

    public function mount(string $handle): void
    {
        $this->collection = ProductCollection::query()->where('handle', $handle)->with(['products.variants.inventory', 'products.media'])->firstOrFail();
    }

    public function render(): mixed
    {
        $products = $this->collection->products->filter(fn ($product): bool => $product->status->value === 'active' && (! $this->inStock || $product->variants->contains(fn ($variant): bool => $variant->availableQuantity() > 0)));

        if ($this->sort === 'price_asc') {
            $products = $products->sortBy(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0);
        } elseif ($this->sort === 'price_desc') {
            $products = $products->sortByDesc(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0);
        } elseif ($this->sort === 'newest') {
            $products = $products->sortByDesc('created_at');
        }

        return view('livewire.storefront.collections.show', ['products' => $products])->layout('layouts.storefront');
    }

    public function clearFilters(): void
    {
        $this->inStock = false;
        $this->sort = 'featured';
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Livewire\Storefront\Search;

use App\Models\Product;
use Livewire\Component;

class Index extends Component
{
    public string $query = '';

    public function mount(?string $q = null): void
    {
        $this->query = $q ?? request()->string('q')->toString();
    }

    public function render(): mixed
    {
        $products = Product::query()->published()->with(['variants.inventory', 'media'])->when($this->query !== '', fn ($query) => $query->where(fn ($nested) => $nested->where('title', 'like', '%'.$this->query.'%')->orWhere('description', 'like', '%'.$this->query.'%')->orWhere('vendor', 'like', '%'.$this->query.'%')))->latest()->paginate(12);

        return view('livewire.storefront.search.index', compact('products'))->layout('layouts.storefront');
    }
}

Search
<form class="mt-8 flex max-w-xl gap-3" method="get" action="{{ route('search') }}">Search products<input id="search-query" name="q" value="{{ $query }}" placeholder="Search products" class="flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button class="rounded-full bg-zinc-900 px-6 py-3 font-semibold text-white dark:bg-white dark:text-zinc-900">Search</button></form>@if ($query !== '')
Results for “{{ $query }}”
@endif
@forelse ($products as $product)<x-storefront.product-card :product="$product" />@empty

No results

Try a different search.

@endforelse

{{ $products->links() }}

<?php

namespace App\Livewire\Storefront\Collections;

use App\Models\Collection as ProductCollection;
use Livewire\Component;

class Show extends Component
{
    public ProductCollection $collection;

    public string $sort = 'featured';

    public bool $inStock = false;

    public function mount(string $handle): void
    {
        $this->collection = ProductCollection::query()->where('handle', $handle)->with(['products.variants.inventory', 'products.media'])->firstOrFail();
    }

    public function render(): mixed
    {
        $products = $this->collection->products->filter(fn ($product): bool => $product->status->value === 'active' && (! $this->inStock || $product->variants->contains(fn ($variant): bool => $variant->availableQuantity() > 0)));

        if ($this->sort === 'price_asc') {
            $products = $products->sortBy(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0);
        } elseif ($this->sort === 'price_desc') {
            $products = $products->sortByDesc(fn ($product): int => $product->defaultVariant()?->price_amount ?? 0);
        } elseif ($this->sort === 'newest') {
            $products = $products->sortByDesc('created_at');
        }

        return view('livewire.storefront.collections.show', ['products' => $products])->layout('layouts.storefront');
    }

    public function clearFilters(): void
    {
        $this->inStock = false;
        $this->sort = 'featured';
    }
}


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Livewire/Storefront/Search/Index.php\n@@\n-use App\\Models\\Product;\n+use App\\Services\\SearchService;\n@@\n-    public function render(): mixed\n+    public function render(SearchService $search): mixed\n     {\n-        $products = Product::query()->published()->with(['variants.inventory', 'media'])->when($this->query !== '', fn ($query) => $query->where(fn ($nested) => $nested->where('title', 'like', '%'.$this->query.'%')->orWhere('description', 'like', '%'.$this->query.'%')->orWhere('vendor', 'like', '%'.$this->query.'%')))->latest()->paginate(12);\n+        $products = $search->search(app('current_store'), $this->query, [], 12);\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

unified_diff:
@@ -4,3 +4,3 @@

-use App\Models\Product;
+use App\Services\SearchService;
 use Livewire\Component;
@@ -16,5 +16,5 @@

-    public function render(): mixed
+    public function render(SearchService $search): mixed
     {
-        $products = Product::query()->published()->with(['variants.inventory', 'media'])->when($this->query !== '', fn ($query) => $query->where(fn ($nested) => $nested->where('title', 'like', '%'.$this->query.'%')->orWhere('description', 'like', '%'.$this->query.'%')->orWhere('vendor', 'like', '%'.$this->query.'%')))->latest()->paginate(12);
+        $products = $search->search(app('current_store'), $this->query, [], 12);

status: completed

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Livewire/Storefront/Search/Index.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"php artisan migrate:fresh --seed --force",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:12000}),
  tools.exec_command({cmd:"php artisan route:list --path=api/storefront --except-vendor",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:5000}),
  tools.exec_command({cmd:"php artisan schedule:list",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:6000})
]);
for (const r of results) text(r.output);


type: CommandExecution

command: php artisan route:list --path=api/storefront --except-vendor

type: unknown

stdout:
  POST      api/storefront/v1/analytics/events Api\StorefrontAnalyticsControl…
  POST      api/storefront/v1/carts ....... Api\StorefrontCartController@store
  GET|HEAD  api/storefront/v1/carts/{cartId} Api\StorefrontCartController@show
  POST      api/storefront/v1/carts/{cartId}/lines Api\StorefrontCartControll…
  PUT       api/storefront/v1/carts/{cartId}/lines/{lineId} Api\StorefrontCar…
  DELETE    api/storefront/v1/carts/{cartId}/lines/{lineId} Api\StorefrontCar…
  POST      api/storefront/v1/checkouts Api\StorefrontCheckoutController@store
  GET|HEAD  api/storefront/v1/checkouts/{checkoutId} Api\StorefrontCheckoutCo…
  PUT       api/storefront/v1/checkouts/{checkoutId}/address Api\StorefrontCh…
  POST      api/storefront/v1/checkouts/{checkoutId}/apply-discount Api\Store…
  POST      api/storefront/v1/checkouts/{checkoutId}/pay Api\StorefrontChecko…
  PUT       api/storefront/v1/checkouts/{checkoutId}/payment-method Api\Store…
  PUT       api/storefront/v1/checkouts/{checkoutId}/shipping-method Api\Stor…

                                                           Showing [13] routes


command:
/bin/zsh
-lc
php artisan schedule:list

cmd: php artisan schedule:list

stdout:
  0    0 * * *  App\Jobs\CleanupAbandonedCarts .... Next Due: 2 hours from now
  */15 * * * *  App\Jobs\ExpireAbandonedCheckouts  Next Due: 8 minutes from now
  0    1 * * *  App\Jobs\AggregateAnalytics ....... Next Due: 3 hours from now
  0    2 * * *  App\Jobs\CancelUnpaidBankTransferOrders  Next Due: 4 hours from now


command: php artisan migrate:fresh --seed --force


text:
Script completed
Wall time 0.6 seconds
Output:

text:
  Dropping all tables ............................................ 2.14ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.08ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 0.60ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.25ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.46ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.25ms DONE
  2026_08_20_000000_create_shop_schema .......................... 10.56ms DONE
  2026_08_20_210057_add_enum_defaults_to_tenancy_tables .......... 5.85ms DONE
  2026_08_20_210057_add_settings_json_to_store_settings_table .... 0.39ms DONE
  2026_08_20_210057_add_tls_mode_to_store_domains_table .......... 0.36ms DONE
  2026_08_20_220000_add_search_and_auth_support .................. 0.51ms DONE
  2026_08_20_220001_add_webhook_delivery_status .................. 0.39ms DONE

   INFO  Seeding database.

  Database\Seeders\ShopSeeder ........................................ RUNNING
  Database\Seeders\ShopSeeder .................................... 404 ms DONE

text:
  POST      api/storefront/v1/analytics/events Api\StorefrontAnalyticsControl…
  POST      api/storefront/v1/carts ....... Api\StorefrontCartController@store
  GET|HEAD  api/storefront/v1/carts/{cartId} Api\StorefrontCartController@show
  POST      api/storefront/v1/carts/{cartId}/lines Api\StorefrontCartControll…
  PUT       api/storefront/v1/carts/{cartId}/lines/{lineId} Api\StorefrontCar…
  DELETE    api/storefront/v1/carts/{cartId}/lines/{lineId} Api\StorefrontCar…
  POST      api/storefront/v1/checkouts Api\StorefrontCheckoutController@store
  GET|HEAD  api/storefront/v1/checkouts/{checkoutId} Api\StorefrontCheckoutCo…
  PUT       api/storefront/v1/checkouts/{checkoutId}/address Api\StorefrontCh…
  POST      api/storefront/v1/checkouts/{checkoutId}/apply-discount Api\Store…
  POST      api/storefront/v1/checkouts/{checkoutId}/pay Api\StorefrontChecko…
  PUT       api/storefront/v1/checkouts/{checkoutId}/payment-method Api\Store…
  PUT       api/storefront/v1/checkouts/{checkoutId}/shipping-method Api\Stor…

                                                           Showing [13] routes

text:
  0    0 * * *  App\Jobs\CleanupAbandonedCarts .... Next Due: 2 hours from now
  */15 * * * *  App\Jobs\ExpireAbandonedCheckouts  Next Due: 8 minutes from now
  0    1 * * *  App\Jobs\AggregateAnalytics ....... Next Due: 3 hours from now
  0    2 * * *  App\Jobs\CancelUnpaidBankTransferOrders  Next Due: 4 hours from now


status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:8000}),
  tools.exec_command({cmd:"php artisan test",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:16000}),
  tools.exec_command({cmd:"php artisan view:cache",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:5000})
]);
for (const r of results) text(r.output);


command:
/bin/zsh
-lc
php artisan view:cache

cmd: php artisan view:cache

stdout: INFO  Blade templates cached successfully.


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

cmd: vendor/bin/pint --dirty --format agent

result: fixed


command:
/bin/zsh
-lc
php artisan test

cmd: php artisan test

stdout:
   PASS  Tests\Unit\ExampleTest
  ✓ that true is true

   PASS  Tests\Unit\Policies\StorePolicyTest
  ✓ product permissions are based on the users role in the current stor… 0.15s
  ✓ store deletion is restricted to owners                               0.01s

   PASS  Tests\Unit\PricingEngineTest
  ✓ tax calculations use integer minor-unit arithmetic
  ✓ discount calculations cap fixed discounts and allocate percentages
  ✓ free shipping discounts do not reduce merchandise totals
  ✓ mock payment provider supports the documented magic cards and deferred transfers

   PASS  Tests\Unit\Tenancy\BelongsToStoreTest
  ✓ tenant models are created and queried for the current store only     0.01s
  ✓ tenant queries return no rows when no current store is resolved      0.01s

   PASS  Tests\Feature\Auth\AuthenticationTest
  ✓ login screen can be rendered                                         0.05s
  ✓ users can authenticate using the login screen                        0.01s
  ✓ users can not authenticate with invalid password                     0.01s
  ✓ users with two factor enabled are redirected to two factor challeng… 0.01s
  ✓ users can logout                                                     0.01s

   PASS  Tests\Feature\Auth\EmailVerificationTest
  ✓ email verification screen can be rendered                            0.01s
  ✓ email can be verified                                                0.01s
  ✓ email is not verified with invalid hash                              0.01s
  ✓ already verified user visiting verification link is redirected with… 0.01s

   PASS  Tests\Feature\Auth\PasswordConfirmationTest
  ✓ confirm password screen can be rendered                              0.01s

   PASS  Tests\Feature\Auth\PasswordResetTest
  ✓ reset password link screen can be rendered                           0.01s
  ✓ reset password link can be requested                                 0.21s
  ✓ reset password screen can be rendered                                0.22s
  ✓ password can be reset with valid token                               0.22s

   PASS  Tests\Feature\Auth\RegistrationTest
  ✓ registration screen can be rendered                                  0.01s
  ✓ new users can register                                               0.01s

   PASS  Tests\Feature\Auth\TwoFactorChallengeTest
  ✓ two factor challenge redirects to login when not authenticated       0.01s
  ✓ two factor challenge can be rendered                                 0.01s

   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.05s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                              0.01s
  ✓ authenticated users can visit the dashboard                          0.02s

   PASS  Tests\Feature\ExampleTest
  ✓ it returns a successful response                                     0.01s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password can be updated                                              0.03s
  ✓ correct password must be provided to update password                 0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                            0.03s
  ✓ profile information can be updated                                   0.02s
  ✓ email verification status is unchanged when email address is unchan… 0.02s
  ✓ user can delete their account                                        0.01s
  ✓ correct password must be provided to delete account                  0.02s

   PASS  Tests\Feature\Settings\TwoFactorAuthenticationTest
  ✓ two factor settings page can be rendered                             0.02s
  ✓ two factor settings page requires password confirmation when enable… 0.01s
  ✓ two factor settings page returns forbidden response when two factor… 0.01s
  ✓ two factor authentication disabled when confirmation abandoned betw… 0.01s

   PASS  Tests\Feature\Tenancy\ResolveStoreTest
  ✓ a storefront request resolves and shares the store by hostname       0.01s
  ✓ a storefront hostname is cached as a store id                        0.01s
  ✓ unknown storefront hosts return not found                            0.01s
  ✓ suspended storefronts return service unavailable                     0.01s
  ✓ admin requests resolve only the session store the user belongs to    0.01s
  ✓ admin requests reject a session store without membership             0.01s

  Tests:    50 passed (124 assertions)
  Duration: 1.42s


text:
Script completed
Wall time 1.6 seconds
Output:

text:
   PASS  Tests\Unit\ExampleTest
  ✓ that true is true

   PASS  Tests\Unit\Policies\StorePolicyTest
  ✓ product permissions are based on the users role in the current stor… 0.15s
  ✓ store deletion is restricted to owners                               0.01s

   PASS  Tests\Unit\PricingEngineTest
  ✓ tax calculations use integer minor-unit arithmetic
  ✓ discount calculations cap fixed discounts and allocate percentages
  ✓ free shipping discounts do not reduce merchandise totals
  ✓ mock payment provider supports the documented magic cards and deferred transfers

   PASS  Tests\Unit\Tenancy\BelongsToStoreTest
  ✓ tenant models are created and queried for the current store only     0.01s
  ✓ tenant queries return no rows when no current store is resolved      0.01s

   PASS  Tests\Feature\Auth\AuthenticationTest
  ✓ login screen can be rendered                                         0.05s
  ✓ users can authenticate using the login screen                        0.01s
  ✓ users can not authenticate with invalid password                     0.01s
  ✓ users with two factor enabled are redirected to two factor challeng… 0.01s
  ✓ users can logout                                                     0.01s

   PASS  Tests\Feature\Auth\EmailVerificationTest
  ✓ email verification screen can be rendered                            0.01s
  ✓ email can be verified                                                0.01s
  ✓ email is not verified with invalid hash                              0.01s
  ✓ already verified user visiting verification link is redirected with… 0.01s

   PASS  Tests\Feature\Auth\PasswordConfirmationTest
  ✓ confirm password screen can be rendered                              0.01s

   PASS  Tests\Feature\Auth\PasswordResetTest
  ✓ reset password link screen can be rendered                           0.01s
  ✓ reset password link can be requested                                 0.21s
  ✓ reset password screen can be rendered                                0.22s
  ✓ password can be reset with valid token                               0.22s

   PASS  Tests\Feature\Auth\RegistrationTest
  ✓ registration screen can be rendered                                  0.01s
  ✓ new users can register                                               0.01s

   PASS  Tests\Feature\Auth\TwoFactorChallengeTest
  ✓ two factor challenge redirects to login when not authenticated       0.01s
  ✓ two factor challenge can be rendered                                 0.01s

   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.05s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                              0.01s
  ✓ authenticated users can visit the dashboard                          0.02s

   PASS  Tests\Feature\ExampleTest
  ✓ it returns a successful response                                     0.01s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password can be updated                                              0.03s
  ✓ correct password must be provided to update password                 0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                            0.03s
  ✓ profile information can be updated                                   0.02s
  ✓ email verification status is unchanged when email address is unchan… 0.02s
  ✓ user can delete their account                                        0.01s
  ✓ correct password must be provided to delete account                  0.02s

   PASS  Tests\Feature\Settings\TwoFactorAuthenticationTest
  ✓ two factor settings page can be rendered                             0.02s
  ✓ two factor settings page requires password confirmation when enable… 0.01s
  ✓ two factor settings page returns forbidden response when two factor… 0.01s
  ✓ two factor authentication disabled when confirmation abandoned betw… 0.01s

   PASS  Tests\Feature\Tenancy\ResolveStoreTest
  ✓ a storefront request resolves and shares the store by hostname       0.01s
  ✓ a storefront hostname is cached as a store id                        0.01s
  ✓ unknown storefront hosts return not found                            0.01s
  ✓ suspended storefronts return service unavailable                     0.01s
  ✓ admin requests resolve only the session store the user belongs to    0.01s
  ✓ admin requests reject a session store without membership             0.01s

  Tests:    50 passed (124 assertions)
  Duration: 1.42s

text: INFO  Blade templates cached successfully.


status: completed

input: const r = await tools.exec_command({cmd:"php artisan make:test --pest SearchAnalyticsWebhookTest --no-interaction",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:3000}); text(r.output);


command: php artisan make:test --pest SearchAnalyticsWebhookTest --no-interaction

stdout: INFO  Test [tests/Feature/SearchAnalyticsWebhookTest.php] created successfully.


text:
Script completed
Wall time 0.2 seconds
Output:

text: INFO  Test [tests/Feature/SearchAnalyticsWebhookTest.php] created successfully.


status: completed

input:
const patch = "*** Begin Patch\n*** Delete File: /Users/wesner/Herd/shop/tests/Feature/SearchAnalyticsWebhookTest.php\n*** Add File: /Users/wesner/Herd/shop/tests/Feature/SearchAnalyticsWebhookTest.php\n+<?php\n+\n+use App\\Jobs\\AggregateAnalytics;\n+use App\\Models\\AnalyticsDaily;\n+use App\\Models\\AnalyticsEvent;\n+use App\\Models\\Product;\n+use App\\Models\\SearchQuery;\n+use App\\Models\\Store;\n+use App\\Models\\WebhookDelivery;\n+use App\\Models\\WebhookSubscription;\n+use App\\Services\\AnalyticsService;\n+use App\\Services\\SearchService;\n+use App\\Services\\WebhookService;\n+use Carbon\\CarbonImmutable;\n+use Database\\Seeders\\ShopSeeder;\n+use Illuminate\\Support\\Facades\\DB;\n+use Illuminate\\Support\\Facades\\Http;\n+\n+uses(\\Illuminate\\Foundation\\Testing\\LazilyRefreshDatabase::class);\n+\n+beforeEach(function (): void {\n+    config(['cache.default' => 'array']);\n+    $this->seed(ShopSeeder::class);\n+    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();\n+    app()->instance('current_store', $this->store);\n+});\n+\n+test('search is tenant scoped, paginated, and logs the query', function (): void {\n+    $search = new SearchService;\n+    $results = $search->search($this->store, 'classic', [], 12);\n+\n+    expect($results->total())->toBe(1)\n+        ->and($results->first()->title)->toBe('Classic Cotton T-Shirt')\n+        ->and(SearchQuery::query()->where('query', 'classic')->count())->toBe(1);\n+});\n+\n+test('product changes are synchronized to the FTS index and autocomplete', function (): void {\n+    $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail();\n+    $product->update(['title' => 'Classic Cotton Tee']);\n+\n+    expect(DB::table('products_fts')->where('product_id', $product->getKey())->value('title'))->toBe('Classic Cotton Tee')\n+        ->and((new SearchService)->autocomplete($this->store, 'Classic')->first()->title)->toBe('Classic Cotton Tee');\n+});\n+\n+test('analytics events aggregate idempotently into daily metrics', function (): void {\n+    $date = CarbonImmutable::yesterday();\n+    $analytics = new AnalyticsService;\n+\n+    foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) {\n+        $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1');\n+        $event->forceFill(['created_at' => $date])->save();\n+    }\n+\n+    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());\n+    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());\n+\n+    $daily = AnalyticsDaily::query()->whereDate('date', $date)->firstOrFail();\n+\n+    expect($daily->visits_count)->toBe(2)\n+        ->and($daily->add_to_cart_count)->toBe(1)\n+        ->and($daily->checkout_started_count)->toBe(1)\n+        ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(4);\n+});\n+\n+test('webhooks are signed and delivered with platform headers', function (): void {\n+    Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]);\n+    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'secret_encrypted' => 'test-secret', 'status' => 'active']);\n+\n+    (new WebhookService)->dispatch($this->store, 'order.created', ['order_id' => 1001]);\n+\n+    Http::assertSent(function ($request): bool {\n+        return $request->hasHeader('X-Platform-Signature')\n+            && $request->header('X-Platform-Event')[0] === 'order.created'\n+            && $request->header('X-Platform-Delivery-Id') !== null;\n+    });\n+\n+    expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered')\n+        ->and((new WebhookService)->verify('{\"order_id\":1001}', (new WebhookService)->sign('{\"order_id\":1001}', 'test-secret'), 'test-secret'))->toBeTrue();\n+});\n*** End Patch";
text(await tools.apply_patch(patch));


type: add

content:
<?php

use App\Jobs\AggregateAnalytics;
use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Product;
use App\Models\SearchQuery;
use App\Models\Store;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use App\Services\AnalyticsService;
use App\Services\SearchService;
use App\Services\WebhookService;
use Carbon\CarbonImmutable;
use Database\Seeders\ShopSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

uses(\Illuminate\Foundation\Testing\LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $this->store);
});

test('search is tenant scoped, paginated, and logs the query', function (): void {
    $search = new SearchService;
    $results = $search->search($this->store, 'classic', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->first()->title)->toBe('Classic Cotton T-Shirt')
        ->and(SearchQuery::query()->where('query', 'classic')->count())->toBe(1);
});

test('product changes are synchronized to the FTS index and autocomplete', function (): void {
    $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail();
    $product->update(['title' => 'Classic Cotton Tee']);

    expect(DB::table('products_fts')->where('product_id', $product->getKey())->value('title'))->toBe('Classic Cotton Tee')
        ->and((new SearchService)->autocomplete($this->store, 'Classic')->first()->title)->toBe('Classic Cotton Tee');
});

test('analytics events aggregate idempotently into daily metrics', function (): void {
    $date = CarbonImmutable::yesterday();
    $analytics = new AnalyticsService;

    foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) {
        $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1');
        $event->forceFill(['created_at' => $date])->save();
    }

    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());
    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());

    $daily = AnalyticsDaily::query()->whereDate('date', $date)->firstOrFail();

    expect($daily->visits_count)->toBe(2)
        ->and($daily->add_to_cart_count)->toBe(1)
        ->and($daily->checkout_started_count)->toBe(1)
        ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(4);
});

test('webhooks are signed and delivered with platform headers', function (): void {
    Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]);
    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'secret_encrypted' => 'test-secret', 'status' => 'active']);

    (new WebhookService)->dispatch($this->store, 'order.created', ['order_id' => 1001]);

    Http::assertSent(function ($request): bool {
        return $request->hasHeader('X-Platform-Signature')
            && $request->header('X-Platform-Event')[0] === 'order.created'
            && $request->header('X-Platform-Delivery-Id') !== null;
    });

    expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered')
        ->and((new WebhookService)->verify('{"order_id":1001}', (new WebhookService)->sign('{"order_id":1001}', 'test-secret'), 'test-secret'))->toBeTrue();
});

stdout:
Success. Updated the following files:
A /Users/wesner/Herd/shop/tests/Feature/SearchAnalyticsWebhookTest.php
D /Users/wesner/Herd/shop/tests/Feature/SearchAnalyticsWebhookTest.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input: const r = await tools.exec_command({cmd:"php artisan test tests/Feature/SearchAnalyticsWebhookTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:14000}); text(r.output);


command: php artisan test tests/Feature/SearchAnalyticsWebhookTest.php

status: failed

stdout:
   FAIL  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.12s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ⨯ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.04s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\SearchAnalyticsWebhookTest > analyt…  QueryException
  SQLSTATE[HY000]: General error: 1 table analytics_daily has no column named updated_at (Connection: sqlite, Database: :memory:, SQL: insert into "analytics_daily" ("store_id", "date", "orders_count", "revenue_amount", "aov_amount", "visits_count", "add_to_cart_count", "checkout_started_count", "updated_at", "created_at") values (1, 2026-08-19 00:00:00, 1, 2998, 2998, 2, 1, 1, 2026-08-20 21:36:51, 2026-08-20 21:36:51))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:578
    574▕             if ($this->pretending()) {
    575▕                 return true;
    576▕             }
    577▕
  ➜ 578▕             $statement = $this->getPdo()->prepare($query);
    579▕
    580▕             $this->bindValues($statement, $this->prepareBindings($bindings));
    581▕
    582▕             $this->recordsHaveBeenModified();

      +18 vendor frames
  19  app/Jobs/AggregateAnalytics.php:31
      +11 vendor frames
  31  tests/Feature/SearchAnalyticsWebhookTest.php:54

  Tests:    1 failed, 3 passed (8 assertions)
  Duration: 0.23s


text:
Script completed
Wall time 0.4 seconds
Output:

text:
   FAIL  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.12s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ⨯ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.04s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\SearchAnalyticsWebhookTest > analyt…  QueryException
  SQLSTATE[HY000]: General error: 1 table analytics_daily has no column named updated_at (Connection: sqlite, Database: :memory:, SQL: insert into "analytics_daily" ("store_id", "date", "orders_count", "revenue_amount", "aov_amount", "visits_count", "add_to_cart_count", "checkout_started_count", "updated_at", "created_at") values (1, 2026-08-19 00:00:00, 1, 2998, 2998, 2, 1, 1, 2026-08-20 21:36:51, 2026-08-20 21:36:51))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:578
    574▕             if ($this->pretending()) {
    575▕                 return true;
    576▕             }
    577▕
  ➜ 578▕             $statement = $this->getPdo()->prepare($query);
    579▕
    580▕             $this->bindValues($statement, $this->prepareBindings($bindings));
    581▕
    582▕             $this->recordsHaveBeenModified();

      +18 vendor frames
  19  app/Jobs/AggregateAnalytics.php:31
      +11 vendor frames
  31  tests/Feature/SearchAnalyticsWebhookTest.php:54

  Tests:    1 failed, 3 passed (8 assertions)
  Duration: 0.23s


status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Models/AnalyticsDaily.php\n@@\n     public $incrementing = false;\n+\n+    public $timestamps = false;\n*** End Patch";
text(await tools.apply_patch(patch));


unified_diff:
@@ -17,2 +17,4 @@

+    public $timestamps = false;
+
     protected function casts(): array

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Models/AnalyticsDaily.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input: const r = await tools.exec_command({cmd:"php artisan test tests/Feature/SearchAnalyticsWebhookTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:10000}); text(r.output);


cmd: php artisan test tests/Feature/SearchAnalyticsWebhookTest.php

stdout:
   FAIL  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.12s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ⨯ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.03s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\SearchAnalytics…  UniqueConstraintViolationException
  SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: analytics_daily.store_id, analytics_daily.date (Connection: sqlite, Database: :memory:, SQL: insert into "analytics_daily" ("store_id", "date", "orders_count", "revenue_amount", "aov_amount", "visits_count", "add_to_cart_count", "checkout_started_count") values (1, 2026-08-19 00:00:00, 1, 2998, 2998, 2, 1, 1))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:584
    580▕             $this->bindValues($statement, $this->prepareBindings($bindings));
    581▕
    582▕             $this->recordsHaveBeenModified();
    583▕
  ➜ 584▕             return $statement->execute();
    585▕         });
    586▕     }
    587▕
    588▕     /**

      +18 vendor frames
  19  app/Jobs/AggregateAnalytics.php:31
      +11 vendor frames
  31  tests/Feature/SearchAnalyticsWebhookTest.php:55

  Tests:    1 failed, 3 passed (8 assertions)
  Duration: 0.23s


text:
Script completed
Wall time 0.4 seconds
Output:

text:
   FAIL  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.12s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ⨯ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.03s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\SearchAnalytics…  UniqueConstraintViolationException
  SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: analytics_daily.store_id, analytics_daily.date (Connection: sqlite, Database: :memory:, SQL: insert into "analytics_daily" ("store_id", "date", "orders_count", "revenue_amount", "aov_amount", "visits_count", "add_to_cart_count", "checkout_started_count") values (1, 2026-08-19 00:00:00, 1, 2998, 2998, 2, 1, 1))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:584
    580▕             $this->bindValues($statement, $this->prepareBindings($bindings));
    581▕
    582▕             $this->recordsHaveBeenModified();
    583▕
  ➜ 584▕             return $statement->execute();
    585▕         });
    586▕     }
    587▕
    588▕     /**

      +18 vendor frames
  19  app/Jobs/AggregateAnalytics.php:31
      +11 vendor frames
  31  tests/Feature/SearchAnalyticsWebhookTest.php:55

  Tests:    1 failed, 3 passed (8 assertions)
  Duration: 0.23s


status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Jobs/AggregateAnalytics.php\n@@\n-            AnalyticsDaily::withoutGlobalScopes()->updateOrCreate(\n+            AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert(\n*** End Patch";
text(await tools.apply_patch(patch));


unified_diff:
@@ -30,3 +30,3 @@

-            AnalyticsDaily::withoutGlobalScopes()->updateOrCreate(
+            AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert(
                 ['store_id' => $store->getKey(), 'date' => $date->toDateString()],

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Jobs/AggregateAnalytics.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input: const r = await tools.exec_command({cmd:"php artisan test tests/Feature/SearchAnalyticsWebhookTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:10000}); text(r.output);


stdout:
   PASS  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.12s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ✓ analytics events aggregate idempotently into daily metrics           0.02s
  ✓ webhooks are signed and delivered with platform headers              0.03s

  Tests:    4 passed (12 assertions)
  Duration: 0.23s


text:
Script completed
Wall time 0.4 seconds
Output:

text:
   PASS  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.12s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ✓ analytics events aggregate idempotently into daily metrics           0.02s
  ✓ webhooks are signed and delivered with platform headers              0.03s

  Tests:    4 passed (12 assertions)
  Duration: 0.23s


status: completed

input: const r = await tools.exec_command({cmd:"sed -n '1,280p' app/Models/Customer.php; sed -n '1,200p' app/Livewire/Storefront/Account/Auth/Login.php; sed -n '1,260p' app/Livewire/Storefront/Account/Auth/Register.php; sed -n '1,220p' config/auth.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:14000}); text(r.output);


command: sed -n '1,280p' app/Models/Customer.php; sed -n '1,200p' app/Livewire/Storefront/Account/Auth/Login.php; sed -n '1,260p' app/Livewire/Storefront/Account/Auth/Register.php; sed -n '1,220p' config/auth.php

Customer.php

type: read

cmd: sed -n '1,280p' app/Models/Customer.php

auth.php

cmd: sed -n '1,220p' config/auth.php

stdout:
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Auth\Authenticatable as AuthenticatableTrait;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;

class Customer extends Model implements Authenticatable
{
    use AuthenticatableTrait, BelongsToStore, HasFactory, Notifiable;

    protected $fillable = ['store_id', 'first_name', 'last_name', 'email', 'password_hash', 'status', 'email_verified_at', 'metadata'];

    protected $hidden = ['password_hash', 'remember_token'];

    protected function casts(): array
    {
        return ['email_verified_at' => 'datetime', 'metadata' => 'array', 'password_hash' => 'hashed'];
    }

    public function getAuthPasswordName(): string
    {
        return 'password_hash';
    }

    public function getAuthPassword(): ?string
    {
        return $this->password_hash;
    }

    public function getNameAttribute(): string
    {
        return trim($this->first_name.' '.$this->last_name);
    }

    public function addresses(): HasMany
    {
        return $this->hasMany(CustomerAddress::class);
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    public function carts(): HasMany
    {
        return $this->hasMany(Cart::class);
    }
}
<?php

namespace App\Livewire\Storefront\Account\Auth;

use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Login extends Component
{
    public string $email = '';

    public string $password = '';

    public bool $remember = false;

    public function login(): void
    {
        $credentials = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);

        if (! Auth::guard('customer')->attempt($credentials, $this->remember)) {
            $this->addError('email', 'These credentials do not match our records.');

            return;
        }

        session()->regenerate();
        $this->redirect(route('account.dashboard'), navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.storefront.account.auth.login')->layout('layouts.storefront');
    }
}
<?php

namespace App\Livewire\Storefront\Account\Auth;

use App\Models\Customer;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Register extends Component
{
    public string $firstName = '';

    public string $lastName = '';

    public string $email = '';

    public string $password = '';

    public string $passwordConfirmation = '';

    public function register(): void
    {
        $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:customers,email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);
        $customer = Customer::create(['store_id' => app('current_store')->getKey(), 'first_name' => $data['firstName'], 'last_name' => $data['lastName'], 'email' => $data['email'], 'password_hash' => $data['password'], 'status' => 'active']);
        Auth::guard('customer')->login($customer);
        $this->redirect(route('account.dashboard'), navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.storefront.account.auth.register')->layout('layouts.storefront');
    }
}
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'customer' => [
            'driver' => 'session',
            'provider' => 'customers',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
        'customers' => [
            'driver' => 'customer',
            'model' => App\Models\Customer::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage
    | and the user provider that is invoked to actually retrieve users.
    |
    | The expiry time is the number of minutes that each reset token will be
    | considered valid. This security feature keeps tokens short-lived so
    | they have less time to be guessed. You may change this as needed.
    |
    | The throttle setting is the number of seconds a user must wait before
    | generating more password reset tokens. This prevents the user from
    | quickly generating a very large amount of password reset tokens.
    |
    */

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
        'customers' => [
            'provider' => 'customers',
            'table' => 'customer_password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Password Confirmation Timeout
    |--------------------------------------------------------------------------
    |
    | Here you may define the number of seconds before a password confirmation
    | window expires and users are asked to re-enter their password via the
    | confirmation screen. By default, the timeout lasts for three hours.
    |
    */

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Auth\Authenticatable as AuthenticatableTrait;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;

class Customer extends Model implements Authenticatable
{
    use AuthenticatableTrait, BelongsToStore, HasFactory, Notifiable;

    protected $fillable = ['store_id', 'first_name', 'last_name', 'email', 'password_hash', 'status', 'email_verified_at', 'metadata'];

    protected $hidden = ['password_hash', 'remember_token'];

    protected function casts(): array
    {
        return ['email_verified_at' => 'datetime', 'metadata' => 'array', 'password_hash' => 'hashed'];
    }

    public function getAuthPasswordName(): string
    {
        return 'password_hash';
    }

    public function getAuthPassword(): ?string
    {
        return $this->password_hash;
    }

    public function getNameAttribute(): string
    {
        return trim($this->first_name.' '.$this->last_name);
    }

    public function addresses(): HasMany
    {
        return $this->hasMany(CustomerAddress::class);
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    public function carts(): HasMany
    {
        return $this->hasMany(Cart::class);
    }
}
<?php

namespace App\Livewire\Storefront\Account\Auth;

use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Login extends Component
{
    public string $email = '';

    public string $password = '';

    public bool $remember = false;

    public function login(): void
    {
        $credentials = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);

        if (! Auth::guard('customer')->attempt($credentials, $this->remember)) {
            $this->addError('email', 'These credentials do not match our records.');

            return;
        }

        session()->regenerate();
        $this->redirect(route('account.dashboard'), navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.storefront.account.auth.login')->layout('layouts.storefront');
    }
}
<?php

namespace App\Livewire\Storefront\Account\Auth;

use App\Models\Customer;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Register extends Component
{
    public string $firstName = '';

    public string $lastName = '';

    public string $email = '';

    public string $password = '';

    public string $passwordConfirmation = '';

    public function register(): void
    {
        $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:customers,email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);
        $customer = Customer::create(['store_id' => app('current_store')->getKey(), 'first_name' => $data['firstName'], 'last_name' => $data['lastName'], 'email' => $data['email'], 'password_hash' => $data['password'], 'status' => 'active']);
        Auth::guard('customer')->login($customer);
        $this->redirect(route('account.dashboard'), navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.storefront.account.auth.register')->layout('layouts.storefront');
    }
}
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'customer' => [
            'driver' => 'session',
            'provider' => 'customers',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
        'customers' => [
            'driver' => 'customer',
            'model' => App\Models\Customer::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage
    | and the user provider that is invoked to actually retrieve users.
    |
    | The expiry time is the number of minutes that each reset token will be
    | considered valid. This security feature keeps tokens short-lived so
    | they have less time to be guessed. You may change this as needed.
    |
    | The throttle setting is the number of seconds a user must wait before
    | generating more password reset tokens. This prevents the user from
    | quickly generating a very large amount of password reset tokens.
    |
    */

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
        'customers' => [
            'provider' => 'customers',
            'table' => 'customer_password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Password Confirmation Timeout
    |--------------------------------------------------------------------------
    |
    | Here you may define the number of seconds before a password confirmation
    | window expires and users are asked to re-enter their password via the
    | confirmation screen. By default, the timeout lasts for three hours.
    |
    */

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];


exec

status: completed

input: const r = await tools.exec_command({cmd:"php artisan route:list --path=forgot --except-vendor; php artisan route:list --path=reset --except-vendor",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:5000}); text(r.output);


command: php artisan route:list --path=forgot --except-vendor; php artisan route:list --path=reset --except-vendor

stdout: ERROR  Your application doesn't have any routes matching the given criteria.


text:
Script completed
Wall time 0.3 seconds
Output:

text:
   ERROR  Your application doesn't have any routes matching the given criteria.

   ERROR  Your application doesn't have any routes matching the given criteria.


status: completed

input:
const patch = "*** Begin Patch\n*** Add File: /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/ForgotPassword.php\n+<?php\n+\n+namespace App\\Livewire\\Storefront\\Account\\Auth;\n+\n+use Illuminate\\Support\\Facades\\Password;\n+use Livewire\\Component;\n+\n+class ForgotPassword extends Component\n+{\n+    public string $email = '';\n+\n+    public string $message = '';\n+\n+    public function sendResetLink(): void\n+    {\n+        $this->validate(['email' => ['required', 'email']]);\n+        Password::broker('customers')->sendResetLink(['email' => $this->email]);\n+        $this->message = 'If an account exists for that email, a reset link has been sent.';\n+    }\n+\n+    public function render(): mixed\n+    {\n+        return view('livewire.storefront.account.auth.forgot-password')->layout('layouts.storefront');\n+    }\n+}\n*** Add File: /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/ResetPassword.php\n+<?php\n+\n+namespace App\\Livewire\\Storefront\\Account\\Auth;\n+\n+use App\\Models\\Customer;\n+use Illuminate\\Support\\Facades\\Password;\n+use Livewire\\Component;\n+\n+class ResetPassword extends Component\n+{\n+    public string $token;\n+\n+    public string $email = '';\n+\n+    public string $password = '';\n+\n+    public string $passwordConfirmation = '';\n+\n+    public function mount(string $token): void\n+    {\n+        $this->token = $token;\n+        $this->email = request()->string('email')->toString();\n+    }\n+\n+    public function resetPassword(): void\n+    {\n+        $data = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);\n+        $status = Password::broker('customers')->reset(['email' => $data['email'], 'password' => $data['password'], 'password_confirmation' => $this->passwordConfirmation, 'token' => $this->token], function (Customer $customer, string $password): void {\n+            $customer->password_hash = $password;\n+            $customer->save();\n+        });\n+\n+        if ($status !== Password::PASSWORD_RESET) {\n+            $this->addError('email', __($status));\n+\n+            return;\n+        }\n+\n+        $this->redirect(route('account.login'), navigate: true);\n+    }\n+\n+    public function render(): mixed\n+    {\n+        return view('livewire.storefront.account.auth.reset-password')->layout('layouts.storefront');\n+    }\n+}\n*** Add File: /Users/wesner/Herd/shop/resources/views/livewire/storefront/account/auth/forgot-password.blade.php\n+

Account

Forgot your password?

Enter your email and we’ll send a reset link.
<form wire:submit=\"sendResetLink\" class=\"mt-8 space-y-5\">
Email<input id=\"customer-forgot-email\" type=\"email\" wire:model=\"email\" class=\"mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3\">@error('email')
{{ $message }}
@enderror
<button class=\"w-full rounded-full bg-zinc-900 px-5 py-3 font-semibold text-white\">Send reset link</button></form>@if ($message)
{{ $message }}
@endif
\n*** Add File: /Users/wesner/Herd/shop/resources/views/livewire/storefront/account/auth/reset-password.blade.php\n+

Account

Reset your password
<form wire:submit=\"resetPassword\" class=\"mt-8 space-y-5\">
Email<input id=\"customer-reset-email\" type=\"email\" wire:model=\"email\" class=\"mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3\">@error('email')
{{ $message }}
@enderror

New password<input id=\"customer-reset-password\" type=\"password\" wire:model=\"password\" class=\"mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3\">@error('password')
{{ $message }}
@enderror

Confirm password<input id=\"customer-reset-confirmation\" type=\"password\" wire:model=\"passwordConfirmation\" class=\"mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3\">
<button class=\"w-full rounded-full bg-zinc-900 px-5 py-3 font-semibold text-white\">Reset password</button></form>
\n*** Update File: /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/Register.php\n@@\n use Illuminate\\Support\\Facades\\Auth;\n+use Illuminate\\Validation\\Rule;\n@@\n-        $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:customers,email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);\n+        $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', Rule::unique('customers', 'email')->where('store_id', app('current_store')->getKey())], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);\n*** Update File: /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/Login.php\n@@\n     public function login(): void\n*** Update File: /Users/wesner/Herd/shop/routes/web.php\n@@\n use App\\Livewire\\Storefront\\Account\\Auth\\Login as CustomerLogin;\n+use App\\Livewire\\Storefront\\Account\\Auth\\ForgotPassword as CustomerForgotPassword;\n use App\\Livewire\\Storefront\\Account\\Auth\\Register as CustomerRegister;\n+use App\\Livewire\\Storefront\\Account\\Auth\\ResetPassword as CustomerResetPassword;\n@@\n-    Route::livewire('/account/login', CustomerLogin::class)->name('account.login');\n+    Route::livewire('/account/login', CustomerLogin::class)->middleware('throttle:login')->name('account.login');\n     Route::livewire('/account/register', CustomerRegister::class)->name('account.register');\n+    Route::livewire('/forgot-password', CustomerForgotPassword::class)->name('password.request');\n+    Route::livewire('/reset-password/{token}', CustomerResetPassword::class)->name('password.reset');\n*** End Patch";
text(await tools.apply_patch(patch));


content:
Account

Reset your password
<form wire:submit="resetPassword" class="mt-8 space-y-5">
Email<input id="customer-reset-email" type="email" wire:model="email" class="mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3">@error('email')
{{ $message }}
@enderror

New password<input id="customer-reset-password" type="password" wire:model="password" class="mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3">@error('password')
{{ $message }}
@enderror

Confirm password<input id="customer-reset-confirmation" type="password" wire:model="passwordConfirmation" class="mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3">
<button class="w-full rounded-full bg-zinc-900 px-5 py-3 font-semibold text-white">Reset password</button></form>

unified_diff:
@@ -18,3 +18,5 @@
 use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin;
+use App\Livewire\Storefront\Account\Auth\ForgotPassword as CustomerForgotPassword;
 use App\Livewire\Storefront\Account\Auth\Register as CustomerRegister;
+use App\Livewire\Storefront\Account\Auth\ResetPassword as CustomerResetPassword;
 use App\Livewire\Storefront\Account\Dashboard as AccountDashboard;
@@ -50,4 +52,6 @@
     Route::livewire('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->name('checkout.confirmation');
-    Route::livewire('/account/login', CustomerLogin::class)->name('account.login');
+    Route::livewire('/account/login', CustomerLogin::class)->middleware('throttle:login')->name('account.login');
     Route::livewire('/account/register', CustomerRegister::class)->name('account.register');
+    Route::livewire('/forgot-password', CustomerForgotPassword::class)->name('password.request');
+    Route::livewire('/reset-password/{token}', CustomerResetPassword::class)->name('password.reset');
     Route::post('/account/logout', function (): \Illuminate\Http\RedirectResponse {

unified_diff:
@@ -6,2 +6,3 @@
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Validation\Rule;
 use Livewire\Component;
@@ -22,3 +23,3 @@
     {
-        $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:customers,email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);
+        $data = $this->validate(['firstName' => ['required', 'string', 'max:255'], 'lastName' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', Rule::unique('customers', 'email')->where('store_id', app('current_store')->getKey())], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);
         $customer = Customer::create(['store_id' => app('current_store')->getKey(), 'first_name' => $data['firstName'], 'last_name' => $data['lastName'], 'email' => $data['email'], 'password_hash' => $data['password'], 'status' => 'active']);

content:
Account

Forgot your password?

Enter your email and we’ll send a reset link.
<form wire:submit="sendResetLink" class="mt-8 space-y-5">
Email<input id="customer-forgot-email" type="email" wire:model="email" class="mt-2 w-full rounded-lg border border-zinc-300 px-4 py-3">@error('email')
{{ $message }}
@enderror
<button class="w-full rounded-full bg-zinc-900 px-5 py-3 font-semibold text-white">Send reset link</button></form>@if ($message)
{{ $message }}
@endif

content:
<?php

namespace App\Livewire\Storefront\Account\Auth;

use Illuminate\Support\Facades\Password;
use Livewire\Component;

class ForgotPassword extends Component
{
    public string $email = '';

    public string $message = '';

    public function sendResetLink(): void
    {
        $this->validate(['email' => ['required', 'email']]);
        Password::broker('customers')->sendResetLink(['email' => $this->email]);
        $this->message = 'If an account exists for that email, a reset link has been sent.';
    }

    public function render(): mixed
    {
        return view('livewire.storefront.account.auth.forgot-password')->layout('layouts.storefront');
    }
}

content:
<?php

namespace App\Livewire\Storefront\Account\Auth;

use App\Models\Customer;
use Illuminate\Support\Facades\Password;
use Livewire\Component;

class ResetPassword extends Component
{
    public string $token;

    public string $email = '';

    public string $password = '';

    public string $passwordConfirmation = '';

    public function mount(string $token): void
    {
        $this->token = $token;
        $this->email = request()->string('email')->toString();
    }

    public function resetPassword(): void
    {
        $data = $this->validate(['email' => ['required', 'email'], 'password' => ['required', 'min:8', 'same:passwordConfirmation']]);
        $status = Password::broker('customers')->reset(['email' => $data['email'], 'password' => $data['password'], 'password_confirmation' => $this->passwordConfirmation, 'token' => $this->token], function (Customer $customer, string $password): void {
            $customer->password_hash = $password;
            $customer->save();
        });

        if ($status !== Password::PASSWORD_RESET) {
            $this->addError('email', __($status));

            return;
        }

        $this->redirect(route('account.login'), navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.storefront.account.auth.reset-password')->layout('layouts.storefront');
    }
}

stdout:
Success. Updated the following files:
A /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/ForgotPassword.php
A /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/ResetPassword.php
A /Users/wesner/Herd/shop/resources/views/livewire/storefront/account/auth/forgot-password.blade.php
A /Users/wesner/Herd/shop/resources/views/livewire/storefront/account/auth/reset-password.blade.php
M /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/Register.php
M /Users/wesner/Herd/shop/app/Livewire/Storefront/Account/Auth/Login.php
M /Users/wesner/Herd/shop/routes/web.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:8000}),
  tools.exec_command({cmd:"php artisan route:list --path=forgot --except-vendor; php artisan route:list --path=reset --except-vendor",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:4000}),
  tools.exec_command({cmd:"php artisan test tests/Feature/SearchAnalyticsWebhookTest.php tests/Feature/CommerceFlowTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:12000})
]);
for (const r of results) text(r.output);


cmd: php artisan route:list --path=forgot --except-vendor; php artisan route:list --path=reset --except-vendor

aggregated_output: ERROR  Your application doesn't have any routes matching the given criteria.


command: php artisan test tests/Feature/SearchAnalyticsWebhookTest.php tests/Feature/CommerceFlowTest.php

stdout:
   PASS  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.13s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ✓ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.03s

   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.05s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s

  Tests:    7 passed (30 assertions)
  Duration: 0.37s


text:
Script completed
Wall time 1.2 seconds
Output:

text: {"result":"fixed","files":[{"path":"routes\/web.php","fixers":["ordered_imports"]}]}

text:
   ERROR  Your application doesn't have any routes matching the given criteria.

   ERROR  Your application doesn't have any routes matching the given criteria.

text:
   PASS  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.13s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.02s
  ✓ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.03s

   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.05s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s

  Tests:    7 passed (30 assertions)
  Duration: 0.37s


status: completed

input: const r = await tools.exec_command({cmd:"php artisan route:list --except-vendor | rg 'forgot|reset-password|account/login' || true",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:5000}); text(r.output);


command: php artisan route:list --except-vendor | rg 'forgot|reset-password|account/login' || true


text:
Script completed
Wall time 0.2 seconds
Output:


status: completed

input: const r = await tools.exec_command({cmd:"php artisan route:list | rg 'forgot|reset-password|account/login' || true; rg -n 'forgot-password|reset-password' routes/web.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:5000}); text(r.output);


command: php artisan route:list | rg 'forgot|reset-password|account/login' || true; rg -n 'forgot-password|reset-password' routes/web.php

stdout:
  GET|HEAD  account/login account.login › Livewire\Mechanisms › LivewirePageC…
  GET|HEAD  forgot-password password.request › Livewire\Mechanisms › Livewire…
  POST      forgot-password password.email › Laravel\Fortify › PasswordResetL…
  POST      reset-password password.update › Laravel\Fortify › NewPasswordCon…
  GET|HEAD  reset-password/{token} password.reset › Livewire\Mechanisms › Liv…
55:    Route::livewire('/forgot-password', CustomerForgotPassword::class)->name('password.request');
56:    Route::livewire('/reset-password/{token}', CustomerResetPassword::class)->name('password.reset');


text:
Script completed
Wall time 0.2 seconds
Output:

text:
  GET|HEAD  account/login account.login › Livewire\Mechanisms › LivewirePageC…
  GET|HEAD  forgot-password password.request › Livewire\Mechanisms › Livewire…
  POST      forgot-password password.email › Laravel\Fortify › PasswordResetL…
  POST      reset-password password.update › Laravel\Fortify › NewPasswordCon…
  GET|HEAD  reset-password/{token} password.reset › Livewire\Mechanisms › Liv…
55:    Route::livewire('/forgot-password', CustomerForgotPassword::class)->name('password.request');
56:    Route::livewire('/reset-password/{token}', CustomerResetPassword::class)->name('password.reset');


status: completed

input: const r = await tools.exec_command({cmd:"sed -n '1,220p' specs/02-API-ROUTES.md; sed -n '220,520p' specs/02-API-ROUTES.md | head -300",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:20000}); text(r.output);


command: sed -n '1,220p' specs/02-API-ROUTES.md; sed -n '220,520p' specs/02-API-ROUTES.md | head -300

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

stdout:
# API and Routing Specification

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

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

---

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

### 1.1 Admin Auth Routes

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

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

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

---

### 1.2 Admin Routes

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

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

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

#### Dashboard

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

#### Products

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

#### Inventory

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

#### Collections

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

#### Orders

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

#### Customers

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

#### Discounts

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

#### Settings

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

#### Themes and Online Store

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

#### Pages

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

#### Navigation

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

#### Apps

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

#### Developers

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

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

#### Analytics

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

#### Search Settings

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

---

### 1.3 Storefront Routes

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

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

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

#### Customer Account Routes

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

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

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

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

---

### 1.4 Checkout Routes

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

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

---

## 2. Storefront REST API

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

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

---

### 2.1 Cart Endpoints

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

Create a new cart.

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

**Request body:**

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

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

**Success response:** `201 Created`

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

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

---

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

Retrieve a cart with all lines and computed totals.

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

**URL parameters:**

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

**Success response:** `200 OK`

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

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

---

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

Add a line item to the cart.

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

**Request body:**

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

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

**Success response:** `201 Created`

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

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

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

---

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

Update the quantity of a cart line.

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

**Request body:**

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

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

**Success response:** `200 OK`

Returns the full cart object with updated totals.

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

---

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

Remove a line item from the cart.

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

**Request body:**

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

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

**Success response:** `200 OK`

Returns the full cart object.

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

---

### 2.2 Checkout Endpoints

#### Checkout Flow Sequence Diagram

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

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

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

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

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

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

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

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

Create a checkout from a cart.

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

**Request body:**

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

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

**Success response:** `201 Created`

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

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