Success. Updated the following files:
M specs/progress.md

@@ -435,2 +435,8 @@
 - 2026-05-04: `php artisan test --compact` passed after adding the admin auth browser suite: 256 tests, 1527 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel database seeding docs before adding the Spec 08 storefront browsing browser suite.
+- 2026-05-04: `php artisan make:test Browser/Storefront/BrowsingTest --pest --no-interaction` created the storefront browsing browser test, which was moved into the Spec 08 browser directory structure.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the storefront browsing browser suite.
+- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/BrowsingTest.php` passed for the new Suite 7 storefront browsing coverage: 15 tests, 56 assertions.
+- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php` passed after adding the storefront browsing suite: 29 tests, 176 assertions.
+- 2026-05-04: `php artisan test --compact` passed after adding the storefront browsing browser suite: 271 tests, 1583 assertions.

@@ -504,3 +510,4 @@
 - Laravel's SQLite `enum()` migrations generate database `CHECK` clauses in this app; `DatabaseConstraintTest` locks representative constraints across tenancy, roles, catalog, orders, tax settings, and exports.
-- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, and mobile storefront rendering.
+- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions and Suite 7 storefront browsing interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, mobile storefront rendering, seeded collection/product/search/static page browsing, sale/compare-at pricing, draft-product visibility guards, stock/backorder messaging, and main-navigation clicks.
+- Pest's per-visit browser `host` option is temporary, so host-sensitive storefront browser tests set the Playwright host for the whole test lifecycle to keep clicked absolute URLs resolving through `shop.test`.

@@ -508,3 +515,3 @@

-- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage.
+- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage, Suite 2 admin authentication, and Suite 7 storefront browsing.

@@ -512,2 +519,2 @@

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, Suite 7 storefront browsing browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.


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


git diff -- tests/Browser/Storefront/BrowsingTest.php specs/progress.md


find .playwright-mcp -maxdepth 1 -type f -name '*2026-05-04*' -print


git diff -- tests/Browser/Storefront/BrowsingTest.php specs/progress.md

diff --git a/specs/progress.md b/specs/progress.md
index 3ce41978..8914ae05 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -433,6 +433,12 @@ ## Verification Evidence
 - 2026-05-04: `php artisan test --compact tests/Browser/Admin/AuthenticationTest.php` passed for the new Suite 2 admin auth browser coverage: 10 tests, 54 assertions.
 - 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php` passed after adding the admin auth suite: 14 tests, 120 assertions.
 - 2026-05-04: `php artisan test --compact` passed after adding the admin auth browser suite: 256 tests, 1527 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel database seeding docs before adding the Spec 08 storefront browsing browser suite.
+- 2026-05-04: `php artisan make:test Browser/Storefront/BrowsingTest --pest --no-interaction` created the storefront browsing browser test, which was moved into the Spec 08 browser directory structure.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the storefront browsing browser suite.
+- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/BrowsingTest.php` passed for the new Suite 7 storefront browsing coverage: 15 tests, 56 assertions.
+- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php` passed after adding the storefront browsing suite: 29 tests, 176 assertions.
+- 2026-05-04: `php artisan test --compact` passed after adding the storefront browsing browser suite: 271 tests, 1583 assertions.

 ## Decisions

@@ -502,12 +508,13 @@ ## Decisions
 - Product media uploads use Livewire temporary file uploads on the existing product form, create `ProductMedia` rows in `processing` status, and dispatch the existing `ProcessMediaUpload` job instead of adding a second media-processing path.
 - Resource policies now type concrete model parameters for Product, Collection, Customer, Discount, Order, Fulfillment, Refund, Page, and Theme actions; `ChecksStoreRole::storeIdForModel()` stays generic because it is the shared store-id extraction helper.
 - Laravel's SQLite `enum()` migrations generate database `CHECK` clauses in this app; `DatabaseConstraintTest` locks representative constraints across tenancy, roles, catalog, orders, tax settings, and exports.
-- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, and mobile storefront rendering.
+- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions and Suite 7 storefront browsing interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, mobile storefront rendering, seeded collection/product/search/static page browsing, sale/compare-at pricing, draft-product visibility guards, stock/backorder messaging, and main-navigation clicks.
+- Pest's per-visit browser `host` option is temporary, so host-sensitive storefront browser tests set the Playwright host for the whole test lifecycle to keep clicked absolute URLs resolving through `shop.test`.

 ## Open Issues

-- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage.
+- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage, Suite 2 admin authentication, and Suite 7 storefront browsing.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, Suite 7 storefront browsing browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.


 M specs/progress.md
?? tests/Browser/Storefront/


Output:
Chunk ID: df27a8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 13
Output:
 M specs/progress.md
?? tests/Browser/Storefront/


Output:
Chunk ID: 704f74
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1514
Output:
diff --git a/specs/progress.md b/specs/progress.md
index 3ce41978..8914ae05 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -433,6 +433,12 @@ ## Verification Evidence
 - 2026-05-04: `php artisan test --compact tests/Browser/Admin/AuthenticationTest.php` passed for the new Suite 2 admin auth browser coverage: 10 tests, 54 assertions.
 - 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php` passed after adding the admin auth suite: 14 tests, 120 assertions.
 - 2026-05-04: `php artisan test --compact` passed after adding the admin auth browser suite: 256 tests, 1527 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel database seeding docs before adding the Spec 08 storefront browsing browser suite.
+- 2026-05-04: `php artisan make:test Browser/Storefront/BrowsingTest --pest --no-interaction` created the storefront browsing browser test, which was moved into the Spec 08 browser directory structure.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the storefront browsing browser suite.
+- 2026-05-04: `php artisan test --compact tests/Browser/Storefront/BrowsingTest.php` passed for the new Suite 7 storefront browsing coverage: 15 tests, 56 assertions.
+- 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php tests/Browser/Admin/AuthenticationTest.php tests/Browser/Storefront/BrowsingTest.php` passed after adding the storefront browsing suite: 29 tests, 176 assertions.
+- 2026-05-04: `php artisan test --compact` passed after adding the storefront browsing browser suite: 271 tests, 1583 assertions.

 ## Decisions

@@ -502,12 +508,13 @@ ## Decisions
 - Product media uploads use Livewire temporary file uploads on the existing product form, create `ProductMedia` rows in `processing` status, and dispatch the existing `ProcessMediaUpload` job instead of adding a second media-processing path.
 - Resource policies now type concrete model parameters for Product, Collection, Customer, Discount, Order, Fulfillment, Refund, Page, and Theme actions; `ChecksStoreRole::storeIdForModel()` stays generic because it is the shared store-id extraction helper.
 - Laravel's SQLite `enum()` migrations generate database `CHECK` clauses in this app; `DatabaseConstraintTest` locks representative constraints across tenancy, roles, catalog, orders, tax settings, and exports.
-- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, and mobile storefront rendering.
+- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions and Suite 7 storefront browsing interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, mobile storefront rendering, seeded collection/product/search/static page browsing, sale/compare-at pricing, draft-product visibility guards, stock/backorder messaging, and main-navigation clicks.
+- Pest's per-visit browser `host` option is temporary, so host-sensitive storefront browser tests set the Playwright host for the whole test lifecycle to keep clicked absolute URLs resolving through `shop.test`.

 ## Open Issues

-- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage.
+- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage, Suite 2 admin authentication, and Suite 7 storefront browsing.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, Suite 7 storefront browsing browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.


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


sed -n '1,220p' tests/Browser/Storefront/BrowsingTest.php


sed -n '1,220p' tests/Browser/Storefront/BrowsingTest.php

<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Pest\Browser\Playwright\Playwright;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Playwright::setHost('shop.test');

    $this->seed(DatabaseSeeder::class);
});

afterEach(function (): void {
    Playwright::setHost(null);
});

function storefrontBrowsingHost(): array
{
    return ['host' => 'shop.test'];
}

test('shows featured products on home page', function (): void {
    visit('/', storefrontBrowsingHost())
        ->assertSee('Acme Fashion')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99')
        ->assertNoJavaScriptErrors();
});

test('shows collection with product grid', function (): void {
    visit('/collections/t-shirts', storefrontBrowsingHost())
        ->assertSee('T-Shirts')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertNoJavaScriptErrors();
});

test('can navigate from collection to product', function (): void {
    visit('/collections/t-shirts', storefrontBrowsingHost())
        ->click('a[href$="/products/classic-cotton-t-shirt"]')
        ->wait(1)
        ->assertPathIs('/products/classic-cotton-t-shirt')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99')
        ->assertSee('Add to cart')
        ->assertNoJavaScriptErrors();
});

test('shows product detail with variant options', function (): void {
    visit('/products/classic-cotton-t-shirt', storefrontBrowsingHost())
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99')
        ->assertSee('Size')
        ->assertSee('Color')
        ->assertNoJavaScriptErrors();
});

test('shows size and color option values', function (): void {
    visit('/products/classic-cotton-t-shirt', storefrontBrowsingHost())
        ->assertSee('S')
        ->assertSee('M')
        ->assertSee('L')
        ->assertSee('XL')
        ->assertSee('Black')
        ->assertSee('White')
        ->assertSee('Navy')
        ->assertNoJavaScriptErrors();
});

test('shows sale and compare at pricing for sale product', function (): void {
    visit('/products/premium-slim-fit-jeans', storefrontBrowsingHost())
        ->assertSee('Premium Slim Fit Jeans')
        ->assertSee('79.99')
        ->assertSee('99.99')
        ->assertPresent('.line-through')
        ->assertNoJavaScriptErrors();
});

test('shows search results for valid query', function (): void {
    visit('/search?q=cotton', storefrontBrowsingHost())
        ->assertSee('Classic Cotton T-Shirt')
        ->assertNoJavaScriptErrors();
});

test('shows no results message for invalid query', function (): void {
    visit('/search?q=zznonexistentproductzz', storefrontBrowsingHost())
        ->assertSee('No products found')
        ->assertNoJavaScriptErrors();
});

test('does not show draft products on storefront collections', function (): void {
    visit('/collections', storefrontBrowsingHost())
        ->assertDontSee('Unreleased Winter Jacket')
        ->assertNoJavaScriptErrors();
});

test('does not show draft products in search results', function (): void {
    visit('/search?q=draft', storefrontBrowsingHost())
        ->assertDontSee('Unreleased Winter Jacket')
        ->assertNoJavaScriptErrors();
});

test('shows out of stock messaging for deny policy product', function (): void {
    visit('/products/limited-edition-sneakers', storefrontBrowsingHost())
        ->assertSee('Limited Edition Sneakers')
        ->assertSee('Out of stock')
        ->assertSee('Sold out')
        ->assertButtonDisabled('button:has-text("Sold out")')
        ->assertDontSee('Add to cart')
        ->assertNoJavaScriptErrors();
});

test('shows backorder messaging for continue policy product', function (): void {
    visit('/products/backorder-denim-jacket', storefrontBrowsingHost())
        ->assertSee('Backorder Denim Jacket')
        ->assertSee('Available on backorder')
        ->assertSee('Add to cart')
        ->assertButtonEnabled('button:has-text("Add to cart")')
        ->assertNoJavaScriptErrors();
});

test('shows new arrivals collection', function (): void {
    visit('/collections/new-arrivals', storefrontBrowsingHost())
        ->assertSee('New Arrivals')
        ->assertNoJavaScriptErrors();
});

test('shows static about page', function (): void {
    visit('/pages/about', storefrontBrowsingHost())
        ->assertSee('About')
        ->assertNoJavaScriptErrors();
});

test('navigates between pages using the main navigation', function (): void {
    visit('/', storefrontBrowsingHost())
        ->hover('nav[aria-label="Main navigation"] a[href$="/collections"]')
        ->click('nav[aria-label="Main navigation"] a[href$="/collections/t-shirts"]')
        ->wait(1)
        ->assertPathIs('/collections/t-shirts')
        ->assertSee('T-Shirts')
        ->assertNoJavaScriptErrors();
});


Output:
Chunk ID: bc36f4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1219
Output:
<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Pest\Browser\Playwright\Playwright;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Playwright::setHost('shop.test');

    $this->seed(DatabaseSeeder::class);
});

afterEach(function (): void {
    Playwright::setHost(null);
});

function storefrontBrowsingHost(): array
{
    return ['host' => 'shop.test'];
}

test('shows featured products on home page', function (): void {
    visit('/', storefrontBrowsingHost())
        ->assertSee('Acme Fashion')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99')
        ->assertNoJavaScriptErrors();
});

test('shows collection with product grid', function (): void {
    visit('/collections/t-shirts', storefrontBrowsingHost())
        ->assertSee('T-Shirts')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertNoJavaScriptErrors();
});

test('can navigate from collection to product', function (): void {
    visit('/collections/t-shirts', storefrontBrowsingHost())
        ->click('a[href$="/products/classic-cotton-t-shirt"]')
        ->wait(1)
        ->assertPathIs('/products/classic-cotton-t-shirt')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99')
        ->assertSee('Add to cart')
        ->assertNoJavaScriptErrors();
});

test('shows product detail with variant options', function (): void {
    visit('/products/classic-cotton-t-shirt', storefrontBrowsingHost())
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99')
        ->assertSee('Size')
        ->assertSee('Color')
        ->assertNoJavaScriptErrors();
});

test('shows size and color option values', function (): void {
    visit('/products/classic-cotton-t-shirt', storefrontBrowsingHost())
        ->assertSee('S')
        ->assertSee('M')
        ->assertSee('L')
        ->assertSee('XL')
        ->assertSee('Black')
        ->assertSee('White')
        ->assertSee('Navy')
        ->assertNoJavaScriptErrors();
});

test('shows sale and compare at pricing for sale product', function (): void {
    visit('/products/premium-slim-fit-jeans', storefrontBrowsingHost())
        ->assertSee('Premium Slim Fit Jeans')
        ->assertSee('79.99')
        ->assertSee('99.99')
        ->assertPresent('.line-through')
        ->assertNoJavaScriptErrors();
});

test('shows search results for valid query', function (): void {
    visit('/search?q=cotton', storefrontBrowsingHost())
        ->assertSee('Classic Cotton T-Shirt')
        ->assertNoJavaScriptErrors();
});

test('shows no results message for invalid query', function (): void {
    visit('/search?q=zznonexistentproductzz', storefrontBrowsingHost())
        ->assertSee('No products found')
        ->assertNoJavaScriptErrors();
});

test('does not show draft products on storefront collections', function (): void {
    visit('/collections', storefrontBrowsingHost())
        ->assertDontSee('Unreleased Winter Jacket')
        ->assertNoJavaScriptErrors();
});

test('does not show draft products in search results', function (): void {
    visit('/search?q=draft', storefrontBrowsingHost())
        ->assertDontSee('Unreleased Winter Jacket')
        ->assertNoJavaScriptErrors();
});

test('shows out of stock messaging for deny policy product', function (): void {
    visit('/products/limited-edition-sneakers', storefrontBrowsingHost())
        ->assertSee('Limited Edition Sneakers')
        ->assertSee('Out of stock')
        ->assertSee('Sold out')
        ->assertButtonDisabled('button:has-text("Sold out")')
        ->assertDontSee('Add to cart')
        ->assertNoJavaScriptErrors();
});

test('shows backorder messaging for continue policy product', function (): void {
    visit('/products/backorder-denim-jacket', storefrontBrowsingHost())
        ->assertSee('Backorder Denim Jacket')
        ->assertSee('Available on backorder')
        ->assertSee('Add to cart')
        ->assertButtonEnabled('button:has-text("Add to cart")')
        ->assertNoJavaScriptErrors();
});

test('shows new arrivals collection', function (): void {
    visit('/collections/new-arrivals', storefrontBrowsingHost())
        ->assertSee('New Arrivals')
        ->assertNoJavaScriptErrors();
});

test('shows static about page', function (): void {
    visit('/pages/about', storefrontBrowsingHost())
        ->assertSee('About')
        ->assertNoJavaScriptErrors();
});

test('navigates between pages using the main navigation', function (): void {
    visit('/', storefrontBrowsingHost())
        ->hover('nav[aria-label="Main navigation"] a[href$="/collections"]')
        ->click('nav[aria-label="Main navigation"] a[href$="/collections/t-shirts"]')
        ->wait(1)
        ->assertPathIs('/collections/t-shirts')
        ->assertSee('T-Shirts')
        ->assertNoJavaScriptErrors();
});


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


Output:
Chunk ID: b5fa0e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 13
Output:
 M specs/progress.md
?? tests/Browser/Storefront/


The diff is limited to the new storefront browser suite and the progress log. I’m committing this as the next verified Spec 08 slice.


git add tests/Browser/Storefront/BrowsingTest.php specs/progress.md


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


git commit -m "Add storefront browsing browser suite"


git commit -m "Add storefront browsing browser suite"

[2026-05-04-codex-5-5-goal 441f10af] Add storefront browsing browser suite
 2 files changed, 153 insertions(+), 3 deletions(-)
 create mode 100644 tests/Browser/Storefront/BrowsingTest.php


Output:
Chunk ID: 41cbb5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 48
Output:
[2026-05-04-codex-5-5-goal 441f10af] Add storefront browsing browser suite
 2 files changed, 153 insertions(+), 3 deletions(-)
 create mode 100644 tests/Browser/Storefront/BrowsingTest.php


Committed `441f10af` for Suite 7. I’m moving to Spec 08 Suite 8 now, which covers the cart browser flow; first I’ll read the spec and current cart UI/actions so the test follows the built workflow instead of guessing selectors.


browser testing click fill wait assert disabled

livewire browser test component click update


sed -n '1420,1715p' specs/08-PLAYWRIGHT-E2E-PLAN.md


sed -n '1,280p' resources/views/livewire/storefront/cart/show.blade.php


sed -n '1,260p' app/Livewire/Storefront/Cart/Show.php


sed -n '1,260p' app/Livewire/Storefront/Products/Show.php


# Search Results

## Database Testing Running Seeders
Database Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md

## Running Seeders

If you would like to use [database seeders](/docs/{{version}}/seeding) to populate your database during a feature test, you may invoke the `seed` method. By default, the `seed` method will execute the `DatabaseSeeder`, which should execute all of your other seeders. Alternatively, you pass a specific seeder class name to the `seed` method:

```php tab=Pest
<?php

use Database\Seeders\OrderStatusSeeder;
use Database\Seeders\TransactionStatusSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('orders can be created', function () {
    // Run the DatabaseSeeder...
    $this->seed();

    // Run a specific seeder...
    $this->seed(OrderStatusSeeder::class);

    // ...

    // Run an array of specific seeders...
    $this->seed([
        OrderStatusSeeder::class,
        TransactionStatusSeeder::class,
        // ...
    ]);
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Database\Seeders\OrderStatusSeeder;
use Database\Seeders\TransactionStatusSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Test creating a new order.
     */
    public function test_orders_can_be_created(): void
    {
        // Run the DatabaseSeeder...
        $this->seed();

        // Run a specific seeder...
        $this->seed(OrderStatusSeeder::class);

        // ...

        // Run an array of specific seeders...
        $this->seed([
            OrderStatusSeeder::class,
            TransactionStatusSeeder::class,
            // ...
        ]);
    }
}
```

Alternatively, you may instruct Laravel to automatically seed the database before each test that uses the `RefreshDatabase` trait. You may accomplish this by defining a `$seed` property on your base test class:

```php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    /**
     * Indicates whether the default seeder should run before each test.
     *
     * @var bool
     */
    protected $seed = true;
}
```

When the `$seed` property is `true`, the test will run the `Database\Seeders\DatabaseSeeder` class before each test that uses the `RefreshDatabase` trait. However, you may specify a specific seeder that should be executed by defining a `$seeder` property on your test class:

```php
use Database\Seeders\OrderStatusSeeder;

/**
 * Run a specific seeder before each test.
 *
 * @var string
 */
protected $seeder = OrderStatusSeeder::class;
```

---

## Wire Transition Browser support
Wire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition

## Browser support

View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.

> [!warning] Firefox has limited support
> Firefox 144+ supports basic view transitions, but does not support transition types.

[View browser support on caniuse.com →](https://caniuse.com/view-transitions)

---

## Testing Browser testing
Testing | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing

## Browser testing

Pest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.

### Installing browser testing

First, install the Pest browser plugin:

```shell
composer require pestphp/pest-plugin-browser --dev
```

Next, install Playwright via npm:

```shell
npm install playwright@latest
npx playwright install
```

For complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).

### Writing browser tests

Instead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:

```php
it('can create a new post', function () {
    Livewire::visit('post.create')
        ->type('[wire\:model="title"]', 'My first post')
        ->type('[wire\:model="content"]', 'This is the content')
        ->press('Save')
        ->assertSee('Post created successfully');
});
```

Browser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.

For a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).

> [!info] When to use browser tests
> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.

---

## Verification Introduction Database Preparation
Verification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md

### Database Preparation

Next, your `users` table must contain an `email_verified_at` column to store the date and time that the user's email address was verified. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` database migration.

---

## Browser Testing click
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### click

The `click` method clicks the link with the given text:

```php
$page->click('Login');
```

You may also pass options:

```php
$page->click('#button', options: ['clickCount' => 2]);
```

---

## Browser Testing assertDisabled
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertDisabled

The `assertDisabled` method asserts that the given field is disabled:

```php
$page->assertDisabled('submit');
```

---

## Upgrade Upgrading To 12.0 From 11.x Database Database Constructor Signature Changes
Upgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md

#### Database Constructor Signature Changes

**Likelihood Of Impact: Very Low**

In Laravel 12, several low-level database classes now require an `Illuminate\Database\Connection` instance to be provided via their constructors.

**These changes are primarily applicable to database package maintainers - it is extremely unlikely any of these changes affect normal application development.**

`Illuminate\Database\Schema\Blueprint`

The constructor of the `Illuminate\Database\Schema\Blueprint` class now expects a `Connection` instance as its first argument. This primarily affects applications or packages that manually instantiate `Blueprint` instances.

`Illuminate\Database\Grammar`

The constructor of the `Illuminate\Database\Grammar` class also now requires a `Connection` instance. In previous versions, the connection was assigned after construction using the `setConnection()` method. This method has been removed in Laravel 12:

```php
// Laravel <= 11.x
$grammar = new MySqlGrammar;
$grammar->setConnection($connection);

// Laravel >= 12.x
$grammar = new MySqlGrammar($connection);
````

In addition, the following APIs have been removed or deprecated:

- The `Blueprint::getPrefix()` method is deprecated.
- The `Connection::withTablePrefix()` method has been removed.
- The `Grammar::getTablePrefix()` and `setTablePrefix()` methods are deprecated.
- The `Grammar::setConnection()` method has been removed.

When working with table prefixes, you should now retrieve them directly from the database connection:

```php
$prefix = $connection->getTablePrefix();
```

If you maintain custom database drivers, schema builders, or grammar implementations, you should review their constructors and ensure a `Connection` instance is provided.

---

## Styles Browser support
Styles | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/styles

## Browser support

Scoped styles use [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting), which is supported in all modern browsers (Chrome 120+, Firefox 117+, Safari 17.2+). For older browser support, consider using a CSS preprocessor or the `@assets` directive with pre-compiled stylesheets.

---

## Installation Components not updating or errors in browser console
Installation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/installation

## Components not updating or errors in browser console

**Check the following:**
- Ensure `@livewireStyles` is in the `<head>` of your layout
- Ensure `@livewireScripts` is before `</body>` in your layout
- Check your browser's developer console for JavaScript errors
- Verify you're running a supported PHP version (8.1+) and Laravel version (10+)
- Clear your application cache: `php artisan cache:clear`

If issues persist, check the [troubleshooting documentation](/docs/4.x/troubleshooting) for more detailed debugging steps.

---

## Upgrade Upgrading To 12.0 From 11.x Database Multi-Schema Database Inspecting
Upgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md

#### Multi-Schema Database Inspecting

**Likelihood Of Impact: Low**

The `Schema::getTables()`, `Schema::getViews()`, and `Schema::getTypes()` methods now include the results from all schemas by default. You may pass the `schema` argument to retrieve the result for the given schema only:

```php
// All tables on all schemas...
$tables = Schema::getTables();

// All tables on the 'main' schema...
$tables = Schema::getTables(schema: 'main');

// All tables on the 'main' and 'blog' schemas...
$tables = Schema::getTables(schema: ['main', 'blog']);
```

The `Schema::getTableListing()` method now returns schema-qualified table names by default. You may pass the `schemaQualified` argument to change the behavior as desired:

```php
$tables = Schema::getTableListing();
// ['main.migrations', 'main.users', 'blog.posts']

$tables = Schema::getTableListing(schema: 'main');
// ['main.migrations', 'main.users']

$tables = Schema::getTableListing(schema: 'main', schemaQualified: false);
// ['migrations', 'users']
```

The `db:table` and `db:show` commands now output the results of all schemas on MySQL, MariaDB, and SQLite, just like PostgreSQL and SQL Server.

---

## Browser Testing assertButtonDisabled
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertButtonDisabled

The `assertButtonDisabled` method asserts that the given button is disabled:

```php
$page->assertButtonDisabled('Submit');
```

## URL Assertions

---

## Testing Running Tests Running Tests in Parallel Parallel Testing and Databases
Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/testing.md

#### Parallel Testing and Databases

As long as you have configured a primary database connection, Laravel automatically handles creating and migrating a test database for each parallel process that is running your tests. The test databases will be suffixed with a process token which is unique per process. For example, if you have two parallel test processes, Laravel will create and use `your_db_test_1` and `your_db_test_2` test databases.

By default, test databases persist between calls to the `test` Artisan command so that they can be used again by subsequent `test` invocations. However, you may re-create them using the `--recreate-databases` option:

```shell
php artisan test --parallel --recreate-databases
```

_[Truncated]_


Output:
Wall time: 3.3553 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Database Testing Running Seeders\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n## Running Seeders\n\nIf you would like to use [database seeders](/docs/{{version}}/seeding) to populate your database during a feature test, you may invoke the `seed` method. By default, the `seed` method will execute the `DatabaseSeeder`, which should execute all of your other seeders. Alternatively, you pass a specific seeder class name to the `seed` method:\n\n```php tab=Pest\n<?php\n\nuse Database\\Seeders\\OrderStatusSeeder;\nuse Database\\Seeders\\TransactionStatusSeeder;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\n\npest()->use(RefreshDatabase::class);\n\ntest('orders can be created', function () {\n    // Run the DatabaseSeeder...\n    $this->seed();\n\n    // Run a specific seeder...\n    $this->seed(OrderStatusSeeder::class);\n\n    // ...\n\n    // Run an array of specific seeders...\n    $this->seed([\n        OrderStatusSeeder::class,\n        TransactionStatusSeeder::class,\n        // ...\n    ]);\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse Database\\Seeders\\OrderStatusSeeder;\nuse Database\\Seeders\\TransactionStatusSeeder;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    use RefreshDatabase;\n\n    /**\n     * Test creating a new order.\n     */\n    public function test_orders_can_be_created(): void\n    {\n        // Run the DatabaseSeeder...\n        $this->seed();\n\n        // Run a specific seeder...\n        $this->seed(OrderStatusSeeder::class);\n\n        // ...\n\n        // Run an array of specific seeders...\n        $this->seed([\n            OrderStatusSeeder::class,\n            TransactionStatusSeeder::class,\n            // ...\n        ]);\n    }\n}\n```\n\nAlternatively, you may instruct Laravel to automatically seed the database before each test that uses the `RefreshDatabase` trait. You may accomplish this by defining a `$seed` property on your base test class:\n\n```php\n<?php\n\nnamespace Tests;\n\nuse Illuminate\\Foundation\\Testing\\TestCase as BaseTestCase;\n\nabstract class TestCase extends BaseTestCase\n{\n    /**\n     * Indicates whether the default seeder should run before each test.\n     *\n     * @var bool\n     */\n    protected $seed = true;\n}\n```\n\nWhen the `$seed` property is `true`, the test will run the `Database\\Seeders\\DatabaseSeeder` class before each test that uses the `RefreshDatabase` trait. However, you may specify a specific seeder that should be executed by defining a `$seeder` property on your test class:\n\n```php\nuse Database\\Seeders\\OrderStatusSeeder;\n\n/**\n * Run a specific seeder before each test.\n *\n * @var string\n */\nprotected $seeder = OrderStatusSeeder::class;\n```\n\n---\n\n## Wire Transition Browser support\nWire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition\n\n## Browser support\n\nView Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.\n\n> [!warning] Firefox has limited support\n> Firefox 144+ supports basic view transitions, but does not support transition types.\n\n[View browser support on caniuse.com →](https://caniuse.com/view-transitions)\n\n---\n\n## Testing Browser testing\nTesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing\n\n## Browser testing\n\nPest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.\n\n### Installing browser testing\n\nFirst, install the Pest browser plugin:\n\n```shell\ncomposer require pestphp/pest-plugin-browser --dev\n```\n\nNext, install Playwright via npm:\n\n```shell\nnpm install playwright@latest\nnpx playwright install\n```\n\nFor complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).\n\n### Writing browser tests\n\nInstead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:\n\n```php\nit('can create a new post', function () {\n    Livewire::visit('post.create')\n        ->type('[wire\\:model=\"title\"]', 'My first post')\n        ->type('[wire\\:model=\"content\"]', 'This is the content')\n        ->press('Save')\n        ->assertSee('Post created successfully');\n});\n```\n\nBrowser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.\n\nFor a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).\n\n> [!info] When to use browser tests\n> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.\n\n---\n\n## Verification Introduction Database Preparation\nVerification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md\n\n\n### Database Preparation\n\nNext, your `users` table must contain an `email_verified_at` column to store the date and time that the user's email address was verified. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` database migration.\n\n---\n\n## Browser Testing click\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### click\n\nThe `click` method clicks the link with the given text:\n\n```php\n$page->click('Login');\n```\n\nYou may also pass options:\n\n```php\n$page->click('#button', options: ['clickCount' => 2]);\n```\n\n---\n\n## Browser Testing assertDisabled\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### assertDisabled\n\nThe `assertDisabled` method asserts that the given field is disabled:\n\n```php\n$page->assertDisabled('submit');\n```\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Database Database Constructor Signature Changes\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Database Constructor Signature Changes\n\n**Likelihood Of Impact: Very Low**\n\nIn Laravel 12, several low-level database classes now require an `Illuminate\\Database\\Connection` instance to be provided via their constructors.\n\n**These changes are primarily applicable to database package maintainers - it is extremely unlikely any of these changes affect normal application development.**\n\n`Illuminate\\Database\\Schema\\Blueprint`\n\nThe constructor of the `Illuminate\\Database\\Schema\\Blueprint` class now expects a `Connection` instance as its first argument. This primarily affects applications or packages that manually instantiate `Blueprint` instances.\n\n`Illuminate\\Database\\Grammar`\n\nThe constructor of the `Illuminate\\Database\\Grammar` class also now requires a `Connection` instance. In previous versions, the connection was assigned after construction using the `setConnection()` method. This method has been removed in Laravel 12:\n\n```php\n// Laravel <= 11.x\n$grammar = new MySqlGrammar;\n$grammar->setConnection($connection);\n\n// Laravel >= 12.x\n$grammar = new MySqlGrammar($connection);\n````\n\nIn addition, the following APIs have been removed or deprecated:\n\n
\n\n- The `Blueprint::getPrefix()` method is deprecated.\n- The `Connection::withTablePrefix()` method has been removed.\n- The `Grammar::getTablePrefix()` and `setTablePrefix()` methods are deprecated.\n- The `Grammar::setConnection()` method has been removed.\n\n
\n\nWhen working with table prefixes, you should now retrieve them directly from the database connection:\n\n```php\n$prefix = $connection->getTablePrefix();\n```\n\nIf you maintain custom database drivers, schema builders, or grammar implementations, you should review their constructors and ensure a `Connection` instance is provided.\n\n---\n\n## Styles Browser support\nStyles | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/styles\n\n## Browser support\n\nScoped styles use [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting), which is supported in all modern browsers (Chrome 120+, Firefox 117+, Safari 17.2+). For older browser support, consider using a CSS preprocessor or the `@assets` directive with pre-compiled stylesheets.\n\n---\n\n## Installation Components not updating or errors in browser console\nInstallation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/installation\n\n## Components not updating or errors in browser console\n\n**Check the following:**\n- Ensure `@livewireStyles` is in the `<head>` of your layout\n- Ensure `@livewireScripts` is before `</body>` in your layout\n- Check your browser's developer console for JavaScript errors\n- Verify you're running a supported PHP version (8.1+) and Laravel version (10+)\n- Clear your application cache: `php artisan cache:clear`\n\nIf issues persist, check the [troubleshooting documentation](/docs/4.x/troubleshooting) for more detailed debugging steps.\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Database Multi-Schema Database Inspecting\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Multi-Schema Database Inspecting\n\n**Likelihood Of Impact: Low**\n\nThe `Schema::getTables()`, `Schema::getViews()`, and `Schema::getTypes()` methods now include the results from all schemas by default. You may pass the `schema` argument to retrieve the result for the given schema only:\n\n```php\n// All tables on all schemas...\n$tables = Schema::getTables();\n\n// All tables on the 'main' schema...\n$tables = Schema::getTables(schema: 'main');\n\n// All tables on the 'main' and 'blog' schemas...\n$tables = Schema::getTables(schema: ['main', 'blog']);\n```\n\nThe `Schema::getTableListing()` method now returns schema-qualified table names by default. You may pass the `schemaQualified` argument to change the behavior as desired:\n\n```php\n$tables = Schema::getTableListing();\n// ['main.migrations', 'main.users', 'blog.posts']\n\n$tables = Schema::getTableListing(schema: 'main');\n// ['main.migrations', 'main.users']\n\n$tables = Schema::getTableListing(schema: 'main', schemaQualified: false);\n// ['migrations', 'users']\n```\n\nThe `db:table` and `db:show` commands now output the results of all schemas on MySQL, MariaDB, and SQLite, just like PostgreSQL and SQL Server.\n\n---\n\n## Browser Testing assertButtonDisabled\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### assertButtonDisabled\n\nThe `assertButtonDisabled` method asserts that the given button is disabled:\n\n```php\n$page->assertButtonDisabled('Submit');\n```\n\n## URL Assertions\n\n---\n\n## Testing Running Tests Running Tests in Parallel Parallel Testing and Databases\nTesting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/testing.md\n\n\n#### Parallel Testing and Databases\n\nAs long as you have configured a primary database connection, Laravel automatically handles creating and migrating a test database for each parallel process that is running your tests. The test databases will be suffixed with a process token which is unique per process. For example, if you have two parallel test processes, Laravel will create and use `your_db_test_1` and `your_db_test_2` test databases.\n\nBy default, test databases persist between calls to the `test` Artisan command so that they can be used again by subsequent `test` invocations. However, you may re-create them using the `--recreate-databases` option:\n\n```shell\nphp artisan test --parallel --recreate-databases\n```\n\n_[Truncated]_"}]


sed -n '1,260p' app/Livewire/Storefront/Products/Show.php

<?php

namespace App\Livewire\Storefront\Products;

use App\Enums\InventoryPolicy;
use App\Models\Customer;
use App\Models\Product;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\CartService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Show extends Component
{
    public string $handle;

    public int $storeId;

    /**
     * @var array<string, string>
     */
    public array $selectedOptions = [];

    public int $quantity = 1;

    public function mount(string $handle): void
    {
        $this->handle = $handle;
        $this->storeId = $this->store()->getKey();

        $variant = $this->product()->variants->firstWhere('is_default', true)
            ?? $this->product()->variants->first();

        if ($variant instanceof ProductVariant) {
            $this->selectedOptions = $variant->optionValues
                ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value])
                ->all();
        }
    }

    public function selectOption(string $optionName, string $value): void
    {
        $this->selectedOptions[$optionName] = $value;
        $this->quantity = 1;
    }

    public function increaseQuantity(): void
    {
        $variant = $this->selectedVariant();
        $max = $variant?->inventoryItem?->policy === InventoryPolicy::Deny
            ? max(1, $variant->inventoryItem->availableQuantity())
            : null;

        if ($max === null || $this->quantity < $max) {
            $this->quantity++;
        }
    }

    public function decreaseQuantity(): void
    {
        $this->quantity = max(1, $this->quantity - 1);
    }

    public function addToCart(): void
    {
        $store = $this->store();
        $variant = $this->selectedVariant();

        if (! $variant instanceof ProductVariant || ! $this->canAddToCart()) {
            return;
        }

        $customer = Auth::guard('customer')->user();
        $customer = $customer instanceof Customer ? $customer : null;

        app(CartService::class)->addLine(
            app(CartService::class)->getOrCreateForSession($store, $customer),
            $variant->getKey(),
            $this->quantity,
        );

        $this->dispatch('cart-updated');
        $this->dispatch('toast', type: 'success', message: __('Added to cart'));
    }

    public function store(): Store
    {
        if (isset($this->storeId)) {
            $store = Store::query()->findOrFail($this->storeId);
            app()->instance('current_store', $store);

            return $store;
        }

        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    public function product(): Product
    {
        $this->store();

        return Product::query()
            ->with(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections'])
            ->where('handle', $this->handle)
            ->where('status', 'active')
            ->whereNotNull('published_at')
            ->firstOrFail();
    }

    public function selectedVariant(): ?ProductVariant
    {
        $product = $this->product();

        if ($product->options->isEmpty()) {
            return $product->variants->first();
        }

        return $product->variants->first(function (ProductVariant $variant): bool {
            $options = $variant->optionValues
                ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value])
                ->all();

            return $options === $this->selectedOptions;
        });
    }

    public function canAddToCart(): bool
    {
        $variant = $this->selectedVariant();

        if (! $variant instanceof ProductVariant || ! $variant->isPurchasable()) {
            return false;
        }

        $inventory = $variant->inventoryItem;

        return $inventory?->policy === InventoryPolicy::Continue
            || ($inventory?->availableQuantity() ?? 0) >= $this->quantity;
    }

    /**
     * @return array{message: string, class: string}
     */
    public function stockState(): array
    {
        $variant = $this->selectedVariant();
        $inventory = $variant?->inventoryItem;

        if (! $inventory) {
            return ['message' => 'Unavailable', 'class' => 'text-red-700 dark:text-red-300'];
        }

        if ($inventory->policy === InventoryPolicy::Continue && $inventory->availableQuantity() <= 0) {
            return ['message' => 'Available on backorder', 'class' => 'text-blue-700 dark:text-blue-300'];
        }

        if ($inventory->availableQuantity() <= 0) {
            return ['message' => 'Out of stock', 'class' => 'text-red-700 dark:text-red-300'];
        }

        if ($inventory->availableQuantity() <= 10) {
            return ['message' => "Only {$inventory->availableQuantity()} left in stock", 'class' => 'text-amber-700 dark:text-amber-300'];
        }

        return ['message' => 'In stock', 'class' => 'text-green-700 dark:text-green-300'];
    }

    public function render(): mixed
    {
        $product = $this->product();

        return view('livewire.storefront.products.show', [
            'product' => $product,
            'selectedVariant' => $this->selectedVariant(),
            'stockState' => $this->stockState(),
        ])->layout('layouts.storefront', [
            'title' => $product->title,
        ]);
    }
}


sed -n '1420,1715p' specs/08-PLAYWRIGHT-E2E-PLAN.md

## Suite 8: Cart Flow

**File:** `tests/Browser/Storefront/CartTest.php`

**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.

### Cart Interaction Flow

```mermaid
flowchart TD
    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]
    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]
    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]
    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]
    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}
    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]
    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]
    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]
    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]
    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]
```

### Discount Code Validation Matrix

| Code | Type | Value | Min Purchase | Status | Expected Behavior |
|------|------|-------|-------------|--------|-------------------|
| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |
| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |
| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |
| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with "expired" error |
| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with "usage limit" error |
| INVALID | n/a | n/a | n/a | does not exist | Rejected with "Invalid discount code" error |

---

### Test 8.1: Can add product to cart

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"

**Expected Results:**
- Page displays "Classic Cotton T-Shirt"
- Page displays "24.99"
- No JavaScript errors

---

### Test 8.2: Can view cart with added item

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`

**Expected Results:**
- Page displays "Your Cart"
- Page displays "Classic Cotton T-Shirt"
- Page displays "24.99"
- No JavaScript errors

---

### Test 8.3: Can update quantity in cart

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Verify "Classic Cotton T-Shirt" is visible
7. Click "+" to increment quantity

**Expected Results:**
- Quantity updated to 2
- Line total updates to 49.98 (2 x 24.99)
- Page displays "49.98"
- No JavaScript errors

---

### Test 8.4: Can remove item from cart

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Verify "Classic Cotton T-Shirt" is visible
7. Click "Remove" on the item

**Expected Results:**
- Page displays "Your cart is empty"
- No JavaScript errors

---

### Test 8.5: Can add multiple different products

**Preconditions:** Products #1 and #2 exist

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/products/premium-slim-fit-jeans`
6. Click "Add to cart"
7. Navigate to `/cart`

**Expected Results:**
- Page displays "Classic Cotton T-Shirt"
- Page displays "Premium Slim Fit Jeans"
- No JavaScript errors

---

### Test 8.6: Can apply valid discount code WELCOME10

**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Verify "24.99" is displayed
7. Enter discount code: `WELCOME10` in the discount_code field
8. Click "Apply"

**Expected Results:**
- Page displays "WELCOME10" label
- Page displays "Discount" line in totals
- 10% discount applied (approximately 2.50 off 24.99)
- No JavaScript errors

---

### Test 8.7: Shows error for invalid discount code

**Preconditions:** Product #1 in cart

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `INVALID` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays "Invalid discount code"
- No JavaScript errors

---

### Test 8.8: Shows error for expired discount code

**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `EXPIRED20` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays error message containing "expired"
- No JavaScript errors

---

### Test 8.9: Shows error for maxed out discount code

**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `MAXED` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays error message containing "usage limit"
- No JavaScript errors

---

### Test 8.10: Can apply free shipping discount

**Preconditions:** Product #1 in cart, FREESHIP discount exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `FREESHIP` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays "FREESHIP" label
- Page displays "Free shipping" indicator
- No JavaScript errors

---

### Test 8.11: Can apply FLAT5 discount for fixed amount off

**Preconditions:** Product #1 in cart, FLAT5 discount exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `FLAT5` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays "FLAT5" label
- Page displays "5.00" discount amount in totals
- No JavaScript errors

---

### Test 8.12: Shows subtotal and total in cart

**Preconditions:** Product #1 in cart

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`

**Expected Results:**
- Page displays "Subtotal" label
- Page displays "24.99"
- No JavaScript errors

**Total: 12 tests**

---

## Suite 9: Checkout Flow

**File:** `tests/Browser/Storefront/CheckoutTest.php`

**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.

### Checkout Step Flow

```mermaid
flowchart TD
    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]
    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]
    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]
    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]
    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]
    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation
    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]

    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]
    Step1 -->|empty email| Step1Error[Validation error on email]
    Step2 -->|missing fields| Step2Error[Validation errors on address fields]
    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]

    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]
    Step2 -->|Country = US| InternationalRates[International rates]


Output:
Chunk ID: 1cc69a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2177
Output:

## Suite 8: Cart Flow

**File:** `tests/Browser/Storefront/CartTest.php`

**Purpose:** Verify the complete cart lifecycle - adding products with variant selection, updating quantities, removing items, applying/rejecting discount codes, and verifying totals calculations.

### Cart Interaction Flow

```mermaid
flowchart TD
    PDP[Product Page] -->|select variant + click 'Add to cart'| CartDrawer[Cart Drawer / Cart Page]
    CartDrawer -->|click '+'| QuantityUp[Quantity incremented, totals update]
    CartDrawer -->|click '-'| QuantityDown[Quantity decremented, totals update]
    CartDrawer -->|click 'Remove'| EmptyCart[Empty cart state]
    CartDrawer -->|fill discount code + click 'Apply'| DiscountCheck{Valid?}
    DiscountCheck -->|yes| DiscountApplied[Discount reflected in totals]
    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]
    DiscountCheck -->|no: expired| ErrorExpired[Error: discount expired]
    DiscountCheck -->|no: maxed| ErrorMaxed[Error: usage limit reached]
    CartDrawer -->|click 'Checkout'| Checkout[Checkout Flow]
```

### Discount Code Validation Matrix

| Code | Type | Value | Min Purchase | Status | Expected Behavior |
|------|------|-------|-------------|--------|-------------------|
| WELCOME10 | percent | 10% | 20 EUR | active | Applies 10% discount to subtotal |
| FLAT5 | fixed | 5.00 EUR | none | active | Subtracts 5.00 from subtotal |
| FREESHIP | free_shipping | n/a | none | active | Sets shipping cost to 0 |
| EXPIRED20 | percent | 20% | none | expired (end date in past) | Rejected with "expired" error |
| MAXED | percent | 10% | none | active but usage_count >= usage_limit | Rejected with "usage limit" error |
| INVALID | n/a | n/a | n/a | does not exist | Rejected with "Invalid discount code" error |

---

### Test 8.1: Can add product to cart

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"

**Expected Results:**
- Page displays "Classic Cotton T-Shirt"
- Page displays "24.99"
- No JavaScript errors

---

### Test 8.2: Can view cart with added item

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`

**Expected Results:**
- Page displays "Your Cart"
- Page displays "Classic Cotton T-Shirt"
- Page displays "24.99"
- No JavaScript errors

---

### Test 8.3: Can update quantity in cart

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Verify "Classic Cotton T-Shirt" is visible
7. Click "+" to increment quantity

**Expected Results:**
- Quantity updated to 2
- Line total updates to 49.98 (2 x 24.99)
- Page displays "49.98"
- No JavaScript errors

---

### Test 8.4: Can remove item from cart

**Preconditions:** Product #1 exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Verify "Classic Cotton T-Shirt" is visible
7. Click "Remove" on the item

**Expected Results:**
- Page displays "Your cart is empty"
- No JavaScript errors

---

### Test 8.5: Can add multiple different products

**Preconditions:** Products #1 and #2 exist

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/products/premium-slim-fit-jeans`
6. Click "Add to cart"
7. Navigate to `/cart`

**Expected Results:**
- Page displays "Classic Cotton T-Shirt"
- Page displays "Premium Slim Fit Jeans"
- No JavaScript errors

---

### Test 8.6: Can apply valid discount code WELCOME10

**Preconditions:** Product #1 in cart (24.99 EUR, above 20 EUR minimum), WELCOME10 discount exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Verify "24.99" is displayed
7. Enter discount code: `WELCOME10` in the discount_code field
8. Click "Apply"

**Expected Results:**
- Page displays "WELCOME10" label
- Page displays "Discount" line in totals
- 10% discount applied (approximately 2.50 off 24.99)
- No JavaScript errors

---

### Test 8.7: Shows error for invalid discount code

**Preconditions:** Product #1 in cart

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `INVALID` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays "Invalid discount code"
- No JavaScript errors

---

### Test 8.8: Shows error for expired discount code

**Preconditions:** Product #1 in cart, EXPIRED20 discount exists (expired)

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `EXPIRED20` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays error message containing "expired"
- No JavaScript errors

---

### Test 8.9: Shows error for maxed out discount code

**Preconditions:** Product #1 in cart, MAXED discount exists (usage limit reached)

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `MAXED` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays error message containing "usage limit"
- No JavaScript errors

---

### Test 8.10: Can apply free shipping discount

**Preconditions:** Product #1 in cart, FREESHIP discount exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `FREESHIP` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays "FREESHIP" label
- Page displays "Free shipping" indicator
- No JavaScript errors

---

### Test 8.11: Can apply FLAT5 discount for fixed amount off

**Preconditions:** Product #1 in cart, FLAT5 discount exists

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`
6. Enter discount code: `FLAT5` in the discount_code field
7. Click "Apply"

**Expected Results:**
- Page displays "FLAT5" label
- Page displays "5.00" discount amount in totals
- No JavaScript errors

---

### Test 8.12: Shows subtotal and total in cart

**Preconditions:** Product #1 in cart

**Steps:**
1. Navigate to `/products/classic-cotton-t-shirt`
2. Click "M" to select size M
3. Click "Black" to select color Black
4. Click "Add to cart"
5. Navigate to `/cart`

**Expected Results:**
- Page displays "Subtotal" label
- Page displays "24.99"
- No JavaScript errors

**Total: 12 tests**

---

## Suite 9: Checkout Flow

**File:** `tests/Browser/Storefront/CheckoutTest.php`

**Purpose:** Verify the complete multi-step checkout process: cart to contact info, shipping address, shipping method selection, payment, and order confirmation.

### Checkout Step Flow

```mermaid
flowchart TD
    Cart[Cart Page] -->|click 'Checkout'| Step1[Step 1: Contact Info]
    Step1 -->|fill email, click 'Continue'| Step2[Step 2: Shipping Address]
    Step2 -->|fill address fields, click 'Continue'| Step3[Step 3: Shipping Method]
    Step3 -->|select method, click 'Continue'| Step4[Step 4: Payment Method & Pay]
    Step4 -->|select Credit Card, fill card fields, click 'Pay now'| Confirmation[Order Confirmation]
    Step4 -->|select PayPal, click 'Pay with PayPal'| Confirmation
    Step4 -->|select Bank Transfer, click 'Place order'| ConfirmationBT[Confirmation + Bank Transfer Instructions]

    Step4 -->|magic decline card 4000...0002| Step4Error[Payment declined error]
    Step1 -->|empty email| Step1Error[Validation error on email]
    Step2 -->|missing fields| Step2Error[Validation errors on address fields]
    Step2 -->|invalid postal code| Step2PError[Validation error on postal code]

    Step2 -->|Country = DE| DomesticRates[Domestic: Standard 4.99 EUR]
    Step2 -->|Country = US| InternationalRates[International rates]


sed -n '1,260p' app/Livewire/Storefront/Cart/Show.php

<?php

namespace App\Livewire\Storefront\Cart;

use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use App\Services\DiscountService;
use App\Services\ShippingCalculator;
use App\ValueObjects\DiscountResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\On;
use Livewire\Component;

class Show extends Component
{
    public int $storeId;

    public string $discountCode = '';

    public ?string $appliedDiscountCode = null;

    public string $shippingCountry = 'DE';

    public string $shippingPostalCode = '';

    public string $shippingProvinceCode = '';

    public function mount(): void
    {
        $this->storeId = $this->store()->getKey();
        $this->appliedDiscountCode = trim((string) session('cart_discount_code')) ?: null;
        $this->discountCode = $this->appliedDiscountCode ?? '';
    }

    #[On('cart-updated')]
    public function refreshCart(): void {}

    public function increaseQuantity(int $lineId): void
    {
        $line = $this->cartLine($lineId);

        if (! $line instanceof CartLine) {
            return;
        }

        app(CartService::class)->updateLineQuantity($line->cart, $line->getKey(), $line->quantity + 1);
        $this->dispatch('cart-updated');
    }

    public function decreaseQuantity(int $lineId): void
    {
        $line = $this->cartLine($lineId);

        if (! $line instanceof CartLine) {
            return;
        }

        app(CartService::class)->updateLineQuantity($line->cart, $line->getKey(), $line->quantity - 1);
        $this->dispatch('cart-updated');
    }

    public function removeLine(int $lineId): void
    {
        $line = $this->cartLine($lineId);

        if (! $line instanceof CartLine) {
            return;
        }

        app(CartService::class)->removeLine($line->cart, $line->getKey());
        $this->dispatch('cart-updated');
    }

    public function applyDiscount(): void
    {
        $this->validate([
            'discountCode' => ['required', 'string', 'max:50'],
        ]);

        $cart = $this->cart();

        if (! $cart instanceof Cart) {
            return;
        }

        $code = trim($this->discountCode);

        try {
            $discount = app(DiscountService::class)->validate($code, $this->store(), $cart);
            app(DiscountService::class)->calculate($discount, $this->subtotal(), $this->lines()->all());
        } catch (InvalidDiscountException $exception) {
            $this->removeDiscount();

            throw ValidationException::withMessages([
                'discountCode' => $exception->getMessage(),
            ]);
        }

        $this->appliedDiscountCode = $code;
        $this->discountCode = $code;
        session(['cart_discount_code' => $code]);
        $this->resetErrorBag('discountCode');
    }

    public function removeDiscount(): void
    {
        $this->appliedDiscountCode = null;
        $this->discountCode = '';
        session()->forget('cart_discount_code');
        $this->resetErrorBag('discountCode');
    }

    public function estimateShipping(): void
    {
        $this->validate([
            'shippingCountry' => ['required', 'string', 'size:2'],
            'shippingPostalCode' => ['nullable', 'string', 'max:20'],
            'shippingProvinceCode' => ['nullable', 'string', 'max:20'],
        ]);

        $this->resetErrorBag('shippingCountry');
    }

    public function checkout(): void
    {
        if ($this->lineCount() === 0) {
            return;
        }

        $this->redirectRoute('checkout.show', navigate: true);
    }

    public function store(): Store
    {
        if (isset($this->storeId)) {
            $store = Store::query()->findOrFail($this->storeId);
            app()->instance('current_store', $store);

            return $store;
        }

        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    public function cart(): ?Cart
    {
        $cart = app(CartService::class)->currentForSession($this->store(), $this->customer());

        return $cart?->load([
            'lines.variant.product',
            'lines.variant.optionValues.option',
        ]);
    }

    /**
     * @return Collection<int, CartLine>
     */
    public function lines(): Collection
    {
        return $this->cart()?->lines->sortBy('id')->values() ?? collect();
    }

    public function lineCount(): int
    {
        return $this->lines()->sum('quantity');
    }

    public function subtotal(): int
    {
        return $this->lines()->sum('line_subtotal_amount');
    }

    /**
     * @return Collection<int, ShippingRate>
     */
    public function availableRates(): Collection
    {
        if (! $this->requiresShipping() || $this->shippingCountry === '') {
            return collect();
        }

        return app(ShippingCalculator::class)->getAvailableRates($this->store(), $this->shippingAddress());
    }

    public function requiresShipping(): bool
    {
        $cart = $this->cart();

        return $cart instanceof Cart && app(ShippingCalculator::class)->requiresShipping($cart);
    }

    /**
     * @return array<int, int>
     */
    public function shippingRateAmounts(): array
    {
        $cart = $this->cart();

        if (! $cart instanceof Cart) {
            return [];
        }

        return $this->availableRates()
            ->mapWithKeys(fn (ShippingRate $rate): array => [
                $rate->getKey() => app(ShippingCalculator::class)->calculate($rate, $cart) ?? 0,
            ])
            ->all();
    }

    public function estimatedShippingAmount(): ?int
    {
        if (! $this->requiresShipping()) {
            return 0;
        }

        $amounts = $this->shippingRateAmounts();

        return $amounts === [] ? null : min($amounts);
    }

    public function discountResult(): ?DiscountResult
    {
        $cart = $this->cart();
        $code = trim((string) $this->appliedDiscountCode);

        if (! $cart instanceof Cart || $code === '') {
            return null;
        }

        try {
            $discount = app(DiscountService::class)->validate($code, $this->store(), $cart);

            return app(DiscountService::class)->calculate($discount, $this->subtotal(), $this->lines()->all());
        } catch (InvalidDiscountException) {
            return null;
        }
    }

    public function discountAmount(): int
    {
        return $this->discountResult()?->amount ?? 0;
    }

    public function discountFreeShipping(): bool
    {
        return $this->discountResult()?->freeShipping ?? false;
    }

    public function estimatedTotal(): int


sed -n '1,280p' resources/views/livewire/storefront/cart/show.blade.php

    <x-storefront.breadcrumbs :items="[['label' => 'Cart']]" />

Cart

{{ $lineCount }} {{ Str::plural('item', $lineCount) }}

                <flux:button :href="route('collections.index')" wire:navigate variant="ghost" icon="arrow-left">
                    Continue shopping
                </flux:button>

            @if ($lines->isEmpty())

                    <flux:icon name="shopping-bag" class="mx-auto size-12 text-zinc-400 dark:text-zinc-600" />

Your cart is empty

                    <flux:button :href="route('collections.index')" wire:navigate variant="primary" class="mt-6">
                        Browse products
                    </flux:button>

            @else

                    @foreach ($lines as $line)

getKey() }}">
                            variant->product->handle) }}" wire:navigate class="flex aspect-square items-center justify-center rounded-md border border-zinc-200 bg-zinc-100 text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-600">
                                <flux:icon name="shopping-bag" class="size-7" />

                                variant->product->handle) }}" wire:navigate class="font-medium text-zinc-950 hover:underline dark:text-white">
                                    {{ $line->variant->product->title }}

                                    {{ $line->variant->optionValues->map(fn ($value) => $value->option->name.': '.$value->value)->implode(' / ') ?: ($line->variant->sku ?: 'Default') }}

                                <x-storefront.price :amount="$line->unit_price_amount" :currency="$cart->currency" class="text-sm" />

                                    <button type="button" wire:click="decreaseQuantity({{ $line->getKey() }})" class="flex size-10 items-center justify-center border-r border-zinc-200 text-sm dark:border-zinc-700" aria-label="Decrease {{ $line->variant->product->title }} quantity">-</button>
                                    {{ $line->quantity }}
                                    <button type="button" wire:click="increaseQuantity({{ $line->getKey() }})" class="flex size-10 items-center justify-center border-l border-zinc-200 text-sm dark:border-zinc-700" aria-label="Increase {{ $line->variant->product->title }} quantity">+</button>

                                    <x-storefront.price :amount="$line->line_total_amount" :currency="$cart->currency" class="justify-end" />
                                    <flux:button wire:click="removeLine({{ $line->getKey() }})" variant="ghost" size="sm" icon="trash" class="mt-2" aria-label="Remove {{ $line->variant->product->title }}" />

                    @endforeach

            @endif

Summary

                    Subtotal
                    @if ($cart…841 chars truncated…="font-medium text-zinc-950 dark:text-white">-
                    @endif

                    Shipping
                    @if (! $cart || $lineCount === 0)
                        -
                    @elseif (! $requiresShipping || $discountFreeShipping)
                        Free
                    @elseif ($estimatedShipping !== null)
                        <x-storefront.price :amount="$estimatedShipping" :currency="$cart->currency" />
                    @else
                        Unavailable
                    @endif

                    Estimated total
                    @if ($cart)
                        <x-storefront.price :amount="$estimatedTotal" :currency="$cart->currency" />
                    @else
                        -
                    @endif

            @if ($cart && $lineCount > 0)
                <form wire:submit="applyDiscount" class="mt-5 border-t border-zinc-200 pt-5 dark:border-zinc-800">

                            <flux:input wire:model="discountCode" label="Discount code" placeholder="SAVE10" />

                        <flux:button type="submit" variant="ghost" icon="tag" class="shrink-0">
                            Apply
                        </flux:button>

                    <flux:error name="discountCode" />

                    @if ($appliedDiscountCode)

                            <flux:badge color="green">{{ $appliedDiscountCode }}</flux:badge>
                            <flux:button type="button" wire:click="removeDiscount" variant="ghost" size="sm" icon="x-mark">
                                Remove
                            </flux:button>

                    @endif
                </form>

                <form wire:submit="estimateShipping" class="mt-5 border-t border-zinc-200 pt-5 dark:border-zinc-800">

                        <flux:select wire:model.live="shippingCountry" label="Ship to">
                            <flux:select.option value="DE">Germany</flux:select.option>
                            <flux:select.option value="AT">Austria</flux:select.option>
                            <flux:select.option value="CH">Switzerland</flux:select.option>
                        </flux:select>

                            <flux:input wire:model.live.debounce.400ms="shippingPostalCode" label="Postal code" placeholder="10115" />
                            <flux:input wire:model.live.debounce.400ms="shippingProvinceCode" label="Region code" placeholder="BE" />

                    <flux:error name="shippingCountry" />

                    <flux:button type="submit" variant="ghost" icon="truck" class="mt-4 w-full">
                        Estimate shipping
                    </flux:button>

                        @forelse ($rates as $rate)

getKey() }}">
                                {{ $rate->name }}
                                <x-storefront.price :amount="data_get($rateAmounts, $rate->getKey(), 0)" :currency="$cart->currency" class="justify-end text-sm" />

                        @empty

                                {{ $requiresShipping ? 'No rates are available.' : 'No shipping needed.' }}

                        @endforelse

                </form>
            @endif

            <flux:button wire:click="checkout" variant="primary" class="mt-6 w-full" :disabled="$lineCount === 0">
                Checkout
            </flux:button>


Output:
Chunk ID: 4c6998
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2711
Output:

    <x-storefront.breadcrumbs :items="[['label' => 'Cart']]" />

Cart

{{ $lineCount }} {{ Str::plural('item', $lineCount) }}

                <flux:button :href="route('collections.index')" wire:navigate variant="ghost" icon="arrow-left">
                    Continue shopping
                </flux:button>

            @if ($lines->isEmpty())

                    <flux:icon name="shopping-bag" class="mx-auto size-12 text-zinc-400 dark:text-zinc-600" />

Your cart is empty

                    <flux:button :href="route('collections.index')" wire:navigate variant="primary" class="mt-6">
                        Browse products
                    </flux:button>

            @else

                    @foreach ($lines as $line)

getKey() }}">
                            variant->product->handle) }}" wire:navigate class="flex aspect-square items-center justify-center rounded-md border border-zinc-200 bg-zinc-100 text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-600">
                                <flux:icon name="shopping-bag" class="size-7" />

                                variant->product->handle) }}" wire:navigate class="font-medium text-zinc-950 hover:underline dark:text-white">
                                    {{ $line->variant->product->title }}

                                    {{ $line->variant->optionValues->map(fn ($value) => $value->option->name.': '.$value->value)->implode(' / ') ?: ($line->variant->sku ?: 'Default') }}

                                <x-storefront.price :amount="$line->unit_price_amount" :currency="$cart->currency" class="text-sm" />

                                    <button type="button" wire:click="decreaseQuantity({{ $line->getKey() }})" class="flex size-10 items-center justify-center border-r border-zinc-200 text-sm dark:border-zinc-700" aria-label="Decrease {{ $line->variant->product->title }} quantity">-</button>
                                    {{ $line->quantity }}
                                    <button type="button" wire:click="increaseQuantity({{ $line->getKey() }})" class="flex size-10 items-center justify-center border-l border-zinc-200 text-sm dark:border-zinc-700" aria-label="Increase {{ $line->variant->product->title }} quantity">+</button>

                                    <x-storefront.price :amount="$line->line_total_amount" :currency="$cart->currency" class="justify-end" />
                                    <flux:button wire:click="removeLine({{ $line->getKey() }})" variant="ghost" size="sm" icon="trash" class="mt-2" aria-label="Remove {{ $line->variant->product->title }}" />

                    @endforeach

            @endif

Summary

                    Subtotal
                    @if ($cart)
                        <x-storefront.price :amount="$subtotal" :currency="$cart->currency" />
                    @else
                        -
                    @endif

                    Discount
                    @if ($cart && ($discountAmount > 0 || $discountFreeShipping))

                            {{ $discountFreeShipping && $discountAmount === 0 ? 'Free shipping' : '-'.\App\Support\Money::format($discountAmount, $cart->currency) }}

                    @else
                        -
                    @endif

                    Shipping
                    @if (! $cart || $lineCount === 0)
                        -
                    @elseif (! $requiresShipping || $discountFreeShipping)
                        Free
                    @elseif ($estimatedShipping !== null)
                        <x-storefront.price :amount="$estimatedShipping" :currency="$cart->currency" />
                    @else
                        Unavailable
                    @endif

                    Estimated total
                    @if ($cart)
                        <x-storefront.price :amount="$estimatedTotal" :currency="$cart->currency" />
                    @else
                        -
                    @endif

            @if ($cart && $lineCount > 0)
                <form wire:submit="applyDiscount" class="mt-5 border-t border-zinc-200 pt-5 dark:border-zinc-800">

                            <flux:input wire:model="discountCode" label="Discount code" placeholder="SAVE10" />

                        <flux:button type="submit" variant="ghost" icon="tag" class="shrink-0">
                            Apply
                        </flux:button>

                    <flux:error name="discountCode" />

                    @if ($appliedDiscountCode)

                            <flux:badge color="green">{{ $appliedDiscountCode }}</flux:badge>
                            <flux:button type="button" wire:click="removeDiscount" variant="ghost" size="sm" icon="x-mark">
                                Remove
                            </flux:button>

                    @endif
                </form>

                <form wire:submit="estimateShipping" class="mt-5 border-t border-zinc-200 pt-5 dark:border-zinc-800">

                        <flux:select wire:model.live="shippingCountry" label="Ship to">
                            <flux:select.option value="DE">Germany</flux:select.option>
                            <flux:select.option value="AT">Austria</flux:select.option>
                            <flux:select.option value="CH">Switzerland</flux:select.option>
                        </flux:select>

                            <flux:input wire:model.live.debounce.400ms="shippingPostalCode" label="Postal code" placeholder="10115" />
                            <flux:input wire:model.live.debounce.400ms="shippingProvinceCode" label="Region code" placeholder="BE" />

                    <flux:error name="shippingCountry" />

                    <flux:button type="submit" variant="ghost" icon="truck" class="mt-4 w-full">
                        Estimate shipping
                    </flux:button>

                        @forelse ($rates as $rate)

getKey() }}">
                                {{ $rate->name }}
                                <x-storefront.price :amount="data_get($rateAmounts, $rate->getKey(), 0)" :currency="$cart->currency" class="justify-end text-sm" />

                        @empty

                                {{ $requiresShipping ? 'No rates are available.' : 'No shipping needed.' }}

                        @endforelse

                </form>
            @endif

            <flux:button wire:click="checkout" variant="primary" class="mt-6 w-full" :disabled="$lineCount === 0">
                Checkout
            </flux:button>