Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Shipping.php

<?php

namespace App\Livewire\Admin\Settings;

use App\Enums\ShippingRateType;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use App\Services\ShippingCalculator;
use App\Support\Money;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Shipping extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public ?int $editingZoneId = null;

    public string $zoneName = '';

    public string $zoneCountries = '';

    public ?int $rateZoneId = null;

    public ?int $editingRateId = null;

    public string $rateName = '';

    public string $rateType = 'flat';

    public string $rateAmount = '0.00';

    public string $minimumWeight = '0';

    public string $maximumWeight = '';

    public string $minimumOrderAmount = '0.00';

    public string $maximumOrderAmount = '';

    public bool $rateActive = true;

    public string $testCountry = 'DE';

    public string $testRegion = '';

    /**
     * @var array<string, mixed>|null
     */
    public ?array $testResult = null;

    public function mount(): void
    {
        $store = $this->store();

        $this->authorize('update', $store);

        $this->storeId = $store->getKey();
    }

    public function editZone(int $zoneId): void
    {
        $zone = $this->zone($zoneId);

        $this->authorize('update', $this->scopedStore());

        $this->editingZoneId = $zone->getKey();
        $this->zoneName = $zone->name;
        $this->zoneCountries = implode(', ', $zone->countries_json ?? []);
    }

    public function saveZone(): void
    {
        $store = $this->scopedStore();

        $this->authorize('update', $store);

        $validated = $this->validate([
            'zoneName' => ['required', 'string', 'max:255'],
            'zoneCountries' => ['required', 'string', 'max:255'],
        ], [], [
            'zoneName' => 'zone name',
            'zoneCountries' => 'countries',
        ]);

        $countries = $this->countryCodes($validated['zoneCountries']);

        if ($countries === []) {
            $this->addError('zoneCountries', __('Enter at least one ISO country code.'));

            return;
        }

        $payload = [
            'store_id' => $store->getKey(),
            'name' => $validated['zoneName'],
            'countries_json' => $countries,
            'regions_json' => [],
        ];

        $this->editingZoneId
            ? $this->zone($this->editingZoneId)->update($payload)
            : ShippingZone::withoutGlobalScopes()->create($payload);

        $this->resetZoneForm();

        session()->flash('status', 'Shipping zone saved');
        $this->dispatch('toast', type: 'success', message: __('Shipping zone saved'));
    }

    public function deleteZone(int $zoneId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->zone($zoneId)->delete();
        $this->resetZoneForm();
        $this->resetRateForm();

        session()->flash('status', 'Shipping zone deleted');
    }

    public function addRate(int $zoneId): void
    {
        $zone = $this->zone($zoneId);

        $this->authorize('update', $this->scopedStore());

        $this->resetRateForm();
        $this->rateZoneId = $zone->getKey();
    }

    public function editRate(int $rateId): void
    {
        $rate = $this->rate($rateId);

        $this->authorize('update', $this->scopedStore());

        $this->editingRateId = $rate->getKey();
        $this->rateZoneId = $rate->zone_id;
        $this->rateName = $rate->name;
        $this->rateType = $rate->type->value;
        $this->rateActive = $rate->is_active;
        $this->fillRateConfig($rate);
    }

    public function saveRate(): void
    {
        $store = $this->scopedStore();

        $this->authorize('update', $store);

        $validated = $this->validate([
            'rateZoneId' => ['required', 'integer'],
            'rateName' => ['required', 'string', 'max:255'],
            'rateType' => ['required', Rule::in(array_column(ShippingRateType::cases(), 'value'))],
            'rateAmount' => ['required', 'numeric', 'min:0'],
            'minimumWeight' => ['nullable', 'integer', 'min:0'],
            'maximumWeight' => ['nullable', 'integer', 'min:1'],
            'minimumOrderAmount' => ['nullable', 'numeric', 'min:0'],
            'maximumOrderAmount' => ['nullable', 'numeric', 'min:0'],
            'rateActive' => ['boolean'],
        ], [], [
            'rateZoneId' => 'shipping zone',
            'rateName' => 'rate name',
            'rateType' => 'rate type',
            'rateAmount' => 'rate amount',
        ]);

        $zone = $this->zone((int) $validated['rateZoneId']);
        $payload = [
            'zone_id' => $zone->getKey(),
            'name' => $validated['rateName'],
            'type' => ShippingRateType::from($validated['rateType']),
            'config_json' => $this->rateConfig(),
            'is_active' => $this->rateActive,
        ];

        $this->editingRateId
            ? $this->rate($this->editingRateId)->update($payload)
            : ShippingRate::withoutGlobalScopes()->create($payload);

        $this->resetRateForm();

        session()->flash('status', 'Shipping rate saved');
        $this->dispatch('toast', type: 'success', message: __('Shipping rate saved'));
    }

    public function deleteRate(int $rateId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->rate($rateId)->delete();
        $this->resetRateForm();

        session()->flash('status', 'Shipping rate deleted');
    }

    public function toggleRateActive(int $rateId): void
    {
        $this->authorize('update', $this->scopedStore());

        $rate = $this->rate($rateId);
        $rate->forceFill(['is_active' => ! $rate->is_active])->save();
    }

    public function testShippingAddress(ShippingCalculator $shipping): void
    {
        $store = $this->scopedStore();

        $this->authorize('update', $store);

        $address = [
            'country' => strtoupper($this->testCountry),
            'province_code' => strtoupper($this->testRegion),
        ];
        $zone = $shipping->matchingZone($store, $address);

        $this->testResult = [
            'zone' => $zone?->name,
            'rates' => $zone instanceof ShippingZone
                ? ShippingRate::withoutGlobalScopes()
                    ->where('zone_id', $zone->getKey())
                    ->where('is_active', true)
                    ->orderBy('id')
                    ->get()
                    ->map(fn (ShippingRate $rate): string => $rate->name.' - '.$this->rateSummary($rate))
                    ->all()
                : [],
        ];
    }

    /**
     * @return Collection<int, ShippingZone>
     */
    public function zones(): Collection
    {
        return ShippingZone::withoutGlobalScopes()
            ->with(['rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id')])
            ->where('store_id', $this->storeId)
            ->orderBy('id')
            ->get();
    }

    public function rateSummary(ShippingRate $rate): string
    {
        if ($rate->type === ShippingRateType::Carrier) {
            return 'Carrier fallback '.Money::format((int) data_get($rate->config_json, 'amount', 0), $this->scopedStore()->default_currency);
        }

        $amount = match ($rate->type) {
            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
            default => (int) data_get($rate->config_json, 'ranges.0.amount', 0),
        };

        return Money::format($amount, $this->scopedStore()->default_currency);
    }

    public function render(): mixed
    {
        return view('livewire.admin.settings.shipping', [
            'zones' => $this->zones(),
        ])->layout('layouts.app', [
            'title' => __('Shipping'),
        ]);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function zone(int $zoneId): ShippingZone
    {
        return ShippingZone::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->whereKey($zoneId)
            ->firstOrFail();
    }

    private function rate(int $rateId): ShippingRate
    {
        return ShippingRate::withoutGlobalScopes()
            ->whereKey($rateId)
            ->whereHas('zone', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->firstOrFail();
    }

    /**
     * @return list<string>
     */
    private function countryCodes(string $countries): array
    {
        return collect(explode(',', $countries))
            ->map(fn (string $country): string => strtoupper(trim($country)))
            ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1)
            ->unique()
            ->values()
            ->all();
    }

    /**
     * @return array<string, mixed>
     */
    private function rateConfig(): array
    {
        $amount = Money::fromDecimalString($this->rateAmount);

        return match ($this->rateType) {
            ShippingRateType::Weight->value => [
                'ranges' => [[
                    'min_g' => (int) ($this->minimumWeight ?: 0),
                    'max_g' => $this->maximumWeight !== '' ? (int) $this->maximumWeight : null,
                    'amount' => $amount,
                ]],
            ],
            ShippingRateType::Price->value => [
                'ranges' => [[
                    'min_amount' => Money::fromDecimalString($this->minimumOrderAmount),
                    'max_amount' => $this->maximumOrderAmount !== '' ? Money::fromDecimalString($this->maximumOrderAmount) : null,
                    'amount' => $amount,
                ]],
            ],
            ShippingRateType::Carrier->value => ['amount' => $amount],
            default => ['amount' => $amount],
        };
    }

    private function fillRateConfig(ShippingRate $rate): void
    {
        $amount = match ($rate->type) {
            ShippingRateType::Flat, ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 0),
            default => (int) data_get($rate->config_json, 'ranges.0.amount', 0),
        };

        $this->rateAmount = number_format($amount / 100, 2, '.', '');
        $this->minimumWeight = (string) data_get($rate->config_json, 'ranges.0.min_g', 0);
        $this->maximumWeight = data_get($rate->config_json, 'ranges.0.max_g') !== null
            ? (string) data_get($rate->config_json, 'ranges.0.max_g')
            : '';
        $this->minimumOrderAmount = number_format(((int) data_get($rate->config_json, 'ranges.0.min_amount', 0)) / 100, 2, '.', '');
        $this->maximumOrderAmount = data_get($rate->config_json, 'ranges.0.max_amount') !== null
            ? number_format(((int) data_get($rate->config_json, 'ranges.0.max_amount')) / 100, 2, '.', '')
            : '';
    }

    private function resetZoneForm(): void
    {
        $this->editingZoneId = null;
        $this->zoneName = '';
        $this->zoneCountries = '';
    }

    private function resetRateForm(): void
    {
        $this->editingRateId = null;
        $this->rateZoneId = null;
        $this->rateName = '';
        $this->rateType = 'flat';
        $this->rateAmount = '0.00';
        $this->minimumWeight = '0';
        $this->maximumWeight = '';
        $this->minimumOrderAmount = '0.00';
        $this->maximumOrderAmount = '';
        $this->rateActive = true;
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Shipping.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Taxes.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Taxes.php

<?php

namespace App\Livewire\Admin\Settings;

use Livewire\Component;

class Taxes extends Component
{
    public function render()
    {
        return view('livewire.admin.settings.taxes');
    }
}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Taxes.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Taxes.php
+<?php
+
+namespace App\Livewire\Admin\Settings;
+
+use App\Enums\TaxMode;
+use App\Models\Store;
+use App\Models\TaxSettings;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Validation\Rule;
+use Livewire\Attributes\Locked;
+use Livewire\Component;
+
+class Taxes extends Component
+{
+    use AuthorizesRequests;
+
+    #[Locked]
+    public int $storeId;
+
+    public string $mode = 'manual';
+
+    public bool $pricesIncludeTax = false;
+
+    public string $provider = 'none';
+
+    public string $providerApiKey = '';
+
+    /**
+     * @var array<int, array{country: string, name: string, rate_percentage: string}>
+     */
+    public array $manualRates = [];
+
+    public function mount(): void
+    {
+        $store = $this->store();
+
+        $this->authorize('update', $store);
+
+        $this->storeId = $store->getKey();
+        $this->fillFromSettings($this->taxSettings($store));
+    }
+
+    public function addManualRate(): void
+    {
+        $this->manualRates[] = [
+            'country' => '',
+            'name' => 'Tax',
+            'rate_percentage' => '0.00',
+        ];
+    }
+
+    public function removeManualRate(int $index): void
+    {
+        unset($this->manualRates[$index]);
+
+        $this->manualRates = array_values($this->manualRates);
+    }
+
+    public function save(): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $validated = $this->validate([
+            'mode' => ['required', Rule::in(array_column(TaxMode::cases(), 'value'))],
+            'pricesIncludeTax' => ['boolean'],
+            'provider' => ['required', Rule::in(['none', 'stripe_tax'])],
+            'providerApiKey' => ['nullable', 'string', 'max:255'],
+            'manualRates' => ['array'],
+            'manualRates.*.country' => ['required', 'string', 'size:2'],
+            'manualRates.*.name' => ['required', 'string', 'max:50'],
+            'manualRates.*.rate_percentage' => ['required', 'numeric', 'min:0', 'max:100'],
+        ]);
+
+        TaxSettings::withoutGlobalScopes()->updateOrCreate(
+            ['store_id' => $store->getKey()],
+            [
+                'mode' => TaxMode::from($validated['mode']),
+                'provider' => $this->mode === TaxMode::Provider->value ? $this->provider : 'none',
+                'prices_include_tax' => $this->pricesIncludeTax,
+                'config_json' => [
+                    'name' => 'Tax',
+                    'default_rate_bps' => $this->firstRateBasisPoints(),
+                    'shipping_taxable' => true,
+                    'provider_api_key' => $this->mode === TaxMode::Provider->value ? $this->providerApiKey : null,
+                    'rates' => collect($this->manualRates)
+                        ->map(fn (array $rate): array => [
+                            'country' => strtoupper($rate['country']),
+                            'rate_bps' => (int) round(((float) $rate['rate_percentage']) * 100),
+                            'name' => $rate['name'],
+                        ])
+                        ->values()
+                        ->all(),
+                ],
+            ],
+        );
+
+        session()->flash('status', 'Tax settings saved');
+        $this->dispatch('toast', type: 'success', message: __('Tax settings saved'));
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.settings.taxes')
+            ->layout('layouts.app', [
+                'title' => __('Taxes'),
+            ]);
+    }
+
+    private function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
+    private function scopedStore(): Store
+    {
+        return Store::query()->whereKey($this->storeId)->firstOrFail();
+    }
+
+    private function taxSettings(Store $store): TaxSettings
+    {
+        return TaxSettings::withoutGlobalScopes()->firstOrCreate(
+            ['store_id' => $store->getKey()],
+            [
+                'mode' => TaxMode::Manual,
+                'provider' => 'none',
+                'prices_include_tax' => false,
+                'config_json' => [
+                    'name' => 'Tax',
+                    'default_rate_bps' => 0,
+                    'shipping_taxable' => true,
+                    'rates' => [],
+                ],
+            ],
+        );
+    }
+
+    private function fillFromSettings(TaxSettings $settings): void
+    {
+        $this->mode = $settings->mode->value;
+        $this->pricesIncludeTax = $settings->prices_include_tax;
+        $this->provider = $settings->provider;
+        $this->providerApiKey = (string) data_get($settings->config_json, 'provider_api_key', '');
+        $this->manualRates = collect(data_get($settings->config_json, 'rates', []))
+            ->map(fn (array $rate): array => [
+                'country' => (string) data_get($rate, 'country', ''),
+                'name' => (string) data_get($rate, 'name', 'Tax'),
+                'rate_percentage' => number_format(((int) data_get($rate, 'rate_bps', 0)) / 100, 2, '.', ''),
+            ])
+            ->values()
+            ->all();
+
+        if ($this->manualRates === []) {
+            $this->addManualRate();
+        }
+    }
+
+    private function firstRateBasisPoints(): int
+    {
+        $first = $this->manualRates[0]['rate_percentage'] ?? '0';
+
+        return (int) round(((float) $first) * 100);
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Taxes.php

<?php

namespace App\Livewire\Admin\Settings;

use App\Enums\TaxMode;
use App\Models\Store;
use App\Models\TaxSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Taxes extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $mode = 'manual';

    public bool $pricesIncludeTax = false;

    public string $provider = 'none';

    public string $providerApiKey = '';

    /**
     * @var array<int, array{country: string, name: string, rate_percentage: string}>
     */
    public array $manualRates = [];

    public function mount(): void
    {
        $store = $this->store();

        $this->authorize('update', $store);

        $this->storeId = $store->getKey();
        $this->fillFromSettings($this->taxSettings($store));
    }

    public function addManualRate(): void
    {
        $this->manualRates[] = [
            'country' => '',
            'name' => 'Tax',
            'rate_percentage' => '0.00',
        ];
    }

    public function removeManualRate(int $index): void
    {
        unset($this->manualRates[$index]);

        $this->manualRates = array_values($this->manualRates);
    }

    public function save(): void
    {
        $store = $this->scopedStore();

        $this->authorize('update', $store);

        $validated = $this->validate([
            'mode' => ['required', Rule::in(array_column(TaxMode::cases(), 'value'))],
            'pricesIncludeTax' => ['boolean'],
            'provider' => ['required', Rule::in(['none', 'stripe_tax'])],
            'providerApiKey' => ['nullable', 'string', 'max:255'],
            'manualRates' => ['array'],
            'manualRates.*.country' => ['required', 'string', 'size:2'],
            'manualRates.*.name' => ['required', 'string', 'max:50'],
            'manualRates.*.rate_percentage' => ['required', 'numeric', 'min:0', 'max:100'],
        ]);

        TaxSettings::withoutGlobalScopes()->updateOrCreate(
            ['store_id' => $store->getKey()],
            [
                'mode' => TaxMode::from($validated['mode']),
                'provider' => $this->mode === TaxMode::Provider->value ? $this->provider : 'none',
                'prices_include_tax' => $this->pricesIncludeTax,
                'config_json' => [
                    'name' => 'Tax',
                    'default_rate_bps' => $this->firstRateBasisPoints(),
                    'shipping_taxable' => true,
                    'provider_api_key' => $this->mode === TaxMode::Provider->value ? $this->providerApiKey : null,
                    'rates' => collect($this->manualRates)
                        ->map(fn (array $rate): array => [
                            'country' => strtoupper($rate['country']),
                            'rate_bps' => (int) round(((float) $rate['rate_percentage']) * 100),
                            'name' => $rate['name'],
                        ])
                        ->values()
                        ->all(),
                ],
            ],
        );

        session()->flash('status', 'Tax settings saved');
        $this->dispatch('toast', type: 'success', message: __('Tax settings saved'));
    }

    public function render(): mixed
    {
        return view('livewire.admin.settings.taxes')
            ->layout('layouts.app', [
                'title' => __('Taxes'),
            ]);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function taxSettings(Store $store): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()->firstOrCreate(
            ['store_id' => $store->getKey()],
            [
                'mode' => TaxMode::Manual,
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'name' => 'Tax',
                    'default_rate_bps' => 0,
                    'shipping_taxable' => true,
                    'rates' => [],
                ],
            ],
        );
    }

    private function fillFromSettings(TaxSettings $settings): void
    {
        $this->mode = $settings->mode->value;
        $this->pricesIncludeTax = $settings->prices_include_tax;
        $this->provider = $settings->provider;
        $this->providerApiKey = (string) data_get($settings->config_json, 'provider_api_key', '');
        $this->manualRates = collect(data_get($settings->config_json, 'rates', []))
            ->map(fn (array $rate): array => [
                'country' => (string) data_get($rate, 'country', ''),
                'name' => (string) data_get($rate, 'name', 'Tax'),
                'rate_percentage' => number_format(((int) data_get($rate, 'rate_bps', 0)) / 100, 2, '.', ''),
            ])
            ->values()
            ->all();

        if ($this->manualRates === []) {
            $this->addManualRate();
        }
    }

    private function firstRateBasisPoints(): int
    {
        $first = $this->manualRates[0]['rate_percentage'] ?? '0';

        return (int) round(((float) $first) * 100);
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Taxes.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Index.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Index.php

<?php

namespace App\Livewire\Admin\Pages;

use Livewire\Component;

class Index extends Component
{
    public function render()
    {
        return view('livewire.admin.pages.index');
    }
}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Index.php
+<?php
+
+namespace App\Livewire\Admin\Pages;
+
+use App\Enums\PageStatus;
+use App\Models\Page;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
+use Livewire\Component;
+use Livewire\WithPagination;
+
+class Index extends Component
+{
+    use AuthorizesRequests;
+    use WithPagination;
+
+    #[Locked]
+    public int $storeId;
+
+    public string $search = '';
+
+    public function mount(): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $this->authorize('viewAny', Page::class);
+
+        $this->storeId = $store->getKey();
+    }
+
+    public function updatedSearch(): void
+    {
+        $this->resetPage();
+    }
+
+    public function pages(): LengthAwarePaginator
+    {
+        return Page::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->when($this->search !== '', function (Builder $query): void {
+                $search = '%'.$this->search.'%';
+
+                $query->where(function (Builder $query) use ($search): void {
+                    $query
+                        ->where('title', 'like', $search)
+                        ->orWhere('handle', 'like', $search);
+                });
+            })
+            ->latest('updated_at')
+            ->latest('id')
+            ->paginate(20);
+    }
+
+    public function statusColor(PageStatus $status): string
+    {
+        return match ($status) {
+            PageStatus::Published => 'green',
+            PageStatus::Archived => 'red',
+            default => 'zinc',
+        };
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.pages.index', [
+            'pages' => $this->pages(),
+        ])->layout('layouts.app', [
+            'title' => __('Pages'),
+        ]);
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Index.php

<?php

namespace App\Livewire\Admin\Pages;

use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use AuthorizesRequests;
    use WithPagination;

    #[Locked]
    public int $storeId;

    public string $search = '';

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('viewAny', Page::class);

        $this->storeId = $store->getKey();
    }

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function pages(): LengthAwarePaginator
    {
        return Page::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', $search)
                        ->orWhere('handle', 'like', $search);
                });
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate(20);
    }

    public function statusColor(PageStatus $status): string
    {
        return match ($status) {
            PageStatus::Published => 'green',
            PageStatus::Archived => 'red',
            default => 'zinc',
        };
    }

    public function render(): mixed
    {
        return view('livewire.admin.pages.index', [
            'pages' => $this->pages(),
        ])->layout('layouts.app', [
            'title' => __('Pages'),
        ]);
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Form.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Form.php

<?php

namespace App\Livewire\Admin\Pages;

use Livewire\Component;

class Form extends Component
{
    public function render()
    {
        return view('livewire.admin.pages.form');
    }
}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Form.php
+<?php
+
+namespace App\Livewire\Admin\Pages;
+
+use App\Enums\PageStatus;
+use App\Models\NavigationMenu;
+use App\Models\Page;
+use App\Models\Store;
+use App\Services\NavigationService;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
+use Livewire\Component;
+
+class Form extends Component
+{
+    use AuthorizesRequests;
+
+    public ?Page $page = null;
+
+    public string $title = '';
+
+    public string $handle = '';
+
+    public string $bodyHtml = '';
+
+    public string $status = 'draft';
+
+    public string $publishedAt = '';
+
+    public function mount(?Page $page = null): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        if ($page?->exists) {
+            abort_unless((int) $page->store_id === $store->getKey(), 404);
+
+            $this->authorize('update', $page);
+
+            $this->page = $page;
+            $this->fillFromPage($page);
+
+            return;
+        }
+
+        $this->authorize('create', Page::class);
+    }
+
+    public function updatedTitle(string $title): void
+    {
+        if ($this->page instanceof Page || $this->handle !== '') {
+            return;
+        }
+
+        $this->handle = Str::slug($title);
+    }
+
+    public function save(NavigationService $navigation): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $this->authorizeSave();
+
+        $this->handle = Str::slug($this->handle);
+
+        $this->validate([
+            'title' => ['required', 'string', 'max:255'],
+            'handle' => [
+                'required',
+                'string',
+                'max:255',
+                'alpha_dash',
+                Rule::unique('pages', 'handle')
+                    ->where('store_id', $store->getKey())
+                    ->ignore($this->page?->getKey()),
+            ],
+            'bodyHtml' => ['nullable', 'string'],
+            'status' => ['required', Rule::in(array_column(PageStatus::cases(), 'value'))],
+            'publishedAt' => ['nullable', 'date'],
+        ], [], [
+            'bodyHtml' => 'body',
+            'publishedAt' => 'published at',
+        ]);
+
+        $publishedAt = $this->publishedAt !== '' ? $this->publishedAt : null;
+
+        if ($this->status === PageStatus::Published->value && $publishedAt === null) {
+            $publishedAt = now();
+        }
+
+        $page = $this->page instanceof Page
+            ? tap($this->page)->update($this->payload($store, $publishedAt))
+            : Page::withoutGlobalScopes()->create($this->payload($store, $publishedAt));
+
+        $this->page = $page->refresh();
+        $this->fillFromPage($this->page);
+        $this->forgetNavigation($store, $navigation);
+
+        session()->flash('status', 'Page saved');
+        $this->dispatch('toast', type: 'success', message: __('Page saved'));
+    }
+
+    public function deletePage(NavigationService $navigation): void
+    {
+        abort_unless($this->page instanceof Page, 404);
+
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $this->authorize('delete', $this->page);
+
+        $this->page->delete();
+        $this->forgetNavigation($store, $navigation);
+
+        $this->redirectRoute('admin.pages.index', navigate: true);
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.pages.form', [
+            'isEditing' => $this->page instanceof Page,
+        ])->layout('layouts.app', [
+            'title' => $this->page ? __('Edit page') : __('Create page'),
+        ]);
+    }
+
+    private function fillFromPage(Page $page): void
+    {
+        $this->title = $page->title;
+        $this->handle = $page->handle;
+        $this->bodyHtml = (string) $page->body_html;
+        $this->status = $page->status->value;
+        $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i') ?? '';
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function payload(Store $store, mixed $publishedAt): array
+    {
+        return [
+            'store_id' => $store->getKey(),
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'body_html' => $this->bodyHtml,
+            'status' => PageStatus::from($this->status),
+            'published_at' => $publishedAt,
+        ];
+    }
+
+    private function authorizeSave(): void
+    {
+        if ($this->page instanceof Page) {
+            $this->authorize('update', $this->page);
+
+            return;
+        }
+
+        $this->authorize('create', Page::class);
+    }
+
+    private function forgetNavigation(Store $store, NavigationService $navigation): void
+    {
+        NavigationMenu::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->get()
+            ->each(fn (NavigationMenu $menu): mixed => $navigation->forget($menu));
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Form.php

<?php

namespace App\Livewire\Admin\Pages;

use App\Enums\PageStatus;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Services\NavigationService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;

class Form extends Component
{
    use AuthorizesRequests;

    public ?Page $page = null;

    public string $title = '';

    public string $handle = '';

    public string $bodyHtml = '';

    public string $status = 'draft';

    public string $publishedAt = '';

    public function mount(?Page $page = null): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        if ($page?->exists) {
            abort_unless((int) $page->store_id === $store->getKey(), 404);

            $this->authorize('update', $page);

            $this->page = $page;
            $this->fillFromPage($page);

            return;
        }

        $this->authorize('create', Page::class);
    }

    public function updatedTitle(string $title): void
    {
        if ($this->page instanceof Page || $this->handle !== '') {
            return;
        }

        $this->handle = Str::slug($title);
    }

    public function save(NavigationService $navigation): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorizeSave();

        $this->handle = Str::slug($this->handle);

        $this->validate([
            'title' => ['required', 'string', 'max:255'],
            'handle' => [
                'required',
                'string',
                'max:255',
                'alpha_dash',
                Rule::unique('pages', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($this->page?->getKey()),
            ],
            'bodyHtml' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(PageStatus::cases(), 'value'))],
            'publishedAt' => ['nullable', 'date'],
        ], [], [
            'bodyHtml' => 'body',
            'publishedAt' => 'published at',
        ]);

        $publishedAt = $this->publishedAt !== '' ? $this->publishedAt : null;

        if ($this->status === PageStatus::Published->value && $publishedAt === null) {
            $publishedAt = now();
        }

        $page = $this->page instanceof Page
            ? tap($this->page)->update($this->payload($store, $publishedAt))
            : Page::withoutGlobalScopes()->create($this->payload($store, $publishedAt));

        $this->page = $page->refresh();
        $this->fillFromPage($this->page);
        $this->forgetNavigation($store, $navigation);

        session()->flash('status', 'Page saved');
        $this->dispatch('toast', type: 'success', message: __('Page saved'));
    }

    public function deletePage(NavigationService $navigation): void
    {
        abort_unless($this->page instanceof Page, 404);

        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('delete', $this->page);

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

        $this->redirectRoute('admin.pages.index', navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.admin.pages.form', [
            'isEditing' => $this->page instanceof Page,
        ])->layout('layouts.app', [
            'title' => $this->page ? __('Edit page') : __('Create page'),
        ]);
    }

    private function fillFromPage(Page $page): void
    {
        $this->title = $page->title;
        $this->handle = $page->handle;
        $this->bodyHtml = (string) $page->body_html;
        $this->status = $page->status->value;
        $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i') ?? '';
    }

    /**
     * @return array<string, mixed>
     */
    private function payload(Store $store, mixed $publishedAt): array
    {
        return [
            'store_id' => $store->getKey(),
            'title' => $this->title,
            'handle' => $this->handle,
            'body_html' => $this->bodyHtml,
            'status' => PageStatus::from($this->status),
            'published_at' => $publishedAt,
        ];
    }

    private function authorizeSave(): void
    {
        if ($this->page instanceof Page) {
            $this->authorize('update', $this->page);

            return;
        }

        $this->authorize('create', Page::class);
    }

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


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Pages/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php

<?php

namespace App\Livewire\Admin\Navigation;

use Livewire\Component;

class Index extends Component
{
    public function render()
    {
        return view('livewire.admin.navigation.index');
    }
}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php
+<?php
+
+namespace App\Livewire\Admin\Navigation;
+
+use App\Enums\NavigationItemType;
+use App\Models\Collection as ProductCollection;
+use App\Models\NavigationItem;
+use App\Models\NavigationMenu;
+use App\Models\Page;
+use App\Models\Product;
+use App\Models\Store;
+use App\Services\NavigationService;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
+use Livewire\Attributes\Locked;
+use Livewire\Component;
+
+class Index extends Component
+{
+    use AuthorizesRequests;
+
+    #[Locked]
+    public int $storeId;
+
+    public ?int $selectedMenuId = null;
+
+    /**
+     * @var array<int, array{id: int|null, label: string, type: string, url: string|null, resource_id: int|null}>
+     */
+    public array $menuItems = [];
+
+    public ?int $editingItemIndex = null;
+
+    public string $itemLabel = '';
+
+    public string $itemType = 'link';
+
+    public string $itemUrl = '';
+
+    public ?int $itemResourceId = null;
+
+    public function mount(): void
+    {
+        $store = $this->store();
+
+        $this->authorize('update', $store);
+
+        $this->storeId = $store->getKey();
+        $this->selectedMenuId = $this->menus()->first()?->getKey();
+
+        if ($this->selectedMenuId !== null) {
+            $this->loadMenuItems();
+        }
+    }
+
+    public function selectMenu(int $menuId): void
+    {
+        $this->authorize('update', $this->scopedStore());
+
+        $this->selectedMenuId = $this->menu($menuId)->getKey();
+        $this->loadMenuItems();
+        $this->cancelItem();
+    }
+
+    public function addItem(): void
+    {
+        $this->editingItemIndex = null;
+        $this->itemLabel = '';
+        $this->itemType = 'link';
+        $this->itemUrl = '';
+        $this->itemResourceId = null;
+    }
+
+    public function editItem(int $index): void
+    {
+        abort_unless(isset($this->menuItems[$index]), 404);
+
+        $item = $this->menuItems[$index];
+
+        $this->editingItemIndex = $index;
+        $this->itemLabel = $item['label'];
+        $this->itemType = $item['type'];
+        $this->itemUrl = (string) ($item['url'] ?? '');
+        $this->itemResourceId = $item['resource_id'];
+    }
+
+    public function saveItem(): void
+    {
+        $this->authorize('update', $this->scopedStore());
+
+        $validated = $this->validate([
+            'itemLabel' => ['required', 'string', 'max:255'],
+            'itemType' => ['required', Rule::in(array_column(NavigationItemType::cases(), 'value'))],
+            'itemUrl' => [Rule::requiredIf($this->itemType === NavigationItemType::Link->value), 'nullable', 'string', 'max:255'],
+            'itemResourceId' => [Rule::requiredIf($this->itemType !== NavigationItemType::Link->value), 'nullable', 'integer'],
+        ], [], [
+            'itemLabel' => 'label',
+            'itemType' => 'type',
+            'itemUrl' => 'URL',
+            'itemResourceId' => 'resource',
+        ]);
+
+        if ($this->itemType !== NavigationItemType::Link->value && ! $this->resourceExists($this->itemType, (int) $this->itemResourceId)) {
+            $this->addError('itemResourceId', __('Select a valid resource for this store.'));
+
+            return;
+        }
+
+        $item = [
+            'id' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['id'] : null,
+            'label' => $validated['itemLabel'],
+            'type' => $validated['itemType'],
+            'url' => $this->itemType === NavigationItemType::Link->value ? $validated['itemUrl'] : null,
+            'resource_id' => $this->itemType === NavigationItemType::Link->value ? null : (int) $this->itemResourceId,
+        ];
+
+        if ($this->editingItemIndex === null) {
+            $this->menuItems[] = $item;
+        } else {
+            $this->menuItems[$this->editingItemIndex] = $item;
+        }
+
+        $this->cancelItem();
+    }
+
+    public function removeItem(int $index): void
+    {
+        unset($this->menuItems[$index]);
+
+        $this->menuItems = array_values($this->menuItems);
+    }
+
+    public function moveItemUp(int $index): void
+    {
+        if ($index <= 0 || ! isset($this->menuItems[$index])) {
+            return;
+        }
+
+        [$this->menuItems[$index - 1], $this->menuItems[$index]] = [$this->menuItems[$index], $this->menuItems[$index - 1]];
+    }
+
+    public function moveItemDown(int $index): void
+    {
+        if (! isset($this->menuItems[$index], $this->menuItems[$index + 1])) {
+            return;
+        }
+
+        [$this->menuItems[$index], $this->menuItems[$index + 1]] = [$this->menuItems[$index + 1], $this->menuItems[$index]];
+    }
+
+    public function saveMenu(NavigationService $navigation): void
+    {
+        $this->authorize('update', $this->scopedStore());
+
+        $menu = $this->selectedMenu();
+
+        DB::transaction(function () use ($menu): void {
+            NavigationItem::withoutGlobalScopes()
+                ->where('menu_id', $menu->getKey())
+                ->delete();
+
+            foreach ($this->menuItems as $position => $item) {
+                NavigationItem::withoutGlobalScopes()->create([
+                    'menu_id' => $menu->getKey(),
+                    'type' => NavigationItemType::from($item['type']),
+                    'label' => $item['label'],
+                    'url' => $item['url'],
+                    'resource_id' => $item['resource_id'],
+                    'position' => $position,
+                ]);
+            }
+        });
+
+        $navigation->forget($menu);
+        $this->loadMenuItems();
+
+        session()->flash('status', 'Navigation saved');
+        $this->dispatch('toast', type: 'success', message: __('Navigation saved'));
+    }
+
+    public function cancelItem(): void
+    {
+        $this->editingItemIndex = null;
+        $this->itemLabel = '';
+        $this->itemType = 'link';
+        $this->itemUrl = '';
+        $this->itemResourceId = null;
+        $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId']);
+    }
+
+    /**
+     * @return Collection<int, NavigationMenu>
+     */
+    public function menus(): Collection
+    {
+        return NavigationMenu::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->orderBy('title')
+            ->get();
+    }
+
+    /**
+     * @return Collection<int, Page|ProductCollection|Product>
+     */
+    public function resourcesForType(): Collection
+    {
+        return match ($this->itemType) {
+            NavigationItemType::Page->value => Page::withoutGlobalScopes()
+                ->where('store_id', $this->storeId)
+                ->orderBy('title')
+                ->get(['id', 'title']),
+            NavigationItemType::Collection->value => ProductCollection::withoutGlobalScopes()
+                ->where('store_id', $this->storeId)
+                ->orderBy('title')
+                ->get(['id', 'title']),
+            NavigationItemType::Product->value => Product::withoutGlobalScopes()
+                ->where('store_id', $this->storeId)
+                ->orderBy('title')
+                ->limit(100)
+                ->get(['id', 'title']),
+            default => collect(),
+        };
+    }
+
+    public function targetLabel(array $item): string
+    {
+        return match ($item['type']) {
+            NavigationItemType::Page->value => 'page: '.$this->resourceTitle(Page::class, $item['resource_id']),
+            NavigationItemType::Collection->value => 'collection: '.$this->resourceTitle(ProductCollection::class, $item['resource_id']),
+            NavigationItemType::Product->value => 'product: '.$this->resourceTitle(Product::class, $item['resource_id']),
+            default => 'link: '.($item['url'] ?: '#'),
+        };
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.navigation.index', [
+            'menus' => $this->menus(),
+            'selectedMenu' => $this->selectedMenu(),
+            'resources' => $this->resourcesForType(),
+        ])->layout('layouts.app', [
+            'title' => __('Navigation'),
+        ]);
+    }
+
+    private function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
+    private function scopedStore(): Store
+    {
+        return Store::query()->whereKey($this->storeId)->firstOrFail();
+    }
+
+    private function selectedMenu(): NavigationMenu
+    {
+        abort_unless($this->selectedMenuId !== null, 404);
+
+        return $this->menu($this->selectedMenuId);
+    }
+
+    private function menu(int $menuId): NavigationMenu
+    {
+        return NavigationMenu::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->whereKey($menuId)
+            ->firstOrFail();
+    }
+
+    private function loadMenuItems(): void
+    {
+        $this->menuItems = NavigationItem::withoutGlobalScopes()
+            ->where('menu_id', $this->selectedMenu()->getKey())
+            ->orderBy('position')
+            ->get()
+            ->map(fn (NavigationItem $item): array => [
+                'id' => $item->getKey(),
+                'label' => $item->label,
+                'type' => $item->type->value,
+                'url' => $item->url,
+                'resource_id' => $item->resource_id,
+            ])
+            ->all();
+    }
+
+    private function resourceExists(string $type, int $resourceId): bool
+    {
+        return match ($type) {
+            NavigationItemType::Page->value => Page::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(),
+            NavigationItemType::Collection->value => ProductCollection::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(),
+            NavigationItemType::Product->value => Product::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(),
+            default => true,
+        };
+    }
+
+    private function resourceTitle(string $modelClass, mixed $resourceId): string
+    {
+        if (! is_numeric($resourceId)) {
+            return 'Missing';
+        }
+
+        return $modelClass::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->whereKey((int) $resourceId)
+            ->value('title') ?? 'Missing';
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php

<?php

namespace App\Livewire\Admin\Navigation;

use App\Enums\NavigationItemType;
use App\Models\Collection as ProductCollection;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Product;
use App\Models\Store;
use App\Services\NavigationService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public ?int $selectedMenuId = null;

    /**
     * @var array<int, array{id: int|null, label: string, type: string, url: string|null, resource_id: int|null}>
     */
    public array $menuItems = [];

    public ?int $editingItemIndex = null;

    public string $itemLabel = '';

    public string $itemType = 'link';

    public string $itemUrl = '';

    public ?int $itemResourceId = null;

    public function mount(): void
    {
        $store = $this->store();

        $this->authorize('update', $store);

        $this->storeId = $store->getKey();
        $this->selectedMenuId = $this->menus()->first()?->getKey();

        if ($this->selectedMenuId !== null) {
            $this->loadMenuItems();
        }
    }

    public function selectMenu(int $menuId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->selectedMenuId = $this->menu($menuId)->getKey();
        $this->loadMenuItems();
        $this->cancelItem();
    }

    public function addItem(): void
    {
        $this->editingItemIndex = null;
        $this->itemLabel = '';
        $this->itemType = 'link';
        $this->itemUrl = '';
        $this->itemResourceId = null;
    }

    public function editItem(int $index): void
    {
        abort_unless(isset($this->menuItems[$index]), 404);

        $item = $this->menuItems[$index];

        $this->editingItemIndex = $index;
        $this->itemLabel = $item['label'];
        $this->itemType = $item['type'];
        $this->itemUrl = (string) ($item['url'] ?? '');
        $this->itemResourceId = $item['resource_id'];
    }

    public function saveItem(): void
    {
        $this->authorize('update', $this->scopedStore());

        $validated = $this->validate([
            'itemLabel' => ['required', 'string', 'max:255'],
            'itemType' => ['required', Rule::in(array_column(NavigationItemType::cases(), 'value'))],
            'itemUrl' => [Rule::requiredIf($this->itemType === NavigationItemType::Link->value), 'nullable', 'string', 'max:255'],
            'itemResourceId' => [Rule::requiredIf($this->itemType !== NavigationItemType::Link->value), 'nullable', 'integer'],
        ], [], [
            'itemLabel' => 'label',
            'itemType' => 'type',
            'itemUrl' => 'URL',
            'itemResourceId' => 'resource',
        ]);

        if ($this->itemType !== NavigationItemType::Link->value && ! $this->resourceExists($this->itemType, (int) $this->itemResourceId)) {
            $this->addError('itemResourceId', __('Select a valid resource for this store.'));

            return;
        }

        $item = [
            'id' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['id'] : null,
            'label' => $validated['itemLabel'],
            'type' => $validated['itemType'],
            'url' => $this->itemType === NavigationItemType::Link->value ? $validated['itemUrl'] : null,
            'resource_id' => $this->itemType === NavigationItemType::Link->value ? null : (int) $this->itemResourceId,
        ];

        if ($this->editingItemIndex === null) {
            $this->menuItems[] = $item;
        } else {
            $this->menuItems[$this->editingItemIndex] = $item;
        }

        $this->cancelItem();
    }

    public function removeItem(int $index): void
    {
        unset($this->menuItems[$index]);

        $this->menuItems = array_values($this->menuItems);
    }

    public function moveItemUp(int $index): void
    {
        if ($index <= 0 || ! isset($this->menuItems[$index])) {
            return;
        }

        [$this->menuItems[$index - 1], $this->menuItems[$index]] = [$this->menuItems[$index], $this->menuItems[$index - 1]];
    }

    public function moveItemDown(int $index): void
    {
        if (! isset($this->menuItems[$index], $this->menuItems[$index + 1])) {
            return;
        }

        [$this->menuItems[$index], $this->menuItems[$index + 1]] = [$this->menuItems[$index + 1], $this->menuItems[$index]];
    }

    public function saveMenu(NavigationService $navigation): void
    {
        $this->authorize('update', $this->scopedStore());

        $menu = $this->selectedMenu();

        DB::transaction(function () use ($menu): void {
            NavigationItem::withoutGlobalScopes()
                ->where('menu_id', $menu->getKey())
                ->delete();

            foreach ($this->menuItems as $position => $item) {
                NavigationItem::withoutGlobalScopes()->create([
                    'menu_id' => $menu->getKey(),
                    'type' => NavigationItemType::from($item['type']),
                    'label' => $item['label'],
                    'url' => $item['url'],
                    'resource_id' => $item['resource_id'],
                    'position' => $position,
                ]);
            }
        });

        $navigation->forget($menu);
        $this->loadMenuItems();

        session()->flash('status', 'Navigation saved');
        $this->dispatch('toast', type: 'success', message: __('Navigation saved'));
    }

    public function cancelItem(): void
    {
        $this->editingItemIndex = null;
        $this->itemLabel = '';
        $this->itemType = 'link';
        $this->itemUrl = '';
        $this->itemResourceId = null;
        $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId']);
    }

    /**
     * @return Collection<int, NavigationMenu>
     */
    public function menus(): Collection
    {
        return NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->orderBy('title')
            ->get();
    }

    /**
     * @return Collection<int, Page|ProductCollection|Product>
     */
    public function resourcesForType(): Collection
    {
        return match ($this->itemType) {
            NavigationItemType::Page->value => Page::withoutGlobalScopes()
                ->where('store_id', $this->storeId)
                ->orderBy('title')
                ->get(['id', 'title']),
            NavigationItemType::Collection->value => ProductCollection::withoutGlobalScopes()
                ->where('store_id', $this->storeId)
                ->orderBy('title')
                ->get(['id', 'title']),
            NavigationItemType::Product->value => Product::withoutGlobalScopes()
                ->where('store_id', $this->storeId)
                ->orderBy('title')
                ->limit(100)
                ->get(['id', 'title']),
            default => collect(),
        };
    }

    public function targetLabel(array $item): string
    {
        return match ($item['type']) {
            NavigationItemType::Page->value => 'page: '.$this->resourceTitle(Page::class, $item['resource_id']),
            NavigationItemType::Collection->value => 'collection: '.$this->resourceTitle(ProductCollection::class, $item['resource_id']),
            NavigationItemType::Product->value => 'product: '.$this->resourceTitle(Product::class, $item['resource_id']),
            default => 'link: '.($item['url'] ?: '#'),
        };
    }

    public function render(): mixed
    {
        return view('livewire.admin.navigation.index', [
            'menus' => $this->menus(),
            'selectedMenu' => $this->selectedMenu(),
            'resources' => $this->resourcesForType(),
        ])->layout('layouts.app', [
            'title' => __('Navigation'),
        ]);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function selectedMenu(): NavigationMenu
    {
        abort_unless($this->selectedMenuId !== null, 404);

        return $this->menu($this->selectedMenuId);
    }

    private function menu(int $menuId): NavigationMenu
    {
        return NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->whereKey($menuId)
            ->firstOrFail();
    }

    private function loadMenuItems(): void
    {
        $this->menuItems = NavigationItem::withoutGlobalScopes()
            ->where('menu_id', $this->selectedMenu()->getKey())
            ->orderBy('position')
            ->get()
            ->map(fn (NavigationItem $item): array => [
                'id' => $item->getKey(),
                'label' => $item->label,
                'type' => $item->type->value,
                'url' => $item->url,
                'resource_id' => $item->resource_id,
            ])
            ->all();
    }

    private function resourceExists(string $type, int $resourceId): bool
    {
        return match ($type) {
            NavigationItemType::Page->value => Page::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(),
            NavigationItemType::Collection->value => ProductCollection::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(),
            NavigationItemType::Product->value => Product::withoutGlobalScopes()->where('store_id', $this->storeId)->whereKey($resourceId)->exists(),
            default => true,
        };
    }

    private function resourceTitle(string $modelClass, mixed $resourceId): string
    {
        if (! is_numeric($resourceId)) {
            return 'Missing';
        }

        return $modelClass::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->whereKey((int) $resourceId)
            ->value('title') ?? 'Missing';
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Index.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Index.php

<?php

namespace App\Livewire\Admin\Themes;

use Livewire\Component;

class Index extends Component
{
    public function render()
    {
        return view('livewire.admin.themes.index');
    }
}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Index.php
+<?php
+
+namespace App\Livewire\Admin\Themes;
+
+use App\Enums\ThemeStatus;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeFile;
+use App\Models\ThemeSettings;
+use App\Services\ThemeSettingsService;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Locked;
+use Livewire\Component;
+
+class Index extends Component
+{
+    use AuthorizesRequests;
+
+    #[Locked]
+    public int $storeId;
+
+    public function mount(): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $this->authorize('viewAny', Theme::class);
+
+        $this->storeId = $store->getKey();
+    }
+
+    public function publishTheme(int $themeId, ThemeSettingsService $settings): void
+    {
+        $theme = $this->theme($themeId);
+
+        $this->authorize('publish', $theme);
+
+        DB::transaction(function () use ($theme): void {
+            Theme::withoutGlobalScopes()
+                ->where('store_id', $this->storeId)
+                ->update([
+                    'status' => ThemeStatus::Draft,
+                    'published_at' => null,
+                ]);
+
+            $theme->forceFill([
+                'status' => ThemeStatus::Published,
+                'published_at' => now(),
+            ])->save();
+        });
+
+        $settings->forget($this->store());
+
+        session()->flash('status', 'Theme published');
+        $this->dispatch('toast', type: 'success', message: __('Theme published'));
+    }
+
+    public function duplicateTheme(int $themeId): void
+    {
+        $theme = $this->theme($themeId);
+
+        $this->authorize('create', Theme::class);
+
+        DB::transaction(function () use ($theme): void {
+            $copy = Theme::withoutGlobalScopes()->create([
+                'store_id' => $this->storeId,
+                'name' => $theme->name.' Copy',
+                'version' => $theme->version,
+                'status' => ThemeStatus::Draft,
+                'published_at' => null,
+            ]);
+
+            $theme->files()
+                ->withoutGlobalScopes()
+                ->get()
+                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
+                    'theme_id' => $copy->getKey(),
+                    'path' => $file->path,
+                    'storage_key' => $file->storage_key,
+                    'sha256' => $file->sha256,
+                    'byte_size' => $file->byte_size,
+                ]));
+
+            ThemeSettings::withoutGlobalScopes()->create([
+                'theme_id' => $copy->getKey(),
+                'settings_json' => $theme->settings?->settings_json ?? [],
+                'updated_at' => now(),
+            ]);
+        });
+
+        session()->flash('status', 'Theme duplicated');
+    }
+
+    public function deleteTheme(int $themeId): void
+    {
+        $theme = $this->theme($themeId);
+
+        $this->authorize('delete', $theme);
+
+        if ($theme->isPublished()) {
+            $this->addError('theme', __('Published themes cannot be deleted.'));
+
+            return;
+        }
+
+        $theme->delete();
+
+        session()->flash('status', 'Theme deleted');
+    }
+
+    /**
+     * @return Collection<int, Theme>
+     */
+    public function themes(): Collection
+    {
+        return Theme::withoutGlobalScopes()
+            ->withCount('files')
+            ->where('store_id', $this->storeId)
+            ->orderByRaw("case when status = 'published' then 0 else 1 end")
+            ->orderBy('name')
+            ->get();
+    }
+
+    public function statusColor(Theme $theme): string
+    {
+        return $theme->status === ThemeStatus::Published ? 'green' : 'zinc';
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.themes.index', [
+            'themes' => $this->themes(),
+        ])->layout('layouts.app', [
+            'title' => __('Themes'),
+        ]);
+    }
+
+    private function store(): Store
+    {
+        return Store::query()->whereKey($this->storeId)->firstOrFail();
+    }
+
+    private function theme(int $themeId): Theme
+    {
+        return Theme::withoutGlobalScopes()
+            ->with(['files', 'settings'])
+            ->where('store_id', $this->storeId)
+            ->whereKey($themeId)
+            ->firstOrFail();
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Index.php

<?php

namespace App\Livewire\Admin\Themes;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use App\Services\ThemeSettingsService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('viewAny', Theme::class);

        $this->storeId = $store->getKey();
    }

    public function publishTheme(int $themeId, ThemeSettingsService $settings): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('publish', $theme);

        DB::transaction(function () use ($theme): void {
            Theme::withoutGlobalScopes()
                ->where('store_id', $this->storeId)
                ->update([
                    'status' => ThemeStatus::Draft,
                    'published_at' => null,
                ]);

            $theme->forceFill([
                'status' => ThemeStatus::Published,
                'published_at' => now(),
            ])->save();
        });

        $settings->forget($this->store());

        session()->flash('status', 'Theme published');
        $this->dispatch('toast', type: 'success', message: __('Theme published'));
    }

    public function duplicateTheme(int $themeId): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('create', Theme::class);

        DB::transaction(function () use ($theme): void {
            $copy = Theme::withoutGlobalScopes()->create([
                'store_id' => $this->storeId,
                'name' => $theme->name.' Copy',
                'version' => $theme->version,
                'status' => ThemeStatus::Draft,
                'published_at' => null,
            ]);

            $theme->files()
                ->withoutGlobalScopes()
                ->get()
                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
                    'theme_id' => $copy->getKey(),
                    'path' => $file->path,
                    'storage_key' => $file->storage_key,
                    'sha256' => $file->sha256,
                    'byte_size' => $file->byte_size,
                ]));

            ThemeSettings::withoutGlobalScopes()->create([
                'theme_id' => $copy->getKey(),
                'settings_json' => $theme->settings?->settings_json ?? [],
                'updated_at' => now(),
            ]);
        });

        session()->flash('status', 'Theme duplicated');
    }

    public function deleteTheme(int $themeId): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('delete', $theme);

        if ($theme->isPublished()) {
            $this->addError('theme', __('Published themes cannot be deleted.'));

            return;
        }

        $theme->delete();

        session()->flash('status', 'Theme deleted');
    }

    /**
     * @return Collection<int, Theme>
     */
    public function themes(): Collection
    {
        return Theme::withoutGlobalScopes()
            ->withCount('files')
            ->where('store_id', $this->storeId)
            ->orderByRaw("case when status = 'published' then 0 else 1 end")
            ->orderBy('name')
            ->get();
    }

    public function statusColor(Theme $theme): string
    {
        return $theme->status === ThemeStatus::Published ? 'green' : 'zinc';
    }

    public function render(): mixed
    {
        return view('livewire.admin.themes.index', [
            'themes' => $this->themes(),
        ])->layout('layouts.app', [
            'title' => __('Themes'),
        ]);
    }

    private function store(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function theme(int $themeId): Theme
    {
        return Theme::withoutGlobalScopes()
            ->with(['files', 'settings'])
            ->where('store_id', $this->storeId)
            ->whereKey($themeId)
            ->firstOrFail();
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Editor.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Editor.php

<?php

namespace App\Livewire\Admin\Themes;

use Livewire\Component;

class Editor extends Component
{
    public function render()
    {
        return view('livewire.admin.themes.editor');
    }
}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Editor.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Editor.php
+<?php
+
+namespace App\Livewire\Admin\Themes;
+
+use App\Enums\ThemeStatus;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeSettings;
+use App\Services\ThemeSettingsService;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\DB;
+use Livewire\Component;
+
+class Editor extends Component
+{
+    use AuthorizesRequests;
+
+    public Theme $theme;
+
+    public string $selectedSection = 'announcement';
+
+    /**
+     * @var array<string, mixed>
+     */
+    public array $settings = [];
+
+    public function mount(Theme $theme, ThemeSettingsService $themeSettings): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+        abort_unless((int) $theme->store_id === $store->getKey(), 404);
+
+        $this->authorize('update', $theme);
+
+        $this->theme = $theme;
+        $this->settings = array_replace_recursive(
+            $themeSettings->defaultsForStore($store),
+            $theme->settings?->settings_json ?? [],
+        );
+    }
+
+    public function selectSection(string $sectionKey): void
+    {
+        abort_unless(array_key_exists($sectionKey, $this->sections()), 404);
+
+        $this->selectedSection = $sectionKey;
+    }
+
+    public function save(ThemeSettingsService $settings): void
+    {
+        $this->authorize('update', $this->theme);
+
+        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
+            ['theme_id' => $this->theme->getKey()],
+            [
+                'settings_json' => $this->settings,
+                'updated_at' => now(),
+            ],
+        );
+
+        $settings->forget($this->store());
+
+        session()->flash('status', 'Theme saved');
+        $this->dispatch('toast', type: 'success', message: __('Theme saved'));
+    }
+
+    public function publish(ThemeSettingsService $settings): void
+    {
+        $this->authorize('publish', $this->theme);
+
+        DB::transaction(function (): void {
+            Theme::withoutGlobalScopes()
+                ->where('store_id', $this->theme->store_id)
+                ->update([
+                    'status' => ThemeStatus::Draft,
+                    'published_at' => null,
+                ]);
+
+            $this->theme->forceFill([
+                'status' => ThemeStatus::Published,
+                'published_at' => now(),
+            ])->save();
+        });
+
+        $this->save($settings);
+
+        session()->flash('status', 'Theme saved and published');
+    }
+
+    public function refreshPreview(): void
+    {
+        $this->dispatch('theme-preview-refresh');
+    }
+
+    /**
+     * @return array<string, array{label: string, fields: array<int, array{key: string, label: string, type: string, options?: array<string, string>}>}>
+     */
+    public function sections(): array
+    {
+        return [
+            'announcement' => [
+                'label' => 'Announcement',
+                'fields' => [
+                    ['key' => 'announcement.enabled', 'label' => 'Enabled', 'type' => 'checkbox'],
+                    ['key' => 'announcement.text', 'label' => 'Text', 'type' => 'text'],
+                    ['key' => 'announcement.url', 'label' => 'URL', 'type' => 'text'],
+                ],
+            ],
+            'header' => [
+                'label' => 'Header',
+                'fields' => [
+                    ['key' => 'header.sticky', 'label' => 'Sticky header', 'type' => 'checkbox'],
+                    ['key' => 'header.main_menu', 'label' => 'Main menu handle', 'type' => 'text'],
+                ],
+            ],
+            'home' => [
+                'label' => 'Home hero',
+                'fields' => [
+                    ['key' => 'home.hero.eyebrow', 'label' => 'Eyebrow', 'type' => 'text'],
+                    ['key' => 'home.hero.heading', 'label' => 'Heading', 'type' => 'text'],
+                    ['key' => 'home.hero.subheading', 'label' => 'Subheading', 'type' => 'textarea'],
+                    ['key' => 'home.hero.primary_label', 'label' => 'Primary button label', 'type' => 'text'],
+                    ['key' => 'home.hero.primary_url', 'label' => 'Primary button URL', 'type' => 'text'],
+                    ['key' => 'home.featured_product_limit', 'label' => 'Featured products', 'type' => 'number'],
+                    ['key' => 'home.featured_collection_limit', 'label' => 'Featured collections', 'type' => 'number'],
+                ],
+            ],
+            'footer' => [
+                'label' => 'Footer',
+                'fields' => [
+                    ['key' => 'footer.menu', 'label' => 'Menu handle', 'type' => 'text'],
+                    ['key' => 'footer.tagline', 'label' => 'Tagline', 'type' => 'textarea'],
+                ],
+            ],
+        ];
+    }
+
+    public function previewUrl(): string
+    {
+        return route('home');
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.themes.editor', [
+            'sections' => $this->sections(),
+            'activeSection' => $this->sections()[$this->selectedSection],
+            'previewUrl' => $this->previewUrl(),
+        ])->layout('layouts.app', [
+            'title' => __('Theme editor'),
+        ]);
+    }
+
+    private function store(): Store
+    {
+        return Store::query()->whereKey($this->theme->store_id)->firstOrFail();
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Editor.php

<?php

namespace App\Livewire\Admin\Themes;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSettings;
use App\Services\ThemeSettingsService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;

class Editor extends Component
{
    use AuthorizesRequests;

    public Theme $theme;

    public string $selectedSection = 'announcement';

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

    public function mount(Theme $theme, ThemeSettingsService $themeSettings): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);
        abort_unless((int) $theme->store_id === $store->getKey(), 404);

        $this->authorize('update', $theme);

        $this->theme = $theme;
        $this->settings = array_replace_recursive(
            $themeSettings->defaultsForStore($store),
            $theme->settings?->settings_json ?? [],
        );
    }

    public function selectSection(string $sectionKey): void
    {
        abort_unless(array_key_exists($sectionKey, $this->sections()), 404);

        $this->selectedSection = $sectionKey;
    }

    public function save(ThemeSettingsService $settings): void
    {
        $this->authorize('update', $this->theme);

        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
            ['theme_id' => $this->theme->getKey()],
            [
                'settings_json' => $this->settings,
                'updated_at' => now(),
            ],
        );

        $settings->forget($this->store());

        session()->flash('status', 'Theme saved');
        $this->dispatch('toast', type: 'success', message: __('Theme saved'));
    }

    public function publish(ThemeSettingsService $settings): void
    {
        $this->authorize('publish', $this->theme);

        DB::transaction(function (): void {
            Theme::withoutGlobalScopes()
                ->where('store_id', $this->theme->store_id)
                ->update([
                    'status' => ThemeStatus::Draft,
                    'published_at' => null,
                ]);

            $this->theme->forceFill([
                'status' => ThemeStatus::Published,
                'published_at' => now(),
            ])->save();
        });

        $this->save($settings);

        session()->flash('status', 'Theme saved and published');
    }

    public function refreshPreview(): void
    {
        $this->dispatch('theme-preview-refresh');
    }

    /**
     * @return array<string, array{label: string, fields: array<int, array{key: string, label: string, type: string, options?: array<string, string>}>}>
     */
    public function sections(): array
    {
        return [
            'announcement' => [
                'label' => 'Announcement',
                'fields' => [
                    ['key' => 'announcement.enabled', 'label' => 'Enabled', 'type' => 'checkbox'],
                    ['key' => 'announcement.text', 'label' => 'Text', 'type' => 'text'],
                    ['key' => 'announcement.url', 'label' => 'URL', 'type' => 'text'],
                ],
            ],
            'header' => [
                'label' => 'Header',
                'fields' => [
                    ['key' => 'header.sticky', 'label' => 'Sticky header', 'type' => 'checkbox'],
                    ['key' => 'header.main_menu', 'label' => 'Main menu handle', 'type' => 'text'],
                ],
            ],
            'home' => [
                'label' => 'Home hero',
                'fields' => [
                    ['key' => 'home.hero.eyebrow', 'label' => 'Eyebrow', 'type' => 'text'],
                    ['key' => 'home.hero.heading', 'label' => 'Heading', 'type' => 'text'],
                    ['key' => 'home.hero.subheading', 'label' => 'Subheading', 'type' => 'textarea'],
                    ['key' => 'home.hero.primary_label', 'label' => 'Primary button label', 'type' => 'text'],
                    ['key' => 'home.hero.primary_url', 'label' => 'Primary button URL', 'type' => 'text'],
                    ['key' => 'home.featured_product_limit', 'label' => 'Featured products', 'type' => 'number'],
                    ['key' => 'home.featured_collection_limit', 'label' => 'Featured collections', 'type' => 'number'],
                ],
            ],
            'footer' => [
                'label' => 'Footer',
                'fields' => [
                    ['key' => 'footer.menu', 'label' => 'Menu handle', 'type' => 'text'],
                    ['key' => 'footer.tagline', 'label' => 'Tagline', 'type' => 'textarea'],
                ],
            ],
        ];
    }

    public function previewUrl(): string
    {
        return route('home');
    }

    public function render(): mixed
    {
        return view('livewire.admin.themes.editor', [
            'sections' => $this->sections(),
            'activeSection' => $this->sections()[$this->selectedSection],
            'previewUrl' => $this->previewUrl(),
        ])->layout('layouts.app', [
            'title' => __('Theme editor'),
        ]);
    }

    private function store(): Store
    {
        return Store::query()->whereKey($this->theme->store_id)->firstOrFail();
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Themes/Editor.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The backend component classes are in place. I’m replacing the generated Blade stubs with the actual admin screens now, keeping each surface dense and form-first like the existing catalog/order/admin pages.


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/settings/index.blade.php
*** End Patch


Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/settings/index.blade.php

{{-- We must ship. - Taylor Otwell --}}


Output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/settings/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/settings/index.blade.php
+

+

+

+            <flux:heading size="xl">Settings</flux:heading>
+            <flux:text class="mt-1">Store defaults, checkout preferences, and domains.</flux:text>
+

+
+

+            <flux:button :href="route('admin.settings.index')" wire:navigate variant="primary">General</flux:button>
+            <flux:button :href="route('admin.settings.shipping')" wire:navigate variant="filled">Shipping</flux:button>
+            <flux:button :href="route('admin.settings.taxes')" wire:navigate variant="filled">Taxes</flux:button>
+

+

+
+    @if (session('status'))
+        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
+    @endif
+
+    <form wire:submit="save" class="space-y-6">
+

+

+

+                    <flux:heading size="lg">Store details</flux:heading>
+                    <flux:text class="mt-1">Basic storefront identity.</flux:text>
+

+
+

+                    <flux:input wire:model="storeName" label="Store name" />
+                    <flux:input wire:model="storeHandle" label="Store handle" disabled />
+                    <flux:error name="storeName" />
+

+

+
+            <flux:separator />
+
+

+

+                    <flux:heading size="lg">Defaults</flux:heading>
+                    <flux:text class="mt-1">Currency, language, and timezone.</flux:text>
+

+
+

+                    <flux:select wire:model="defaultCurrency" label="Default currency">
+                        @foreach ($currencyOptions as $currency)
+                            <flux:select.option value="{{ $currency }}">{{ $currency }}</flux:select.option>
+                        @endforeach
+                    </flux:select>
+
+                    <flux:select wire:model="defaultLocale" label="Default locale">
+                        @foreach ($localeOptions as $locale => $label)
+                            <flux:select.option value="{{ $locale }}">{{ $label }}</flux:select.option>
+                        @endforeach
+                    </flux:select>
+
+                    <flux:select wire:model="timezone" label="Timezone">
+                        @foreach ($timezoneOptions as $timezoneOption)
+                            <flux:select.option value="{{ $timezoneOption }}">{{ $timezoneOption }}</flux:select.option>
+                        @endforeach
+                    </flux:select>
+

+

+
+            <flux:separator />
+
+

+

+                    <flux:heading size="lg">Checkout</flux:heading>
+                    <flux:text class="mt-1">Customer-facing storefront defaults.</flux:text>
+

+
+

+                    <flux:switch wire:model="announcementEnabled" label="Announcement bar" align="left" />
+                    <flux:input wire:model="announcementText" label="Announcement text" />
+                    <flux:checkbox wire:model="guestCheckoutEnabled" label="Guest checkout enabled" />
+

+

+
+

+                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
+                    Save settings
+                    Saving...
+                </flux:button>
+

+

+    </form>
+
+

+

+

+                <flux:heading size="lg">Domains</flux:heading>
+                <flux:text class="mt-1">Hostnames connected to this store.</flux:text>
+

+
+

+                <flux:input wire:model="newHostname" label="Hostname" placeholder="shop.example.com" />
+                <flux:select wire:model="newType" label="Type">
+                    <flux:select.option value="storefront">Storefront</flux:select.option>
+                    <flux:select.option value="admin">Admin</flux:select.option>
+                    <flux:select.option value="api">API</flux:select.option>
+                </flux:select>
+

+                    <flux:button type="button" wire:click="addDomain" variant="primary" icon="plus">Add</flux:button>
+

+

+

+
+

+

+

+

+                        Hostname
+                        Type
+                        Primary
+                        TLS
+                        Actions
+

+

+

+                    @foreach ($domains as $domain)
+
getKey() }}">
+                            {{ $domain->hostname }}
+                            <flux:badge>{{ Str::headline($domain->type->value) }}</flux:badge>
+
+                                @if ($domain->is_primary)
+                                    <flux:badge color="green">Primary</flux:badge>
+                                @else
+                                    -
+                                @endif
+
+                            {{ Str::headline($domain->tls_mode) }}
+
+

+                                    @unless ($domain->is_primary)
+                                        <flux:button type="button" size="sm" variant="filled" wire:click="setPrimary({{ $domain->getKey() }})">Set primary</flux:button>
+                                    @endunless
+                                    <flux:button type="button" size="sm" variant="danger" wire:click="removeDomain({{ $domain->getKey() }})">Delete</flux:button>
+

+
+

+                    @endforeach
+

+

+

+

+

*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/settings/index.blade.php

            <flux:heading size="xl">Settings</flux:heading>
            <flux:text class="mt-1">Store defaults, checkout preferences, and domains.</flux:text>

            <flux:button :href="route('admin.settings.index')" wire:navigate variant="primary">General</flux:button>
            <flux:button :href="route('admin.settings.shipping')" wire:navigate variant="filled">Shipping</flux:button>
            <flux:button :href="route('admin.settings.taxes')" wire:navigate variant="filled">Taxes</flux:button>

    @if (session('status'))
        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
    @endif

    <form wire:submit="save" class="space-y-6">

                    <flux:heading size="lg">Store details</flux:heading>
                    <flux:text class="mt-1">Basic storefront identity.</flux:text>

                    <flux:input wire:model="storeName" label="Store name" />
                    <flux:input wire:model="storeHandle" label="Store handle" disabled />
                    <flux:error name="storeName" />

            <flux:separator />

                    <flux:heading size="lg">Defaults</flux:heading>
                    <flux:text class="mt-1">Currency, language, and timezone.</flux:text>

                    <flux:select wire:model="defaultCurrency" label="Default currency">
                        @foreach ($currencyOptions as $currency)
                            <flux:select.option value="{{ $currency }}">{{ $currency }}</flux:select.option>
                        @endforeach
                    </flux:select>

                    <flux:select wire:model="defaultLocale" label="Default locale">
                        @foreach ($localeOptions as $locale => $label)
                            <flux:select.option value="{{ $locale }}">{{ $label }}</flux:select.option>
                        @endforeach
                    </flux:select>

                    <flux:select wire:model="timezone" label="Timezone">
                        @foreach ($timezoneOptions as $timezoneOption)
                            <flux:select.option value="{{ $timezoneOption }}">{{ $timezoneOption }}</flux:select.option>
                        @endforeach
                    </flux:select>

            <flux:separator />

                    <flux:heading size="lg">Checkout</flux:heading>
                    <flux:text class="mt-1">Customer-facing storefront defaults.</flux:text>

                    <flux:switch wire:model="announcementEnabled" label="Announcement bar" align="left" />
                    <flux:input wire:model="announcementText" label="Announcement text" />
                    <flux:checkbox wire:model="guestCheckoutEnabled" label="Guest checkout enabled" />

                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
                    Save settings
                    Saving...
                </flux:button>

    </form>

                <flux:heading size="lg">Domains</flux:heading>
                <flux:text class="mt-1">Hostnames connected to this store.</flux:text>

                <flux:input wire:model="newHostname" label="Hostname" placeholder="shop.example.com" />
                <flux:select wire:model="newType" label="Type">
                    <flux:select.option value="storefront">Storefront</flux:select.option>
                    <flux:select.option value="admin">Admin</flux:select.option>
                    <flux:select.option value="api">API</flux:select.option>
                </flux:select>

                    <flux:button type="button" wire:click="addDomain" variant="primary" icon="plus">Add</flux:button>

                        Hostname
                        Type
                        Primary
                        TLS
                        Actions

                    @foreach ($domains as $domain)

getKey() }}">
                            {{ $domain->hostname }}
                            <flux:badge>{{ Str::headline($domain->type->value) }}</flux:badge>

                                @if ($domain->is_primary)
                                    <flux:badge color="green">Primary</flux:badge>
                                @else
                                    -
                                @endif

                            {{ Str::headline($domain->tls_mode) }}

                                    @unless ($domain->is_primary)
                                        <flux:button type="button" size="sm" variant="filled" wire:click="setPrimary({{ $domain->getKey() }})">Set primary</flux:button>
                                    @endunless
                                    <flux:button type="button" size="sm" variant="danger" wire:click="removeDomain({{ $domain->getKey() }})">Delete</flux:button>

                    @endforeach


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/settings/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


rg -n "variant=\"danger\"|color=\"red\"|variant=\"filled\"" resources/views/livewire/admin | head -80


rg -n "variant=\"danger\"|color=\"red\"|variant=\"filled\"" resources/views/livewire/admin | head -80

rg -n "variant=\\\"danger\\\"|color=\\\"red\\\"|variant=\\\"filled\\\"" resources/views/livewire/admin

Question: variant=\"danger\"|color=\"red\"|variant=\"filled\"

resources/views/livewire/admin/discounts/form.blade.php:8:        <flux:button :href="route('admin.discounts.index')" wire:navigate variant="filled" icon="arrow-left">
resources/views/livewire/admin/discounts/form.blade.php:35:                        <flux:button type="button" wire:click="generateCode" variant="filled">Generate</flux:button>
resources/views/livewire/admin/customers/show.blade.php:8:        <flux:button :href="route('admin.customers.index')" wire:navigate variant="filled" icon="arrow-left">
resources/views/livewire/admin/customers/show.blade.php:126:                            <flux:button wire:click="openAddressForm({{ $address->getKey() }})" size="sm" variant="filled">Edit</flux:button>
resources/views/livewire/admin/customers/show.blade.php:130:                            <flux:button wire:click="deleteAddress({{ $address->getKey() }})" wire:confirm="Delete this address?" size="sm" variant="danger">Delete</flux:button>
resources/views/livewire/admin/customers/show.blade.php:178:                    <flux:button variant="filled">Cancel</flux:button>
resources/views/livewire/admin/products/form.blade.php:14:            <flux:button wire:click="deleteProduct" variant="danger" icon="archive-box">Archive</flux:button>
resources/views/livewire/admin/products/index.blade.php:38:                <flux:button wire:click="bulkDelete" variant="danger">Delete</flux:button>
resources/views/livewire/admin/settings/index.blade.php:10:            <flux:button :href="route('admin.settings.shipping')" wire:navigate variant="filled">Shipping</flux:button>
resources/views/livewire/admin/settings/index.blade.php:11:            <flux:button :href="route('admin.settings.taxes')" wire:navigate variant="filled">Taxes</flux:button>
resources/views/livewire/admin/settings/index.blade.php:134:                                        <flux:button type="button" size="sm" variant="filled" wire:click="setPrimary({{ $domain->getKey() }})">Set primary</flux:button>
resources/views/livewire/admin/settings/index.blade.php:136:                                    <flux:button type="button" size="sm" variant="danger" wire:click="removeDomain({{ $domain->getKey() }})">Delete</flux:button>
resources/views/livewire/admin/orders/show.blade.php:23:                    <flux:button variant="danger" icon="receipt-refund">
resources/views/livewire/admin/orders/show.blade.php:31:                    <flux:button variant="filled" icon="truck">
resources/views/livewire/admin/orders/show.blade.php:138:                                        <flux:button wire:click="markFulfillmentShipped({{ $fulfillment->getKey() }})" size="sm" variant="filled">Mark shipped</flux:button>
resources/views/livewire/admin/orders/show.blade.php:141:                                        <flux:button wire:click="markFulfillmentDelivered({{ $fulfillment->getKey() }})" size="sm" variant="filled">Mark delivered</flux:button>
resources/views/livewire/admin/orders/show.blade.php:168:                    <flux:button :href="route('admin.customers.show', $order->customer)" wire:navigate variant="filled" size="sm" class="mt-4">
resources/views/livewire/admin/orders/show.blade.php:232:                    <flux:button variant="filled">Cancel</flux:button>
resources/views/livewire/admin/orders/show.blade.php:234:                <flux:button type="submit" variant="danger">Process refund</flux:button>
resources/views/livewire/admin/orders/show.blade.php:272:                    <flux:button variant="filled">Cancel</flux:button>