status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Settings/Index.php
+<?php
+
+namespace App\Livewire\Admin\Settings;
+
+use App\Enums\StoreDomainType;
+use App\Models\Store;
+use App\Models\StoreDomain;
+use App\Models\StoreSettings;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
+use Illuminate\Validation\Rule;
+use Livewire\Attributes\Locked;
+use Livewire\Component;
+
+class Index extends Component
+{
+    use AuthorizesRequests;
+
+    #[Locked]
+    public int $storeId;
+
+    public string $storeName = '';
+
+    public string $storeHandle = '';
+
+    public string $defaultCurrency = 'EUR';
+
+    public string $defaultLocale = 'en';
+
+    public string $timezone = 'UTC';
+
+    public bool $announcementEnabled = false;
+
+    public string $announcementText = '';
+
+    public bool $guestCheckoutEnabled = true;
+
+    public string $newHostname = '';
+
+    public string $newType = 'storefront';
+
+    public function mount(): void
+    {
+        $store = $this->store();
+
+        $this->authorize('update', $store);
+
+        $settings = $this->storeSettings($store)->settings_json ?? [];
+
+        $this->storeId = $store->getKey();
+        $this->storeName = $store->name;
+        $this->storeHandle = $store->handle;
+        $this->defaultCurrency = $store->default_currency;
+        $this->defaultLocale = $store->default_locale;
+        $this->timezone = $store->timezone;
+        $this->announcementEnabled = (bool) data_get($settings, 'announcement.enabled', false);
+        $this->announcementText = (string) data_get($settings, 'announcement.text', '');
+        $this->guestCheckoutEnabled = (bool) data_get($settings, 'checkout.guest_checkout_enabled', true);
+    }
+
+    public function save(): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $validated = $this->validate([
+            'storeName' => ['required', 'string', 'max:255'],
+            'defaultCurrency' => ['required', Rule::in($this->currencyOptions())],
+            'defaultLocale' => ['required', Rule::in(array_keys($this->localeOptions()))],
+            'timezone' => ['required', Rule::in(timezone_identifiers_list())],
+            'announcementEnabled' => ['boolean'],
+            'announcementText' => ['nullable', 'string', 'max:255'],
+            'guestCheckoutEnabled' => ['boolean'],
+        ]);
+
+        $store->forceFill([
+            'name' => $validated['storeName'],
+            'default_currency' => $validated['defaultCurrency'],
+            'default_locale' => $validated['defaultLocale'],
+            'timezone' => $validated['timezone'],
+        ])->save();
+
+        $settings = $this->storeSettings($store);
+        $settings->forceFill([
+            'settings_json' => array_replace_recursive($settings->settings_json ?? [], [
+                'announcement' => [
+                    'enabled' => $this->announcementEnabled,
+                    'text' => $this->announcementText,
+                ],
+                'checkout' => [
+                    'guest_checkout_enabled' => $this->guestCheckoutEnabled,
+                ],
+            ]),
+            'updated_at' => now(),
+        ])->save();
+
+        session()->flash('status', 'Settings saved');
+        $this->dispatch('toast', type: 'success', message: __('Settings saved'));
+    }
+
+    public function addDomain(): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $validated = $this->validate([
+            'newHostname' => [
+                'required',
+                'string',
+                'max:255',
+                'regex:/^[a-z0-9.-]+$/i',
+                Rule::unique('store_domains', 'hostname'),
+            ],
+            'newType' => ['required', Rule::in(array_column(StoreDomainType::cases(), 'value'))],
+        ], [], [
+            'newHostname' => 'hostname',
+            'newType' => 'domain type',
+        ]);
+
+        StoreDomain::query()->create([
+            'store_id' => $store->getKey(),
+            'hostname' => mb_strtolower(trim($validated['newHostname'])),
+            'type' => StoreDomainType::from($validated['newType']),
+            'is_primary' => $this->domains()->isEmpty(),
+            'tls_mode' => 'local',
+        ]);
+
+        $this->reset('newHostname');
+
+        session()->flash('status', 'Domain added');
+        $this->dispatch('toast', type: 'success', message: __('Domain added'));
+    }
+
+    public function setPrimary(int $domainId): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $domain = $this->domain($domainId);
+
+        StoreDomain::query()
+            ->where('store_id', $store->getKey())
+            ->update(['is_primary' => false]);
+
+        $domain->forceFill(['is_primary' => true])->save();
+
+        session()->flash('status', 'Primary domain updated');
+    }
+
+    public function removeDomain(int $domainId): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $domain = $this->domain($domainId);
+        $wasPrimary = $domain->is_primary;
+
+        $domain->delete();
+
+        if ($wasPrimary) {
+            StoreDomain::query()
+                ->where('store_id', $store->getKey())
+                ->orderBy('id')
+                ->first()
+                ?->forceFill(['is_primary' => true])
+                ->save();
+        }
+
+        session()->flash('status', 'Domain removed');
+    }
+
+    /**
+     * @return Collection<int, StoreDomain>
+     */
+    public function domains(): Collection
+    {
+        return StoreDomain::query()
+            ->where('store_id', $this->storeId)
+            ->orderByDesc('is_primary')
+            ->orderBy('hostname')
+            ->get();
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    public function localeOptions(): array
+    {
+        return [
+            'en' => 'English',
+            'de' => 'German',
+            'fr' => 'French',
+        ];
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function currencyOptions(): array
+    {
+        return ['EUR', 'USD', 'GBP', 'CHF'];
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function timezoneOptions(): array
+    {
+        return collect(timezone_identifiers_list())
+            ->filter(fn (string $timezone): bool => str_starts_with($timezone, 'Europe/') || str_starts_with($timezone, 'America/'))
+            ->values()
+            ->all();
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.settings.index', [
+            'domains' => $this->domains(),
+            'currencyOptions' => $this->currencyOptions(),
+            'localeOptions' => $this->localeOptions(),
+            'timezoneOptions' => $this->timezoneOptions(),
+        ])->layout('layouts.app', [
+            'title' => __('Settings'),
+        ]);
+    }
+
+    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 storeSettings(Store $store): StoreSettings
+    {
+        return StoreSettings::query()->firstOrCreate(
+            ['store_id' => $store->getKey()],
+            ['settings_json' => []],
+        );
+    }
+
+    private function domain(int $domainId): StoreDomain
+    {
+        return StoreDomain::query()
+            ->where('store_id', $this->storeId)
+            ->whereKey($domainId)
+            ->firstOrFail();
+    }
+}
*** End Patch


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

type: add

content:
<?php

namespace App\Livewire\Admin\Settings;

use App\Enums\StoreDomainType;
use App\Models\Store;
use App\Models\StoreDomain;
use App\Models\StoreSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $storeName = '';

    public string $storeHandle = '';

    public string $defaultCurrency = 'EUR';

    public string $defaultLocale = 'en';

    public string $timezone = 'UTC';

    public bool $announcementEnabled = false;

    public string $announcementText = '';

    public bool $guestCheckoutEnabled = true;

    public string $newHostname = '';

    public string $newType = 'storefront';

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

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

        $settings = $this->storeSettings($store)->settings_json ?? [];

        $this->storeId = $store->getKey();
        $this->storeName = $store->name;
        $this->storeHandle = $store->handle;
        $this->defaultCurrency = $store->default_currency;
        $this->defaultLocale = $store->default_locale;
        $this->timezone = $store->timezone;
        $this->announcementEnabled = (bool) data_get($settings, 'announcement.enabled', false);
        $this->announcementText = (string) data_get($settings, 'announcement.text', '');
        $this->guestCheckoutEnabled = (bool) data_get($settings, 'checkout.guest_checkout_enabled', true);
    }

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

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

        $validated = $this->validate([
            'storeName' => ['required', 'string', 'max:255'],
            'defaultCurrency' => ['required', Rule::in($this->currencyOptions())],
            'defaultLocale' => ['required', Rule::in(array_keys($this->localeOptions()))],
            'timezone' => ['required', Rule::in(timezone_identifiers_list())],
            'announcementEnabled' => ['boolean'],
            'announcementText' => ['nullable', 'string', 'max:255'],
            'guestCheckoutEnabled' => ['boolean'],
        ]);

        $store->forceFill([
            'name' => $validated['storeName'],
            'default_currency' => $validated['defaultCurrency'],
            'default_locale' => $validated['defaultLocale'],
            'timezone' => $validated['timezone'],
        ])->save();

        $settings = $this->storeSettings($store);
        $settings->forceFill([
            'settings_json' => array_replace_recursive($settings->settings_json ?? [], [
                'announcement' => [
                    'enabled' => $this->announcementEnabled,
                    'text' => $this->announcementText,
                ],
                'checkout' => [
                    'guest_checkout_enabled' => $this->guestCheckoutEnabled,
                ],
            ]),
            'updated_at' => now(),
        ])->save();

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

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

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

        $validated = $this->validate([
            'newHostname' => [
                'required',
                'string',
                'max:255',
                'regex:/^[a-z0-9.-]+$/i',
                Rule::unique('store_domains', 'hostname'),
            ],
            'newType' => ['required', Rule::in(array_column(StoreDomainType::cases(), 'value'))],
        ], [], [
            'newHostname' => 'hostname',
            'newType' => 'domain type',
        ]);

        StoreDomain::query()->create([
            'store_id' => $store->getKey(),
            'hostname' => mb_strtolower(trim($validated['newHostname'])),
            'type' => StoreDomainType::from($validated['newType']),
            'is_primary' => $this->domains()->isEmpty(),
            'tls_mode' => 'local',
        ]);

        $this->reset('newHostname');

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

    public function setPrimary(int $domainId): void
    {
        $store = $this->scopedStore();

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

        $domain = $this->domain($domainId);

        StoreDomain::query()
            ->where('store_id', $store->getKey())
            ->update(['is_primary' => false]);

        $domain->forceFill(['is_primary' => true])->save();

        session()->flash('status', 'Primary domain updated');
    }

    public function removeDomain(int $domainId): void
    {
        $store = $this->scopedStore();

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

        $domain = $this->domain($domainId);
        $wasPrimary = $domain->is_primary;

        $domain->delete();

        if ($wasPrimary) {
            StoreDomain::query()
                ->where('store_id', $store->getKey())
                ->orderBy('id')
                ->first()
                ?->forceFill(['is_primary' => true])
                ->save();
        }

        session()->flash('status', 'Domain removed');
    }

    /**
     * @return Collection<int, StoreDomain>
     */
    public function domains(): Collection
    {
        return StoreDomain::query()
            ->where('store_id', $this->storeId)
            ->orderByDesc('is_primary')
            ->orderBy('hostname')
            ->get();
    }

    /**
     * @return array<string, string>
     */
    public function localeOptions(): array
    {
        return [
            'en' => 'English',
            'de' => 'German',
            'fr' => 'French',
        ];
    }

    /**
     * @return list<string>
     */
    public function currencyOptions(): array
    {
        return ['EUR', 'USD', 'GBP', 'CHF'];
    }

    /**
     * @return list<string>
     */
    public function timezoneOptions(): array
    {
        return collect(timezone_identifiers_list())
            ->filter(fn (string $timezone): bool => str_starts_with($timezone, 'Europe/') || str_starts_with($timezone, 'America/'))
            ->values()
            ->all();
    }

    public function render(): mixed
    {
        return view('livewire.admin.settings.index', [
            'domains' => $this->domains(),
            'currencyOptions' => $this->currencyOptions(),
            'localeOptions' => $this->localeOptions(),
            'timezoneOptions' => $this->timezoneOptions(),
        ])->layout('layouts.app', [
            'title' => __('Settings'),
        ]);
    }

    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 storeSettings(Store $store): StoreSettings
    {
        return StoreSettings::query()->firstOrCreate(
            ['store_id' => $store->getKey()],
            ['settings_json' => []],
        );
    }

    private function domain(int $domainId): StoreDomain
    {
        return StoreDomain::query()
            ->where('store_id', $this->storeId)
            ->whereKey($domainId)
            ->firstOrFail();
    }
}

status: completed


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


status: completed

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


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

type: delete

content:
<?php

namespace App\Livewire\Admin\Settings;

use Livewire\Component;

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


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


status: completed

input:
*** Begin Patch
*** Add File: /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;
+    }
+}
*** End Patch


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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


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

content:
<?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}}


status: completed

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