Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Products/Form.php
@@
 namespace App\Livewire\Admin\Products;

+use App\Models\Collection;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductOption;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\ProductService;
+use App\Support\Money;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
 use Livewire\Component;

 class Form extends Component
 {
-    public function render()
+    public ?Product $product = null;
+
+    public string $title = '';
+
+    public string $descriptionHtml = '';
+
+    public string $status = 'active';
+
+    public string $vendor = '';
+
+    public string $productType = '';
+
+    public string $tags = '';
+
+    public string $handle = '';
+
+    public ?string $publishedAt = null;
+
+    /**
+     * @var array<int, int>
+     */
+    public array $collectionIds = [];
+
+    /**
+     * @var array<int, array{name: string, values: string}>
+     */
+    public array $options = [];
+
+    /**
+     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
+     */
+    public array $variants = [];
+
+    public function mount(?Product $product = null): void
+    {
+        if ($product?->exists) {
+            $store = app('current_store');
+
+            abort_unless($store instanceof Store && (int) $product->store_id === $store->getKey(), 404);
+
+            $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']);
+            $this->fillFromProduct($this->product);
+
+            return;
+        }
+
+        $this->variants = [[
+            'id' => null,
+            'label' => 'Default',
+            'sku' => '',
+            'price' => '0.00',
+            'compareAtPrice' => '',
+            'quantity' => 0,
+            'requiresShipping' => true,
+        ]];
+    }
+
+    public function updatedTitle(): void
+    {
+        if ($this->product === null && $this->handle === '') {
+            $this->handle = Str::slug($this->title);
+        }
+    }
+
+    public function addOption(): void
+    {
+        $this->options[] = [
+            'name' => '',
+            'values' => '',
+        ];
+    }
+
+    public function removeOption(int $index): void
+    {
+        unset($this->options[$index]);
+        $this->options = array_values($this->options);
+    }
+
+    public function save(): void
+    {
+        $store = app('current_store');
+        abort_unless($store instanceof Store, 404);
+
+        $this->validate([
+            'title' => ['required', 'string', 'max:255'],
+            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
+            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
+            'vendor' => ['nullable', 'string', 'max:255'],
+            'productType' => ['nullable', 'string', 'max:255'],
+            'tags' => ['nullable', 'string'],
+            'handle' => [
+                'required',
+                'string',
+                'max:255',
+                Rule::unique('products', 'handle')
+                    ->where('store_id', $store->getKey())
+                    ->ignore($this->product?->getKey()),
+            ],
+            'variants' => ['required', 'array', 'min:1'],
+            'variants.*.sku' => ['nullable', 'string', 'max:255'],
+            'variants.*.price' => ['required', 'numeric', 'min:0'],
+            'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'],
+            'variants.*.quantity' => ['required', 'integer', 'min:0'],
+            'variants.*.requiresShipping' => ['boolean'],
+        ]);
+
+        $product = DB::transaction(function () use ($store): Product {
+            if ($this->product === null) {
+                $product = app(ProductService::class)->create($store, [
+                    ...$this->productPayload(),
+                    'options' => $this->optionPayload(),
+                    'variants' => $this->variantPayload($store),
+                ]);
+            } else {
+                $product = app(ProductService::class)->update($this->product, $this->productPayload());
+                $this->syncExistingVariants($product, $store);
+            }
+
+            $product->collections()->sync($this->collectionIds);
+
+            return $product->refresh();
+        });
+
+        $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']);
+        $this->fillFromProduct($this->product);
+
+        session()->flash('status', 'Product saved');
+        $this->dispatch('toast', type: 'success', message: __('Product saved'));
+    }
+
+    public function deleteProduct(): void
+    {
+        abort_unless($this->product instanceof Product, 404);
+
+        app(ProductService::class)->transitionStatus($this->product, \App\Enums\ProductStatus::Archived);
+
+        session()->flash('status', 'Product saved');
+        $this->redirectRoute('admin.products.index', navigate: true);
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.admin.products.form');
+        return view('livewire.admin.products.form', [
+            'availableCollections' => Collection::query()->orderBy('title')->get(),
+            'isEditing' => $this->product !== null,
+        ])->layout('layouts.app', [
+            'title' => $this->product ? $this->product->title : __('Add product'),
+        ]);
+    }
+
+    private function fillFromProduct(Product $product): void
+    {
+        $this->title = $product->title;
+        $this->descriptionHtml = (string) $product->description_html;
+        $this->status = $product->status->value;
+        $this->vendor = (string) $product->vendor;
+        $this->productType = (string) $product->product_type;
+        $this->tags = implode(', ', $product->tags ?? []);
+        $this->handle = $product->handle;
+        $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i');
+        $this->collectionIds = $product->collections->pluck('id')->map(fn (int $id): int => $id)->all();
+        $this->options = $product->options
+            ->map(fn (ProductOption $option): array => [
+                'name' => $option->name,
+                'values' => $option->values->pluck('value')->implode(', '),
+            ])
+            ->all();
+        $this->variants = $product->variants
+            ->map(fn (ProductVariant $variant): array => [
+                'id' => $variant->getKey(),
+                'label' => $variant->optionValues->isEmpty()
+                    ? 'Default'
+                    : $variant->optionValues->map(fn (ProductOptionValue $value): string => $value->value)->implode(' / '),
+                'sku' => (string) $variant->sku,
+                'price' => number_format($variant->price_amount / 100, 2, '.', ''),
+                'compareAtPrice' => $variant->compare_at_amount ? number_format($variant->compare_at_amount / 100, 2, '.', '') : '',
+                'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0,
+                'requiresShipping' => $variant->requires_shipping,
+            ])
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function productPayload(): array
+    {
+        return [
+            'title' => $this->title,
+            'description_html' => $this->descriptionHtml ?: null,
+            'status' => $this->status,
+            'vendor' => $this->vendor ?: null,
+            'product_type' => $this->productType ?: null,
+            'tags' => collect(explode(',', $this->tags))
+                ->map(fn (string $tag): string => trim($tag))
+                ->filter()
+                ->values()
+                ->all(),
+            'handle' => Str::slug($this->handle),
+        ];
+    }
+
+    /**
+     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
+     */
+    private function optionPayload(): array
+    {
+        return collect($this->options)
+            ->map(function (array $option, int $position): array {
+                return [
+                    'name' => $option['name'],
+                    'position' => $position,
+                    'values' => collect(explode(',', $option['values']))
+                        ->map(fn (string $value): string => trim($value))
+                        ->filter()
+                        ->values()
+                        ->map(fn (string $value, int $valuePosition): array => [
+                            'value' => $value,
+                            'position' => $valuePosition,
+                        ])
+                        ->all(),
+                ];
+            })
+            ->filter(fn (array $option): bool => $option['name'] !== '' && $option['values'] !== [])
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    private function variantPayload(Store $store): array
+    {
+        return collect($this->variants)
+            ->map(fn (array $variant, int $position): array => [
+                'sku' => $variant['sku'] ?: null,
+                'price_amount' => Money::fromDecimalString($variant['price']),
+                'compare_at_amount' => $variant['compareAtPrice'] === '' ? null : Money::fromDecimalString($variant['compareAtPrice']),
+                'currency' => $store->default_currency,
+                'quantity_on_hand' => (int) $variant['quantity'],
+                'requires_shipping' => (bool) $variant['requiresShipping'],
+                'is_default' => $position === 0,
+                'position' => $position,
+            ])
+            ->all();
+    }
+
+    private function syncExistingVariants(Product $product, Store $store): void
+    {
+        foreach ($this->variantPayload($store) as $position => $variantData) {
+            $variantId = $this->variants[$position]['id'] ?? null;
+            $variant = $variantId
+                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
+                : $product->variants()->create(['position' => $position]);
+
+            $variant->forceFill([
+                'sku' => $variantData['sku'],
+                'price_amount' => $variantData['price_amount'],
+                'compare_at_amount' => $variantData['compare_at_amount'],
+                'currency' => $variantData['currency'],
+                'requires_shipping' => $variantData['requires_shipping'],
+                'is_default' => $position === 0,
+                'position' => $position,
+            ])->save();
+
+            InventoryItem::withoutGlobalScopes()->updateOrCreate(
+                ['variant_id' => $variant->getKey()],
+                [
+                    'store_id' => $store->getKey(),
+                    'quantity_on_hand' => $variantData['quantity_on_hand'],
+                    'quantity_reserved' => 0,
+                    'policy' => 'deny',
+                ],
+            );
+        }
     }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Products/Form.php

@@ -4,2 +4,14 @@

+use App\Models\Collection;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductOption;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\ProductService;
+use App\Support\Money;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
 use Livewire\Component;
@@ -8,5 +20,278 @@
 {
-    public function render()
+    public ?Product $product = null;
+
+    public string $title = '';
+
+    public string $descriptionHtml = '';
+
+    public string $status = 'active';
+
+    public string $vendor = '';
+
+    public string $productType = '';
+
+    public string $tags = '';
+
+    public string $handle = '';
+
+    public ?string $publishedAt = null;
+
+    /**
+     * @var array<int, int>
+     */
+    public array $collectionIds = [];
+
+    /**
+     * @var array<int, array{name: string, values: string}>
+     */
+    public array $options = [];
+
+    /**
+     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
+     */
+    public array $variants = [];
+
+    public function mount(?Product $product = null): void
+    {
+        if ($product?->exists) {
+            $store = app('current_store');
+
+            abort_unless($store instanceof Store && (int) $product->store_id === $store->getKey(), 404);
+
+            $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']);
+            $this->fillFromProduct($this->product);
+
+            return;
+        }
+
+        $this->variants = [[
+            'id' => null,
+            'label' => 'Default',
+            'sku' => '',
+            'price' => '0.00',
+            'compareAtPrice' => '',
+            'quantity' => 0,
+            'requiresShipping' => true,
+        ]];
+    }
+
+    public function updatedTitle(): void
     {
-        return view('livewire.admin.products.form');
+        if ($this->product === null && $this->handle === '') {
+            $this->handle = Str::slug($this->title);
+        }
+    }
+
+    public function addOption(): void
+    {
+        $this->options[] = [
+            'name' => '',
+            'values' => '',
+        ];
+    }
+
+    public function removeOption(int $index): void
+    {
+        unset($this->options[$index]);
+        $this->options = array_values($this->options);
+    }
+
+    public function save(): void
+    {
+        $store = app('current_store');
+        abort_unless($store instanceof Store, 404);
+
+        $this->validate([
+            'title' => ['required', 'string', 'max:255'],
+            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
+            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
+            'vendor' => ['nullable', 'string', 'max:255'],
+            'productType' => ['nullable', 'string', 'max:255'],
+            'tags' => ['nullable', 'string'],
+            'handle' => [
+                'required',
+                'string',
+                'max:255',
+                Rule::unique('products', 'handle')
+                    ->where('store_id', $store->getKey())
+                    ->ignore($this->product?->getKey()),
+            ],
+            'variants' => ['required', 'array', 'min:1'],
+            'variants.*.sku' => ['nullable', 'string', 'max:255'],
+            'variants.*.price' => ['required', 'numeric', 'min:0'],
+            'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'],
+            'variants.*.quantity' => ['required', 'integer', 'min:0'],
+            'variants.*.requiresShipping' => ['boolean'],
+        ]);
+
+        $product = DB::transaction(function () use ($store): Product {
+            if ($this->product === null) {
+                $product = app(ProductService::class)->create($store, [
+                    ...$this->productPayload(),
+                    'options' => $this->optionPayload(),
+                    'variants' => $this->variantPayload($store),
+                ]);
+            } else {
+                $product = app(ProductService::class)->update($this->product, $this->productPayload());
+                $this->syncExistingVariants($product, $store);
+            }
+
+            $product->collections()->sync($this->collectionIds);
+
+            return $product->refresh();
+        });
+
+        $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']);
+        $this->fillFromProduct($this->product);
+
+        session()->flash('status', 'Product saved');
+        $this->dispatch('toast', type: 'success', message: __('Product saved'));
+    }
+
+    public function deleteProduct(): void
+    {
+        abort_unless($this->product instanceof Product, 404);
+
+        app(ProductService::class)->transitionStatus($this->product, \App\Enums\ProductStatus::Archived);
+
+        session()->flash('status', 'Product saved');
+        $this->redirectRoute('admin.products.index', navigate: true);
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.products.form', [
+            'availableCollections' => Collection::query()->orderBy('title')->get(),
+            'isEditing' => $this->product !== null,
+        ])->layout('layouts.app', [
+            'title' => $this->product ? $this->product->title : __('Add product'),
+        ]);
+    }
+
+    private function fillFromProduct(Product $product): void
+    {
+        $this->title = $product->title;
+        $this->descriptionHtml = (string) $product->description_html;
+        $this->status = $product->status->value;
+        $this->vendor = (string) $product->vendor;
+        $this->productType = (string) $product->product_type;
+        $this->tags = implode(', ', $product->tags ?? []);
+        $this->handle = $product->handle;
+        $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i');
+        $this->collectionIds = $product->collections->pluck('id')->map(fn (int $id): int => $id)->all();
+        $this->options = $product->options
+            ->map(fn (ProductOption $option): array => [
+                'name' => $option->name,
+                'values' => $option->values->pluck('value')->implode(', '),
+            ])
+            ->all();
+        $this->variants = $product->variants
+            ->map(fn (ProductVariant $variant): array => [
+                'id' => $variant->getKey(),
+                'label' => $variant->optionValues->isEmpty()
+                    ? 'Default'
+                    : $variant->optionValues->map(fn (ProductOptionValue $value): string => $value->value)->implode(' / '),
+                'sku' => (string) $variant->sku,
+                'price' => number_format($variant->price_amount / 100, 2, '.', ''),
+                'compareAtPrice' => $variant->compare_at_amount ? number_format($variant->compare_at_amount / 100, 2, '.', '') : '',
+                'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0,
+                'requiresShipping' => $variant->requires_shipping,
+            ])
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function productPayload(): array
+    {
+        return [
+            'title' => $this->title,
+            'description_html' => $this->descriptionHtml ?: null,
+            'status' => $this->status,
+            'vendor' => $this->vendor ?: null,
+            'product_type' => $this->productType ?: null,
+            'tags' => collect(explode(',', $this->tags))
+                ->map(fn (string $tag): string => trim($tag))
+                ->filter()
+                ->values()
+                ->all(),
+            'handle' => Str::slug($this->handle),
+        ];
+    }
+
+    /**
+     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
+     */
+    private function optionPayload(): array
+    {
+        return collect($this->options)
+            ->map(function (array $option, int $position): array {
+                return [
+                    'name' => $option['name'],
+                    'position' => $position,
+                    'values' => collect(explode(',', $option['values']))
+                        ->map(fn (string $value): string => trim($value))
+                        ->filter()
+                        ->values()
+                        ->map(fn (string $value, int $valuePosition): array => [
+                            'value' => $value,
+                            'position' => $valuePosition,
+                        ])
+                        ->all(),
+                ];
+            })
+            ->filter(fn (array $option): bool => $option['name'] !== '' && $option['values'] !== [])
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    private function variantPayload(Store $store): array
+    {
+        return collect($this->variants)
+            ->map(fn (array $variant, int $position): array => [
+                'sku' => $variant['sku'] ?: null,
+                'price_amount' => Money::fromDecimalString($variant['price']),
+                'compare_at_amount' => $variant['compareAtPrice'] === '' ? null : Money::fromDecimalString($variant['compareAtPrice']),
+                'currency' => $store->default_currency,
+                'quantity_on_hand' => (int) $variant['quantity'],
+                'requires_shipping' => (bool) $variant['requiresShipping'],
+                'is_default' => $position === 0,
+                'position' => $position,
+            ])
+            ->all();
+    }
+
+    private function syncExistingVariants(Product $product, Store $store): void
+    {
+        foreach ($this->variantPayload($store) as $position => $variantData) {
+            $variantId = $this->variants[$position]['id'] ?? null;
+            $variant = $variantId
+                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
+                : $product->variants()->create(['position' => $position]);
+
+            $variant->forceFill([
+                'sku' => $variantData['sku'],
+                'price_amount' => $variantData['price_amount'],
+                'compare_at_amount' => $variantData['compare_at_amount'],
+                'currency' => $variantData['currency'],
+                'requires_shipping' => $variantData['requires_shipping'],
+                'is_default' => $position === 0,
+                'position' => $position,
+            ])->save();
+
+            InventoryItem::withoutGlobalScopes()->updateOrCreate(
+                ['variant_id' => $variant->getKey()],
+                [
+                    'store_id' => $store->getKey(),
+                    'quantity_on_hand' => $variantData['quantity_on_hand'],
+                    'quantity_reserved' => 0,
+                    'policy' => 'deny',
+                ],
+            );
+        }
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Products/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: resources/views/livewire/admin/products/form.blade.php
*** End Patch


Success. Updated the following files:
D resources/views/livewire/admin/products/form.blade.php

{{-- It is not the man who has too little, but the man who craves more, that is poor. - Seneca --}}


Output: {"output":"Success. Updated the following files:\nD resources/views/livewire/admin/products/form.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: resources/views/livewire/admin/products/form.blade.php
+

+

+

+            <flux:breadcrumbs>
+                <flux:breadcrumbs.item :href="route('admin.dashboard')" wire:navigate>Home</flux:breadcrumbs.item>
+                <flux:breadcrumbs.item :href="route('admin.products.index')" wire:navigate>Products</flux:breadcrumbs.item>
+                <flux:breadcrumbs.item>{{ $isEditing ? $title : 'Add product' }}</flux:breadcrumbs.item>
+            </flux:breadcrumbs>
+
+            <flux:heading size="xl" class="mt-3">{{ $isEditing ? $title : 'Add product' }}</flux:heading>
+

+
+        @if ($isEditing)
+            <flux:button wire:click="deleteProduct" variant="danger" icon="archive-box">Archive</flux:button>
+        @endif
+

+
+    @if (session('status'))
+        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
+    @endif
+
+    <form wire:submit="save" class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">
+

+

+

+                    <flux:input wire:model.live.debounce.300ms="title" label="Title" placeholder="Short Sleeve T-Shirt" />
+                    <flux:error name="title" />
+
+                    <flux:textarea wire:model="descriptionHtml" label="Description" rows="8" placeholder="Describe your product..." />
+                    <flux:error name="descriptionHtml" />
+

+

+
+

+

+                    <flux:heading size="lg">Variants</flux:heading>
+                    <flux:text>{{ count($variants) }} {{ Str::plural('variant', count($variants)) }}</flux:text>
+

+
+

+

+

+

+                                Variant
+                                SKU
+                                Price
+                                Compare
+                                Qty
+                                Ship
+

+

+

+                            @foreach ($variants as $index => $variant)
+

+                                    {{ $variant['label'] }}
+
+                                        <flux:input wire:model="variants.{{ $index }}.sku" aria-label="Variant SKU" />
+
+
+                                        <flux:input wire:model="variants.{{ $index }}.price" type="number" step="0.01" min="0" aria-label="Variant price" />
+
+
+                                        <flux:input wire:model="variants.{{ $index }}.compareAtPrice" type="number" step="0.01" min="0" aria-label="Compare at price" />
+
+
+                                        <flux:input wire:model="variants.{{ $index }}.quantity" type="number" min="0" aria-label="Variant quantity" />
+
+
+                                        <flux:checkbox wire:model="variants.{{ $index }}.requiresShipping" aria-label="Requires shipping" />
+
+

+                            @endforeach
+

+

+

+

+
+

+

+                    <flux:heading size="lg">Options</flux:heading>
+                    <flux:button type="button" wire:click="addOption" variant="ghost" icon="plus">Add option</flux:button>
+

+
+

+                    @forelse ($options as $index => $option)
+

+                            <flux:input wire:model="options.{{ $index }}.name" label="Option name" placeholder="Size" />
+                            <flux:input wire:model="options.{{ $index }}.values" label="Values" placeholder="S, M, L, XL" />
+

+                                <flux:button type="button" wire:click="removeOption({{ $index }})" variant="ghost" icon="trash">Remove</flux:button>
+

+

+                    @empty
+                        <flux:text>No options for this product.</flux:text>
+                    @endforelse
+

+

+

+
+
+

+

+                    <flux:select wire:model="status" label="Status">
+                        <flux:select.option value="draft">Draft</flux:select.option>
+                        <flux:select.option value="active">Active</flux:select.option>
+                        <flux:select.option value="archived">Archived</flux:select.option>
+                    </flux:select>
+
+                    <flux:input wire:model="handle" label="URL handle" placeholder="short-sleeve-t-shirt" />
+                    <flux:error name="handle" />
+

+

+
+

+

+                    <flux:input wire:model="vendor" label="Vendor" placeholder="Acme Basics" />
+                    <flux:input wire:model="productType" label="Product type" placeholder="T-Shirts" />
+                    <flux:input wire:model="tags" label="Tags" placeholder="new, cotton, sale" />
+

+

+
+

+                <flux:heading size="lg">Collections</flux:heading>
+
+

+                    @forelse ($availableCollections as $collection)
+                        <flux:checkbox wire:model="collectionIds" value="{{ $collection->getKey() }}" :label="$collection->title" wire:key="product-collection-{{ $collection->getKey() }}" />
+                    @empty
+                        <flux:text>No collections yet.</flux:text>
+                    @endforelse
+

+

+
+
+

+

+                <flux:button :href="route('admin.products.index')" wire:navigate variant="ghost">Discard</flux:button>
+                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
+                    Save
+                    Saving...
+                </flux:button>
+

+

+    </form>
+

*** End Patch


Success. Updated the following files:
A resources/views/livewire/admin/products/form.blade.php

            <flux:breadcrumbs>
                <flux:breadcrumbs.item :href="route('admin.dashboard')" wire:navigate>Home</flux:breadcrumbs.item>
                <flux:breadcrumbs.item :href="route('admin.products.index')" wire:navigate>Products</flux:breadcrumbs.item>
                <flux:breadcrumbs.item>{{ $isEditing ? $title : 'Add product' }}</flux:breadcrumbs.item>
            </flux:breadcrumbs>

            <flux:heading size="xl" class="mt-3">{{ $isEditing ? $title : 'Add product' }}</flux:heading>

        @if ($isEditing)
            <flux:button wire:click="deleteProduct" variant="danger" icon="archive-box">Archive</flux:button>
        @endif

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

    <form wire:submit="save" class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">

                    <flux:input wire:model.live.debounce.300ms="title" label="Title" placeholder="Short Sleeve T-Shirt" />
                    <flux:error name="title" />

                    <flux:textarea wire:model="descriptionHtml" label="Description" rows="8" placeholder="Describe your product..." />
                    <flux:error name="descriptionHtml" />

                    <flux:heading size="lg">Variants</flux:heading>
                    <flux:text>{{ count($variants) }} {{ Str::plural('variant', count($variants)) }}</flux:text>

                                Variant
                                SKU
                                Price
                                Compare
                                Qty
                                Ship

                            @foreach ($variants as $index => $variant)

                                    {{ $variant['label'] }}

                                        <flux:input wire:model="variants.{{ $index }}.sku" aria-label="Variant SKU" />

                                        <flux:input wire:model="variants.{{ $index }}.price" type="number" step="0.01" min="0" aria-label="Variant price" />

                                        <flux:input wire:model="variants.{{ $index }}.compareAtPrice" type="number" step="0.01" min="0" aria-label="Compare at price" />

                                        <flux:input wire:model="variants.{{ $index }}.quantity" type="number" min="0" aria-label="Variant quantity" />

                                        <flux:checkbox wire:model="variants.{{ $index }}.requiresShipping" aria-label="Requires shipping" />

                            @endforeach

                    <flux:heading size="lg">Options</flux:heading>
                    <flux:button type="button" wire:click="addOption" variant="ghost" icon="plus">Add option</flux:button>

                    @forelse ($options as $index => $option)

                            <flux:input wire:model="options.{{ $index }}.name" label="Option name" placeholder="Size" />
                            <flux:input wire:model="options.{{ $index }}.values" label="Values" placeholder="S, M, L, XL" />

                                <flux:button type="button" wire:click="removeOption({{ $index }})" variant="ghost" icon="trash">Remove</flux:button>

                    @empty
                        <flux:text>No options for this product.</flux:text>
                    @endforelse

                    <flux:select wire:model="status" label="Status">
                        <flux:select.option value="draft">Draft</flux:select.option>
                        <flux:select.option value="active">Active</flux:select.option>
                        <flux:select.option value="archived">Archived</flux:select.option>
                    </flux:select>

                    <flux:input wire:model="handle" label="URL handle" placeholder="short-sleeve-t-shirt" />
                    <flux:error name="handle" />

                    <flux:input wire:model="vendor" label="Vendor" placeholder="Acme Basics" />
                    <flux:input wire:model="productType" label="Product type" placeholder="T-Shirts" />
                    <flux:input wire:model="tags" label="Tags" placeholder="new, cotton, sale" />

                <flux:heading size="lg">Collections</flux:heading>

                    @forelse ($availableCollections as $collection)
                        <flux:checkbox wire:model="collectionIds" value="{{ $collection->getKey() }}" :label="$collection->title" wire:key="product-collection-{{ $collection->getKey() }}" />
                    @empty
                        <flux:text>No collections yet.</flux:text>
                    @endforelse

                <flux:button :href="route('admin.products.index')" wire:navigate variant="ghost">Discard</flux:button>
                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
                    Save
                    Saving...
                </flux:button>

    </form>


Output: {"output":"Success. Updated the following files:\nA resources/views/livewire/admin/products/form.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Collections/Index.php
@@
 namespace App\Livewire\Admin\Collections;

+use App\Models\Collection;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
 use Livewire\Component;
+use Livewire\WithPagination;

 class Index extends Component
 {
-    public function render()
+    use WithPagination;
+
+    public string $search = '';
+
+    public string $statusFilter = 'all';
+
+    public function updatedSearch(): void
+    {
+        $this->resetPage();
+    }
+
+    public function updatedStatusFilter(): void
+    {
+        $this->resetPage();
+    }
+
+    public function deleteCollection(int $id): void
+    {
+        Collection::query()->findOrFail($id)->delete();
+
+        $this->dispatch('toast', type: 'success', message: __('Collection deleted'));
+    }
+
+    public function collections(): LengthAwarePaginator
+    {
+        return Collection::query()
+            ->withCount('products')
+            ->when($this->search !== '', function (Builder $query): void {
+                $query->where('title', 'like', '%'.$this->search.'%');
+            })
+            ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))
+            ->latest('updated_at')
+            ->paginate(15);
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.admin.collections.index');
+        return view('livewire.admin.collections.index', [
+            'collections' => $this->collections(),
+        ])->layout('layouts.app', [
+            'title' => __('Collections'),
+        ]);
     }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Collections/Index.php

@@ -4,3 +4,7 @@

+use App\Models\Collection;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
 use Livewire\Component;
+use Livewire\WithPagination;

@@ -8,5 +12,44 @@
 {
-    public function render()
+    use WithPagination;
+
+    public string $search = '';
+
+    public string $statusFilter = 'all';
+
+    public function updatedSearch(): void
+    {
+        $this->resetPage();
+    }
+
+    public function updatedStatusFilter(): void
+    {
+        $this->resetPage();
+    }
+
+    public function deleteCollection(int $id): void
     {
-        return view('livewire.admin.collections.index');
+        Collection::query()->findOrFail($id)->delete();
+
+        $this->dispatch('toast', type: 'success', message: __('Collection deleted'));
+    }
+
+    public function collections(): LengthAwarePaginator
+    {
+        return Collection::query()
+            ->withCount('products')
+            ->when($this->search !== '', function (Builder $query): void {
+                $query->where('title', 'like', '%'.$this->search.'%');
+            })
+            ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))
+            ->latest('updated_at')
+            ->paginate(15);
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.collections.index', [
+            'collections' => $this->collections(),
+        ])->layout('layouts.app', [
+            'title' => __('Collections'),
+        ]);
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Collections/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: resources/views/livewire/admin/collections/index.blade.php
*** End Patch


Success. Updated the following files:
D resources/views/livewire/admin/collections/index.blade.php

{{-- The best way to take care of the future is to take care of the present moment. - Thich Nhat Hanh --}}


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


Input:
*** Begin Patch
*** Add File: resources/views/livewire/admin/collections/index.blade.php
+

+

+

+            <flux:heading size="xl">Collections</flux:heading>
+            <flux:text class="mt-1">Group products for storefront browsing and merchandising.</flux:text>
+

+
+        <flux:button :href="route('admin.collections.create')" wire:navigate variant="primary" icon="plus">
+            Add collection
+        </flux:button>
+

+
+

+        <flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="Search collections..." aria-label="Search collections" />
+
+        <flux:select wire:model.live="statusFilter" aria-label="Status filter">
+            <flux:select.option value="all">All statuses</flux:select.option>
+            <flux:select.option value="active">Active</flux:select.option>
+            <flux:select.option value="draft">Draft</flux:select.option>
+            <flux:select.option value="archived">Archived</flux:select.option>
+        </flux:select>
+

+
+

+

+

+

+

+                        Title
+                        Products
+                        Status
+                        Updated
+
+

+

+

+                    @forelse ($collections as $collection)
+                        @php
+                            $statusColor = match ($collection->status->value) {
+                                'active' => 'green',
+                                'archived' => 'red',
+                                default => 'zinc',
+                            };
+                        @endphp
+
+
getKey() }}">
+
+
+                                    {{ $collection->title }}
+
+
/{{ $collection->handle }}

+
+                            {{ $collection->products_count }}
+                            <flux:badge :color="$statusColor">{{ Str::title($collection->status->value) }}</flux:badge>
+                            {{ $collection->updated_at?->diffForHumans() }}
+
+                                <flux:button wire:click="deleteCollection({{ $collection->getKey() }})" variant="ghost" icon="trash">Delete</flux:button>
+
+

+                    @empty
+

+
+

+

+                                        <flux:icon name="rectangle-stack" class="size-6" />
+

+                                    <flux:heading size="lg">No collections found</flux:heading>
+                                    <flux:text>Create a collection to organize your products.</flux:text>
+                                    <flux:button :href="route('admin.collections.create')" wire:navigate variant="primary">Add collection</flux:button>
+

+
+

+                    @endforelse
+

+

+

+

+
+    {{ $collections->links() }}
+

*** End Patch


Success. Updated the following files:
A resources/views/livewire/admin/collections/index.blade.php

            <flux:heading size="xl">Collections</flux:heading>
            <flux:text class="mt-1">Group products for storefront browsing and merchandising.</flux:text>

        <flux:button :href="route('admin.collections.create')" wire:navigate variant="primary" icon="plus">
            Add collection
        </flux:button>

        <flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="Search collections..." aria-label="Search collections" />

        <flux:select wire:model.live="statusFilter" aria-label="Status filter">
            <flux:select.option value="all">All statuses</flux:select.option>
            <flux:select.option value="active">Active</flux:select.option>
            <flux:select.option value="draft">Draft</flux:select.option>
            <flux:select.option value="archived">Archived</flux:select.option>
        </flux:select>

                        Title
                        Products
                        Status
                        Updated

                    @forelse ($collections as $collection)
                        @php
                            $statusColor = match ($collection->status->value) {
                                'active' => 'green',
                                'archived' => 'red',
                                default => 'zinc',
                            };
                        @endphp

getKey() }}">

                                    {{ $collection->title }}

/{{ $collection->handle }}

                            {{ $collection->products_count }}
                            <flux:badge :color="$statusColor">{{ Str::title($collection->status->value) }}</flux:badge>
                            {{ $collection->updated_at?->diffForHumans() }}

                                <flux:button wire:click="deleteCollection({{ $collection->getKey() }})" variant="ghost" icon="trash">Delete</flux:button>

                    @empty

                                        <flux:icon name="rectangle-stack" class="size-6" />

                                    <flux:heading size="lg">No collections found</flux:heading>
                                    <flux:text>Create a collection to organize your products.</flux:text>
                                    <flux:button :href="route('admin.collections.create')" wire:navigate variant="primary">Add collection</flux:button>

                    @endforelse

    {{ $collections->links() }}


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


sed -n '1,220p' app/Enums/CollectionStatus.php && sed -n '1,220p' app/Models/Collection.php


sed -n '1,220p' app/Enums/CollectionStatus.php && sed -n '1,220p' app/Models/Collection.php

Collection.php

<?php

namespace App\Enums;

enum CollectionStatus: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Archived = 'archived';
}
<?php

namespace App\Models;

use App\Enums\CollectionStatus;
use App\Enums\CollectionType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Collection extends Model
{
    /** @use HasFactory<\Database\Factories\CollectionFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'description_html',
        'type',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'manual',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsToMany<Product, $this>
     */
    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => CollectionType::class,
            'status' => CollectionStatus::class,
        ];
    }
}


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

namespace App\Enums;

enum CollectionStatus: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Archived = 'archived';
}
<?php

namespace App\Models;

use App\Enums\CollectionStatus;
use App\Enums\CollectionType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Collection extends Model
{
    /** @use HasFactory<\Database\Factories\CollectionFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'description_html',
        'type',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'manual',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsToMany<Product, $this>
     */
    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => CollectionType::class,
            'status' => CollectionStatus::class,
        ];
    }
}


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Collections/Form.php
@@
 namespace App\Livewire\Admin\Collections;

+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection as SupportCollection;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
 use Livewire\Component;

 class Form extends Component
 {
-    public function render()
+    public ?Collection $collection = null;
+
+    public string $title = '';
+
+    public string $handle = '';
+
+    public string $descriptionHtml = '';
+
+    public string $status = 'active';
+
+    public string $productSearch = '';
+
+    /**
+     * @var array<int, int>
+     */
+    public array $assignedProductIds = [];
+
+    public function mount(?Collection $collection = null): void
+    {
+        if ($collection?->exists) {
+            $store = app('current_store');
+
+            abort_unless($store instanceof Store && (int) $collection->store_id === $store->getKey(), 404);
+
+            $this->collection = $collection->load('products');
+            $this->fillFromCollection($this->collection);
+        }
+    }
+
+    public function updatedTitle(): void
+    {
+        if ($this->collection === null && $this->handle === '') {
+            $this->handle = Str::slug($this->title);
+        }
+    }
+
+    public function addProduct(int $productId): void
+    {
+        if (! in_array($productId, $this->assignedProductIds, true)) {
+            $this->assignedProductIds[] = $productId;
+        }
+
+        $this->productSearch = '';
+    }
+
+    public function removeProduct(int $productId): void
+    {
+        $this->assignedProductIds = array_values(array_filter(
+            $this->assignedProductIds,
+            fn (int $assignedProductId): bool => $assignedProductId !== $productId,
+        ));
+    }
+
+    public function save(): void
+    {
+        $store = app('current_store');
+        abort_unless($store instanceof Store, 404);
+
+        $this->validate([
+            'title' => ['required', 'string', 'max:255'],
+            'handle' => [
+                'required',
+                'string',
+                'max:255',
+                Rule::unique('collections', 'handle')
+                    ->where('store_id', $store->getKey())
+                    ->ignore($this->collection?->getKey()),
+            ],
+            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
+            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
+        ]);
+
+        $collection = Collection::query()->updateOrCreate(
+            ['id' => $this->collection?->getKey()],
+            [
+                'store_id' => $store->getKey(),
+                'title' => $this->title,
+                'handle' => Str::slug($this->handle),
+                'description_html' => $this->descriptionHtml ?: null,
+                'type' => 'manual',
+                'status' => $this->status,
+            ],
+        );
+
+        $collection->products()->sync(collect($this->assignedProductIds)
+            ->values()
+            ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $position]])
+            ->all());
+
+        $this->collection = $collection->refresh()->load('products');
+        $this->fillFromCollection($this->collection);
+
+        session()->flash('status', 'Collection saved');
+        $this->dispatch('toast', type: 'success', message: __('Collection saved'));
+    }
+
+    public function searchResults(): SupportCollection
+    {
+        if (trim($this->productSearch) === '') {
+            return collect();
+        }
+
+        return Product::query()
+            ->whereNotIn('id', $this->assignedProductIds)
+            ->where(function (Builder $query): void {
+                $query
+                    ->where('title', 'like', '%'.$this->productSearch.'%')
+                    ->orWhere('handle', 'like', '%'.$this->productSearch.'%');
+            })
+            ->limit(5)
+            ->get();
+    }
+
+    public function assignedProducts(): SupportCollection
+    {
+        if ($this->assignedProductIds === []) {
+            return collect();
+        }
+
+        return Product::query()
+            ->whereIn('id', $this->assignedProductIds)
+            ->get()
+            ->sortBy(fn (Product $product): int => array_search($product->getKey(), $this->assignedProductIds, true))
+            ->values();
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.admin.collections.form');
+        return view('livewire.admin.collections.form', [
+            'searchResults' => $this->searchResults(),
+            'assignedProducts' => $this->assignedProducts(),
+            'isEditing' => $this->collection !== null,
+        ])->layout('layouts.app', [
+            'title' => $this->collection ? $this->collection->title : __('Add collection'),
+        ]);
+    }
+
+    private function fillFromCollection(Collection $collection): void
+    {
+        $this->title = $collection->title;
+        $this->handle = $collection->handle;
+        $this->descriptionHtml = (string) $collection->description_html;
+        $this->status = $collection->status->value;
+        $this->assignedProductIds = $collection->products->pluck('id')->map(fn (int $id): int => $id)->all();
     }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Collections/Form.php

@@ -4,2 +4,9 @@

+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection as SupportCollection;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
 use Livewire\Component;
@@ -8,5 +15,146 @@
 {
-    public function render()
+    public ?Collection $collection = null;
+
+    public string $title = '';
+
+    public string $handle = '';
+
+    public string $descriptionHtml = '';
+
+    public string $status = 'active';
+
+    public string $productSearch = '';
+
+    /**
+     * @var array<int, int>
+     */
+    public array $assignedProductIds = [];
+
+    public function mount(?Collection $collection = null): void
+    {
+        if ($collection?->exists) {
+            $store = app('current_store');
+
+            abort_unless($store instanceof Store && (int) $collection->store_id === $store->getKey(), 404);
+
+            $this->collection = $collection->load('products');
+            $this->fillFromCollection($this->collection);
+        }
+    }
+
+    public function updatedTitle(): void
+    {
+        if ($this->collection === null && $this->handle === '') {
+            $this->handle = Str::slug($this->title);
+        }
+    }
+
+    public function addProduct(int $productId): void
+    {
+        if (! in_array($productId, $this->assignedProductIds, true)) {
+            $this->assignedProductIds[] = $productId;
+        }
+
+        $this->productSearch = '';
+    }
+
+    public function removeProduct(int $productId): void
+    {
+        $this->assignedProductIds = array_values(array_filter(
+            $this->assignedProductIds,
+            fn (int $assignedProductId): bool => $assignedProductId !== $productId,
+        ));
+    }
+
+    public function save(): void
+    {
+        $store = app('current_store');
+        abort_unless($store instanceof Store, 404);
+
+        $this->validate([
+            'title' => ['required', 'string', 'max:255'],
+            'handle' => [
+                'required',
+                'string',
+                'max:255',
+                Rule::unique('collections', 'handle')
+                    ->where('store_id', $store->getKey())
+                    ->ignore($this->collection?->getKey()),
+            ],
+            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
+            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
+        ]);
+
+        $collection = Collection::query()->updateOrCreate(
+            ['id' => $this->collection?->getKey()],
+            [
+                'store_id' => $store->getKey(),
+                'title' => $this->title,
+                'handle' => Str::slug($this->handle),
+                'description_html' => $this->descriptionHtml ?: null,
+                'type' => 'manual',
+                'status' => $this->status,
+            ],
+        );
+
+        $collection->products()->sync(collect($this->assignedProductIds)
+            ->values()
+            ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $position]])
+            ->all());
+
+        $this->collection = $collection->refresh()->load('products');
+        $this->fillFromCollection($this->collection);
+
+        session()->flash('status', 'Collection saved');
+        $this->dispatch('toast', type: 'success', message: __('Collection saved'));
+    }
+
+    public function searchResults(): SupportCollection
+    {
+        if (trim($this->productSearch) === '') {
+            return collect();
+        }
+
+        return Product::query()
+            ->whereNotIn('id', $this->assignedProductIds)
+            ->where(function (Builder $query): void {
+                $query
+                    ->where('title', 'like', '%'.$this->productSearch.'%')
+                    ->orWhere('handle', 'like', '%'.$this->productSearch.'%');
+            })
+            ->limit(5)
+            ->get();
+    }
+
+    public function assignedProducts(): SupportCollection
+    {
+        if ($this->assignedProductIds === []) {
+            return collect();
+        }
+
+        return Product::query()
+            ->whereIn('id', $this->assignedProductIds)
+            ->get()
+            ->sortBy(fn (Product $product): int => array_search($product->getKey(), $this->assignedProductIds, true))
+            ->values();
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.admin.collections.form');
+        return view('livewire.admin.collections.form', [
+            'searchResults' => $this->searchResults(),
+            'assignedProducts' => $this->assignedProducts(),
+            'isEditing' => $this->collection !== null,
+        ])->layout('layouts.app', [
+            'title' => $this->collection ? $this->collection->title : __('Add collection'),
+        ]);
+    }
+
+    private function fillFromCollection(Collection $collection): void
+    {
+        $this->title = $collection->title;
+        $this->handle = $collection->handle;
+        $this->descriptionHtml = (string) $collection->description_html;
+        $this->status = $collection->status->value;
+        $this->assignedProductIds = $collection->products->pluck('id')->map(fn (int $id): int => $id)->all();
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Collections/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: resources/views/livewire/admin/collections/form.blade.php
*** End Patch


Success. Updated the following files:
D resources/views/livewire/admin/collections/form.blade.php

{{-- You must be the change you wish to see in the world. - Mahatma Gandhi --}}


Output: {"output":"Success. Updated the following files:\nD resources/views/livewire/admin/collections/form.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: resources/views/livewire/admin/collections/form.blade.php
+

+

+        <flux:breadcrumbs>
+            <flux:breadcrumbs.item :href="route('admin.dashboard')" wire:navigate>Home</flux:breadcrumbs.item>
+            <flux:breadcrumbs.item :href="route('admin.collections.index')" wire:navigate>Collections</flux:breadcrumbs.item>
+            <flux:breadcrumbs.item>{{ $isEditing ? $title : 'Add collection' }}</flux:breadcrumbs.item>
+        </flux:breadcrumbs>
+
+        <flux:heading size="xl" class="mt-3">{{ $isEditing ? $title : 'Add collection' }}</flux:heading>
+

+
+    @if (session('status'))
+        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
+    @endif
+
+    <form wire:submit="save" class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">
+

+

+

+                    <flux:input wire:model.live.debounce.300ms="title" label="Title" placeholder="Summer Collection" />
+                    <flux:error name="title" />
+
+                    <flux:input wire:model="handle" label="Handle" placeholder="summer-collection" />
+                    <flux:error name="handle" />
+
+                    <flux:textarea wire:model="descriptionHtml" label="Description" rows="6" placeholder="Describe this collection..." />
+

+

+
+

+                <flux:heading size="lg">Products</flux:heading>
+
+

+                    <flux:input wire:model.live.debounce.300ms="productSearch" icon="magnifying-glass" label="Search products" placeholder="Search products..." />
+
+                    @if ($searchResults->isNotEmpty())
+

+                            @foreach ($searchResults as $product)
+
getKey() }}">
+

+
{{ $product->title }}

+
/{{ $product->handle }}

+

+                                    <flux:button type="button" wire:click="addProduct({{ $product->getKey() }})" variant="ghost" icon="plus">Add</flux:button>
+

+                            @endforeach
+

+                    @endif
+
+

+                        <flux:text>Assigned products</flux:text>
+
+                        @forelse ($assignedProducts as $product)
+
getKey() }}">
+

+

+                                        <flux:icon name="cube" class="size-4" />
+

+

+
{{ $product->title }}

+
/{{ $product->handle }}

+

+

+
+                                <flux:button type="button" wire:click="removeProduct({{ $product->getKey() }})" variant="ghost" icon="x-mark">Remove</flux:button>
+

+                        @empty
+                            <flux:text>No products assigned.</flux:text>
+                        @endforelse
+

+

+

+

+
+
+

+                <flux:select wire:model="status" label="Status">
+                    <flux:select.option value="draft">Draft</flux:select.option>
+                    <flux:select.option value="active">Active</flux:select.option>
+                    <flux:select.option value="archived">Archived</flux:select.option>
+                </flux:select>
+

+
+
+

+

+                <flux:button :href="route('admin.collections.index')" wire:navigate variant="ghost">Discard</flux:button>
+                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
+                    Save
+                    Saving...
+                </flux:button>
+

+

+    </form>
+

*** End Patch


Success. Updated the following files:
A resources/views/livewire/admin/collections/form.blade.php

        <flux:breadcrumbs>
            <flux:breadcrumbs.item :href="route('admin.dashboard')" wire:navigate>Home</flux:breadcrumbs.item>
            <flux:breadcrumbs.item :href="route('admin.collections.index')" wire:navigate>Collections</flux:breadcrumbs.item>
            <flux:breadcrumbs.item>{{ $isEditing ? $title : 'Add collection' }}</flux:breadcrumbs.item>
        </flux:breadcrumbs>

        <flux:heading size="xl" class="mt-3">{{ $isEditing ? $title : 'Add collection' }}</flux:heading>

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

    <form wire:submit="save" class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">

                    <flux:input wire:model.live.debounce.300ms="title" label="Title" placeholder="Summer Collection" />
                    <flux:error name="title" />

                    <flux:input wire:model="handle" label="Handle" placeholder="summer-collection" />
                    <flux:error name="handle" />

                    <flux:textarea wire:model="descriptionHtml" label="Description" rows="6" placeholder="Describe this collection..." />

                <flux:heading size="lg">Products</flux:heading>

                    <flux:input wire:model.live.debounce.300ms="productSearch" icon="magnifying-glass" label="Search products" placeholder="Search products..." />

                    @if ($searchResults->isNotEmpty())

                            @foreach ($searchResults as $product)

getKey() }}">

{{ $product->title }}

/{{ $product->handle }}

                                    <flux:button type="button" wire:click="addProduct({{ $product->getKey() }})" variant="ghost" icon="plus">Add</flux:button>

                            @endforeach

                    @endif

                        <flux:text>Assigned products</flux:text>

                        @forelse ($assignedProducts as $product)

getKey() }}">

                                        <flux:icon name="cube" class="size-4" />

{{ $product->title }}

/{{ $product->handle }}

                                <flux:button type="button" wire:click="removeProduct({{ $product->getKey() }})" variant="ghost" icon="x-mark">Remove</flux:button>

                        @empty
                            <flux:text>No products assigned.</flux:text>
                        @endforelse

                <flux:select wire:model="status" label="Status">
                    <flux:select.option value="draft">Draft</flux:select.option>
                    <flux:select.option value="active">Active</flux:select.option>
                    <flux:select.option value="archived">Archived</flux:select.option>
                </flux:select>

                <flux:button :href="route('admin.collections.index')" wire:navigate variant="ghost">Discard</flux:button>
                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
                    Save
                    Saving...
                </flux:button>

    </form>


Output: {"output":"Success. Updated the following files:\nA resources/views/livewire/admin/collections/form.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Collections/Form.php
@@
-        $collection = Collection::query()->updateOrCreate(
-            ['id' => $this->collection?->getKey()],
-            [
+        $attributes = [
+            'store_id' => $store->getKey(),
+            'title' => $this->title,
+            'handle' => Str::slug($this->handle),
+            'description_html' => $this->descriptionHtml ?: null,
+            'type' => 'manual',
+            'status' => $this->status,
+        ];
+
+        $collection = $this->collection instanceof Collection
+            ? tap($this->collection)->update($attributes)
+            : Collection::query()->create([
                 'store_id' => $store->getKey(),
-                'title' => $this->title,
-                'handle' => Str::slug($this->handle),
-                'description_html' => $this->descriptionHtml ?: null,
-                'type' => 'manual',
-                'status' => $this->status,
-            ],
-        );
+                ...$attributes,
+            ]);
*** End Patch


@@ -87,13 +87,17 @@

-        $collection = Collection::query()->updateOrCreate(
-            ['id' => $this->collection?->getKey()],
-            [
+        $attributes = [
+            'store_id' => $store->getKey(),
+            'title' => $this->title,
+            'handle' => Str::slug($this->handle),
+            'description_html' => $this->descriptionHtml ?: null,
+            'type' => 'manual',
+            'status' => $this->status,
+        ];
+
+        $collection = $this->collection instanceof Collection
+            ? tap($this->collection)->update($attributes)
+            : Collection::query()->create([
                 'store_id' => $store->getKey(),
-                'title' => $this->title,
-                'handle' => Str::slug($this->handle),
-                'description_html' => $this->descriptionHtml ?: null,
-                'type' => 'manual',
-                'status' => $this->status,
-            ],
-        );
+                ...$attributes,
+            ]);


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Collections/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Collections/Form.php
@@
-            : Collection::query()->create([
-                'store_id' => $store->getKey(),
-                ...$attributes,
-            ]);
+            : Collection::query()->create($attributes);
*** End Patch


@@ -98,6 +98,3 @@
             ? tap($this->collection)->update($attributes)
-            : Collection::query()->create([
-                'store_id' => $store->getKey(),
-                ...$attributes,
-            ]);
+            : Collection::query()->create($attributes);


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Collections/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Home.php
@@
 namespace App\Livewire\Storefront;

+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use Illuminate\Support\Collection as SupportCollection;
 use Livewire\Component;

 class Home extends Component
 {
-    public function render()
+    public function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
+    public function featuredProducts(): SupportCollection
+    {
+        return Product::query()
+            ->with(['variants.inventoryItem'])
+            ->withCount('variants')
+            ->where('status', 'active')
+            ->whereNotNull('published_at')
+            ->latest('published_at')
+            ->limit(8)
+            ->get();
+    }
+
+    public function featuredCollections(): SupportCollection
+    {
+        return Collection::query()
+            ->where('status', 'active')
+            ->orderBy('title')
+            ->limit(4)
+            ->get();
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.storefront.home');
+        return view('livewire.storefront.home', [
+            'store' => $this->store(),
+            'featuredProducts' => $this->featuredProducts(),
+            'featuredCollections' => $this->featuredCollections(),
+        ])->layout('layouts.storefront', [
+            'title' => $this->store()->name,
+        ]);
     }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Storefront/Home.php

@@ -4,2 +4,6 @@

+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use Illuminate\Support\Collection as SupportCollection;
 use Livewire\Component;
@@ -8,5 +12,41 @@
 {
-    public function render()
+    public function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
+    public function featuredProducts(): SupportCollection
+    {
+        return Product::query()
+            ->with(['variants.inventoryItem'])
+            ->withCount('variants')
+            ->where('status', 'active')
+            ->whereNotNull('published_at')
+            ->latest('published_at')
+            ->limit(8)
+            ->get();
+    }
+
+    public function featuredCollections(): SupportCollection
+    {
+        return Collection::query()
+            ->where('status', 'active')
+            ->orderBy('title')
+            ->limit(4)
+            ->get();
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.storefront.home');
+        return view('livewire.storefront.home', [
+            'store' => $this->store(),
+            'featuredProducts' => $this->featuredProducts(),
+            'featuredCollections' => $this->featuredCollections(),
+        ])->layout('layouts.storefront', [
+            'title' => $this->store()->name,
+        ]);
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Storefront/Home.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: resources/views/livewire/storefront/home.blade.php
*** End Patch


Success. Updated the following files:
D resources/views/livewire/storefront/home.blade.php

{{-- Live as if you were to die tomorrow. Learn as if you were to live forever. - Mahatma Gandhi --}}


Output: {"output":"Success. Updated the following files:\nD resources/views/livewire/storefront/home.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: resources/views/livewire/storefront/home.blade.php
+

+

+

+

+

+
New season essentials

+
{{ $store->name }}

+
Browse a scoped demo catalog with variants, inventory states, sale pricing, and digital products.

+

+
+

+                    <flux:button :href="route('collections.show', 'new-arrivals')" wire:navigate variant="primary">Shop new arrivals</flux:button>
+                    <flux:button :href="route('collections.index')" wire:navigate variant="ghost">View collections</flux:button>
+

+

+
+

+                @foreach ($featuredProducts->take(4) as $product)
+
getKey() }}">
+                        <x-storefront.product-card :product="$product" />
+

+                @endforeach
+

+

+

+
+

+

+

+
Featured collections

+
Curated paths through the current store catalog.

+

+            All collections
+

+
+

+            @foreach ($featuredCollections as $collection)
+                handle) }}" class="group rounded-lg border border-zinc-200 bg-white p-5 transition hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700" wire:navigate wire:key="home-collection-{{ $collection->getKey() }}">
+

+                        <flux:icon name="rectangle-stack" class="size-10" />
+

+
{{ $collection->title }}

+
{{ $collection->products()->count() }} products

+
+            @endforeach
+

+

+
+

+

+

+
Featured products

+
Active products currently published for {{ $store->name }}.

+

+

+
+

+            @foreach ($featuredProducts as $product)
+                <x-storefront.product-card :product="$product" wire:key="home-product-{{ $product->getKey() }}" />
+            @endforeach
+

+

+

*** End Patch


Success. Updated the following files:
A resources/views/livewire/storefront/home.blade.php

New season essentials

{{ $store->name }}

Browse a scoped demo catalog with variants, inventory states, sale pricing, and digital products.

                    <flux:button :href="route('collections.show', 'new-arrivals')" wire:navigate variant="primary">Shop new arrivals</flux:button>
                    <flux:button :href="route('collections.index')" wire:navigate variant="ghost">View collections</flux:button>

                @foreach ($featuredProducts->take(4) as $product)

getKey() }}">
                        <x-storefront.product-card :product="$product" />

                @endforeach

Featured collections

Curated paths through the current store catalog.

            All collections

            @foreach ($featuredCollections as $collection)
                handle) }}" class="group rounded-lg border border-zinc-200 bg-white p-5 transition hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700" wire:navigate wire:key="home-collection-{{ $collection->getKey() }}">

                        <flux:icon name="rectangle-stack" class="size-10" />

{{ $collection->title }}

{{ $collection->products()->count() }} products

            @endforeach

Featured products

Active products currently published for {{ $store->name }}.

            @foreach ($featuredProducts as $product)
                <x-storefront.product-card :product="$product" wire:key="home-product-{{ $product->getKey() }}" />
            @endforeach


Output: {"output":"Success. Updated the following files:\nA resources/views/livewire/storefront/home.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Collections/Index.php
@@
 namespace App\Livewire\Storefront\Collections;

+use App\Models\Collection;
+use Illuminate\Support\Collection as SupportCollection;
 use Livewire\Component;

 class Index extends Component
 {
-    public function render()
+    public function collections(): SupportCollection
+    {
+        return Collection::query()
+            ->withCount(['products' => fn ($query) => $query
+                ->where('status', 'active')
+                ->whereNotNull('published_at')])
+            ->where('status', 'active')
+            ->orderBy('title')
+            ->get();
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.storefront.collections.index');
+        return view('livewire.storefront.collections.index', [
+            'collections' => $this->collections(),
+        ])->layout('layouts.storefront', [
+            'title' => __('Collections'),
+        ]);
     }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Storefront/Collections/Index.php

@@ -4,2 +4,4 @@

+use App\Models\Collection;
+use Illuminate\Support\Collection as SupportCollection;
 use Livewire\Component;
@@ -8,5 +10,20 @@
 {
-    public function render()
+    public function collections(): SupportCollection
     {
-        return view('livewire.storefront.collections.index');
+        return Collection::query()
+            ->withCount(['products' => fn ($query) => $query
+                ->where('status', 'active')
+                ->whereNotNull('published_at')])
+            ->where('status', 'active')
+            ->orderBy('title')
+            ->get();
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.storefront.collections.index', [
+            'collections' => $this->collections(),
+        ])->layout('layouts.storefront', [
+            'title' => __('Collections'),
+        ]);
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Storefront/Collections/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: resources/views/livewire/storefront/collections/index.blade.php
*** End Patch


Success. Updated the following files:
D resources/views/livewire/storefront/collections/index.blade.php

{{-- People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius --}}


Output: {"output":"Success. Updated the following files:\nD resources/views/livewire/storefront/collections/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: resources/views/livewire/storefront/collections/index.blade.php
+

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

+
Collections

+
Browse curated groups of products from this store.

+

+
+

+        @foreach ($collections as $collection)
+            handle) }}" class="group rounded-lg border border-zinc-200 bg-white p-5 transition hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700" wire:navigate wire:key="collection-index-{{ $collection->getKey() }}">
+

+                    <flux:icon name="rectangle-stack" class="size-10" />
+

+
{{ $collection->title }}

+
{{ $collection->products_count }} products

+
+        @endforeach
+

+

*** End Patch


Success. Updated the following files:
A resources/views/livewire/storefront/collections/index.blade.php

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

Collections

Browse curated groups of products from this store.

        @foreach ($collections as $collection)
            handle) }}" class="group rounded-lg border border-zinc-200 bg-white p-5 transition hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700" wire:navigate wire:key="collection-index-{{ $collection->getKey() }}">

                    <flux:icon name="rectangle-stack" class="size-10" />

{{ $collection->title }}

{{ $collection->products_count }} products

        @endforeach


Output: {"output":"Success. Updated the following files:\nA resources/views/livewire/storefront/collections/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Collections/Show.php
@@
 namespace App\Livewire\Storefront\Collections;

+use App\Models\Collection;
+use App\Models\ProductVariant;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection as SupportCollection;
 use Livewire\Component;
+use Livewire\WithPagination;

 class Show extends Component
 {
-    public function render()
+    use WithPagination;
+
+    public string $handle;
+
+    public string $sort = 'featured';
+
+    public bool $inStock = false;
+
+    public ?int $minPrice = null;
+
+    public ?int $maxPrice = null;
+
+    /**
+     * @var array<int, string>
+     */
+    public array $types = [];
+
+    /**
+     * @var array<int, string>
+     */
+    public array $vendors = [];
+
+    public function mount(string $handle): void
+    {
+        $this->handle = $handle;
+    }
+
+    public function updated(): void
+    {
+        $this->resetPage();
+    }
+
+    public function clearFilters(): void
+    {
+        $this->inStock = false;
+        $this->minPrice = null;
+        $this->maxPrice = null;
+        $this->types = [];
+        $this->vendors = [];
+        $this->resetPage();
+    }
+
+    public function collection(): Collection
+    {
+        return Collection::query()
+            ->where('handle', $this->handle)
+            ->where('status', 'active')
+            ->firstOrFail();
+    }
+
+    public function products(): LengthAwarePaginator
+    {
+        $collection = $this->collection();
+
+        return $collection->products()
+            ->with(['variants.inventoryItem'])
+            ->withCount('variants')
+            ->where('products.status', 'active')
+            ->whereNotNull('products.published_at')
+            ->when($this->inStock, function (Builder $query): void {
+                $query->whereHas('variants.inventoryItem', function (Builder $query): void {
+                    $query
+                        ->where('policy', 'continue')
+                        ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved');
+                });
+            })
+            ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types))
+            ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors))
+            ->when($this->minPrice !== null, function (Builder $query): void {
+                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100));
+            })
+            ->when($this->maxPrice !== null, function (Builder $query): void {
+                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100));
+            })
+            ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
+            ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
+            ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at'))
+            ->paginate(12);
+    }
+
+    public function productTypes(): SupportCollection
+    {
+        return $this->collection()->products()
+            ->where('products.status', 'active')
+            ->whereNotNull('products.published_at')
+            ->whereNotNull('product_type')
+            ->distinct()
+            ->orderBy('product_type')
+            ->pluck('product_type');
+    }
+
+    public function productVendors(): SupportCollection
+    {
+        return $this->collection()->products()
+            ->where('products.status', 'active')
+            ->whereNotNull('products.published_at')
+            ->whereNotNull('vendor')
+            ->distinct()
+            ->orderBy('vendor')
+            ->pluck('vendor');
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.storefront.collections.show');
+        $collection = $this->collection();
+
+        return view('livewire.storefront.collections.show', [
+            'collection' => $collection,
+            'products' => $this->products(),
+            'productTypes' => $this->productTypes(),
+            'productVendors' => $this->productVendors(),
+        ])->layout('layouts.storefront', [
+            'title' => $collection->title,
+        ]);
     }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Storefront/Collections/Show.php

@@ -4,3 +4,9 @@

+use App\Models\Collection;
+use App\Models\ProductVariant;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection as SupportCollection;
 use Livewire\Component;
+use Livewire\WithPagination;

@@ -8,5 +14,116 @@
 {
-    public function render()
+    use WithPagination;
+
+    public string $handle;
+
+    public string $sort = 'featured';
+
+    public bool $inStock = false;
+
+    public ?int $minPrice = null;
+
+    public ?int $maxPrice = null;
+
+    /**
+     * @var array<int, string>
+     */
+    public array $types = [];
+
+    /**
+     * @var array<int, string>
+     */
+    public array $vendors = [];
+
+    public function mount(string $handle): void
     {
-        return view('livewire.storefront.collections.show');
+        $this->handle = $handle;
+    }
+
+    public function updated(): void
+    {
+        $this->resetPage();
+    }
+
+    public function clearFilters(): void
+    {
+        $this->inStock = false;
+        $this->minPrice = null;
+        $this->maxPrice = null;
+        $this->types = [];
+        $this->vendors = [];
+        $this->resetPage();
+    }
+
+    public function collection(): Collection
+    {
+        return Collection::query()
+            ->where('handle', $this->handle)
+            ->where('status', 'active')
+            ->firstOrFail();
+    }
+
+    public function products(): LengthAwarePaginator
+    {
+        $collection = $this->collection();
+
+        return $collection->products()
+            ->with(['variants.inventoryItem'])
+            ->withCount('variants')
+            ->where('products.status', 'active')
+            ->whereNotNull('products.published_at')
+            ->when($this->inStock, function (Builder $query): void {
+                $query->whereHas('variants.inventoryItem', function (Builder $query): void {
+                    $query
+                        ->where('policy', 'continue')
+                        ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved');
+                });
+            })
+            ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types))
+            ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors))
+            ->when($this->minPrice !== null, function (Builder $query): void {
+                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100));
+            })
+            ->when($this->maxPrice !== null, function (Builder $query): void {
+                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100));
+            })
+            ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
+            ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
+            ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at'))
+            ->paginate(12);
+    }
+
+    public function productTypes(): SupportCollection
+    {
+        return $this->collection()->products()
+            ->where('products.status', 'active')
+            ->whereNotNull('products.published_at')
+            ->whereNotNull('product_type')
+            ->distinct()
+            ->orderBy('product_type')
+            ->pluck('product_type');
+    }
+
+    public function productVendors(): SupportCollection
+    {
+        return $this->collection()->products()
+            ->where('products.status', 'active')
+            ->whereNotNull('products.published_at')
+            ->whereNotNull('vendor')
+            ->distinct()
+            ->orderBy('vendor')
+            ->pluck('vendor');
+    }
+
+    public function render(): mixed
+    {
+        $collection = $this->collection();
+
+        return view('livewire.storefront.collections.show', [
+            'collection' => $collection,
+            'products' => $this->products(),
+            'productTypes' => $this->productTypes(),
+            'productVendors' => $this->productVendors(),
+        ])->layout('layouts.storefront', [
+            'title' => $collection->title,
+        ]);
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Storefront/Collections/Show.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: resources/views/livewire/storefront/collections/show.blade.php
*** End Patch


Success. Updated the following files:
D resources/views/livewire/storefront/collections/show.blade.php

{{-- Nothing worth having comes easy. - Theodore Roosevelt --}}


Output: {"output":"Success. Updated the following files:\nD resources/views/livewire/storefront/collections/show.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Add File: resources/views/livewire/storefront/collections/show.blade.php
+

+    <x-storefront.breadcrumbs :items="[
+        ['label' => 'Collections', 'url' => route('collections.index')],
+        ['label' => $collection->title],
+    ]" />
+
+

+
{{ $collection->title }}

+        @if ($collection->description_html)
+
{!! $collection->description_html !!}

+        @endif
+

+
+

+
+

+
Filters

+                <button wire:click="clearFilters" class="text-sm font-medium text-blue-700 hover:underline dark:text-blue-300">Clear all</button>
+

+
+

+                <flux:checkbox wire:model.live="inStock" label="In stock" />
+
+

+
Price

+

+                        <flux:input wire:model.live.debounce.500ms="minPrice" type="number" min="0" placeholder="Min" aria-label="Minimum price" />
+                        <flux:input wire:model.live.debounce.500ms="maxPrice" type="number" min="0" placeholder="Max" aria-label="Maximum price" />
+

+

+
+

+
Product type

+

+                        @foreach ($productTypes as $type)
+                            <flux:checkbox wire:model.live="types" value="{{ $type }}" :label="$type" wire:key="type-filter-{{ $type }}" />
+                        @endforeach
+

+

+
+

+
Vendor

+

+                        @foreach ($productVendors as $vendor)
+                            <flux:checkbox wire:model.live="vendors" value="{{ $vendor }}" :label="$vendor" wire:key="vendor-filter-{{ $vendor }}" />
+                        @endforeach
+

+

+

+
+
+

+

+
{{ $products->total() }} products

+
+                <flux:select wire:model.live="sort" aria-label="Sort products" class="sm:max-w-56">
+                    <flux:select.option value="featured">Featured</flux:select.option>
+                    <flux:select.option value="price_asc">Price: Low to High</flux:select.option>
+                    <flux:select.option value="price_desc">Price: High to Low</flux:select.option>
+                    <flux:select.option value="newest">Newest</flux:select.option>
+                </flux:select>
+

+
+            @if ($products->isEmpty())
+

+

+                        <flux:icon name="magnifying-glass" class="size-10 text-zinc-400" />
+
No products found

+
Try adjusting your filters or browse another collection.

+                        <flux:button wire:click="clearFilters" variant="ghost">Clear filters</flux:button>
+

+

+            @else
+

+                    @foreach ($products as $product)
+                        <x-storefront.product-card :product="$product" wire:key="collection-product-{{ $product->getKey() }}" />
+                    @endforeach
+

+
+                {{ $products->links() }}
+            @endif
+

+

+

*** End Patch


Success. Updated the following files:
A resources/views/livewire/storefront/collections/show.blade.php

    <x-storefront.breadcrumbs :items="[
        ['label' => 'Collections', 'url' => route('collections.index')],
        ['label' => $collection->title],
    ]" />

{{ $collection->title }}

        @if ($collection->description_html)

{!! $collection->description_html !!}

        @endif

Filters

                <button wire:click="clearFilters" class="text-sm font-medium text-blue-700 hover:underline dark:text-blue-300">Clear all</button>

                <flux:checkbox wire:model.live="inStock" label="In stock" />

Price

                        <flux:input wire:model.live.debounce.500ms="minPrice" type="number" min="0" placeholder="Min" aria-label="Minimum price" />
                        <flux:input wire:model.live.debounce.500ms="maxPrice" type="number" min="0" placeholder="Max" aria-label="Maximum price" />

Product type

                        @foreach ($productTypes as $type)
                            <flux:checkbox wire:model.live="types" value="{{ $type }}" :label="$type" wire:key="type-filter-{{ $type }}" />
                        @endforeach

Vendor

                        @foreach ($productVendors as $vendor)
                            <flux:checkbox wire:model.live="vendors" value="{{ $vendor }}" :label="$vendor" wire:key="vendor-filter-{{ $vendor }}" />
                        @endforeach

{{ $products->total() }} products

                <flux:select wire:model.live="sort" aria-label="Sort products" class="sm:max-w-56">
                    <flux:select.option value="featured">Featured</flux:select.option>
                    <flux:select.option value="price_asc">Price: Low to High</flux:select.option>
                    <flux:select.option value="price_desc">Price: High to Low</flux:select.option>
                    <flux:select.option value="newest">Newest</flux:select.option>
                </flux:select>

            @if ($products->isEmpty())

                        <flux:icon name="magnifying-glass" class="size-10 text-zinc-400" />

No products found

Try adjusting your filters or browse another collection.

                        <flux:button wire:click="clearFilters" variant="ghost">Clear filters</flux:button>

            @else

                    @foreach ($products as $product)
                        <x-storefront.product-card :product="$product" wire:key="collection-product-{{ $product->getKey() }}" />
                    @endforeach

                {{ $products->links() }}
            @endif


Output: {"output":"Success. Updated the following files:\nA resources/views/livewire/storefront/collections/show.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Products/Show.php
@@
 namespace App\Livewire\Storefront\Products;

+use App\Enums\InventoryPolicy;
+use App\Models\Product;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
 use Livewire\Component;

 class Show extends Component
 {
-    public function render()
+    public string $handle;
+
+    /**
+     * @var array<string, string>
+     */
+    public array $selectedOptions = [];
+
+    public int $quantity = 1;
+
+    public function mount(string $handle): void
+    {
+        $this->handle = $handle;
+
+        $variant = $this->product()->variants->firstWhere('is_default', true)
+            ?? $this->product()->variants->first();
+
+        if ($variant instanceof ProductVariant) {
+            $this->selectedOptions = $variant->optionValues
+                ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value])
+                ->all();
+        }
+    }
+
+    public function selectOption(string $optionName, string $value): void
+    {
+        $this->selectedOptions[$optionName] = $value;
+        $this->quantity = 1;
+    }
+
+    public function increaseQuantity(): void
+    {
+        $variant = $this->selectedVariant();
+        $max = $variant?->inventoryItem?->policy === InventoryPolicy::Deny
+            ? max(1, $variant->inventoryItem->availableQuantity())
+            : null;
+
+        if ($max === null || $this->quantity < $max) {
+            $this->quantity++;
+        }
+    }
+
+    public function decreaseQuantity(): void
+    {
+        $this->quantity = max(1, $this->quantity - 1);
+    }
+
+    public function addToCart(): void
+    {
+        if (! $this->canAddToCart()) {
+            return;
+        }
+
+        $this->dispatch('toast', type: 'success', message: __('Added to cart'));
+    }
+
+    public function product(): Product
+    {
+        return Product::query()
+            ->with(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections'])
+            ->where('handle', $this->handle)
+            ->where('status', 'active')
+            ->whereNotNull('published_at')
+            ->firstOrFail();
+    }
+
+    public function selectedVariant(): ?ProductVariant
+    {
+        $product = $this->product();
+
+        if ($product->options->isEmpty()) {
+            return $product->variants->first();
+        }
+
+        return $product->variants->first(function (ProductVariant $variant): bool {
+            $options = $variant->optionValues
+                ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value])
+                ->all();
+
+            return $options === $this->selectedOptions;
+        });
+    }
+
+    public function canAddToCart(): bool
+    {
+        $variant = $this->selectedVariant();
+
+        if (! $variant instanceof ProductVariant || ! $variant->isPurchasable()) {
+            return false;
+        }
+
+        $inventory = $variant->inventoryItem;
+
+        return $inventory?->policy === InventoryPolicy::Continue
+            || ($inventory?->availableQuantity() ?? 0) >= $this->quantity;
+    }
+
+    /**
+     * @return array{message: string, class: string}
+     */
+    public function stockState(): array
+    {
+        $variant = $this->selectedVariant();
+        $inventory = $variant?->inventoryItem;
+
+        if (! $inventory) {
+            return ['message' => 'Unavailable', 'class' => 'text-red-700 dark:text-red-300'];
+        }
+
+        if ($inventory->policy === InventoryPolicy::Continue && $inventory->availableQuantity() <= 0) {
+            return ['message' => 'Available on backorder', 'class' => 'text-blue-700 dark:text-blue-300'];
+        }
+
+        if ($inventory->availableQuantity() <= 0) {
+            return ['message' => 'Out of stock', 'class' => 'text-red-700 dark:text-red-300'];
+        }
+
+        if ($inventory->availableQuantity() <= 10) {
+            return ['message' => "Only {$inventory->availableQuantity()} left in stock", 'class' => 'text-amber-700 dark:text-amber-300'];
+        }
+
+        return ['message' => 'In stock', 'class' => 'text-green-700 dark:text-green-300'];
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.storefront.products.show');
+        $product = $this->product();
+
+        return view('livewire.storefront.products.show', [
+            'product' => $product,
+            'selectedVariant' => $this->selectedVariant(),
+            'stockState' => $this->stockState(),
+        ])->layout('layouts.storefront', [
+            'title' => $product->title,
+        ]);
     }
 }
*** End Patch