sed -n '1,240p' app/Livewire/Admin/Settings/Shipping.php

<?php

namespace App\Livewire\Admin\Settings;

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

class Shipping extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public ?int $editingZoneId = null;

    public string $zoneName = '';

    public string $zoneCountries = '';

    public ?int $rateZoneId = null;

    public ?int $editingRateId = null;

    public string $rateName = '';

    public string $rateType = 'flat';

    public string $rateAmount = '0.00';

    public string $minimumWeight = '0';

    public string $maximumWeight = '';

    public string $minimumOrderAmount = '0.00';

    public string $maximumOrderAmount = '';

    public bool $rateActive = true;

    public string $testCountry = 'DE';

    public string $testRegion = '';

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

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

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

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

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

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

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

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

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

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

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

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

            return;
        }

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

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

        $this->resetZoneForm();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        $this->resetRateForm();

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

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

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

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

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

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

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

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

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

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

    /**
     * @return Collection<int, ShippingZone>


Output:
Chunk ID: 42345a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1725
Output:
<?php

namespace App\Livewire\Admin\Settings;

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

class Shipping extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public ?int $editingZoneId = null;

    public string $zoneName = '';

    public string $zoneCountries = '';

    public ?int $rateZoneId = null;

    public ?int $editingRateId = null;

    public string $rateName = '';

    public string $rateType = 'flat';

    public string $rateAmount = '0.00';

    public string $minimumWeight = '0';

    public string $maximumWeight = '';

    public string $minimumOrderAmount = '0.00';

    public string $maximumOrderAmount = '';

    public bool $rateActive = true;

    public string $testCountry = 'DE';

    public string $testRegion = '';

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

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

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

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

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

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

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

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

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

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

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

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

            return;
        }

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

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

        $this->resetZoneForm();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        $this->resetRateForm();

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

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

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

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

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

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

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

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

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

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

    /**
     * @return Collection<int, ShippingZone>


Input:
*** Begin Patch
*** Update File: app/Services/SearchService.php
@@
-        if (filled($filters['vendor'] ?? null)) {
-            $builder->where('products.vendor', (string) $filters['vendor']);
+        $vendors = collect(Arr::wrap($filters['vendor'] ?? []))
+            ->filter(fn (mixed $vendor): bool => filled($vendor))
+            ->map(fn (mixed $vendor): string => (string) $vendor)
+            ->values()
+            ->all();
+
+        if ($vendors !== []) {
+            $builder->whereIn('products.vendor', $vendors);
+        }
+
+        $productTypes = collect(Arr::wrap($filters['product_type'] ?? []))
+            ->filter(fn (mixed $productType): bool => filled($productType))
+            ->map(fn (mixed $productType): string => (string) $productType)
+            ->values()
+            ->all();
+
+        if ($productTypes !== []) {
+            $builder->whereIn('products.product_type', $productTypes);
         }
@@
     private function toFtsQuery(string $query): string
     {
         $tokens = preg_split('/[^\pL\pN]+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $tokens = array_slice($tokens, 0, 8);
+        $lastIndex = count($tokens) - 1;

         return collect($tokens)
-            ->take(8)
-            ->map(fn (string $token, int $index) => $index === count($tokens) - 1 ? "{$token}*" : $token)
+            ->map(fn (string $token, int $index): string => $index === $lastIndex ? "{$token}*" : $token)
             ->implode(' ');
     }
*** Update File: app/Livewire/Storefront/Search/Index.php
@@
-use App\Models\Product;
+use App\Models\Product;
+use App\Models\Store;
+use App\Services\SearchService;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
-use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
 use Livewire\Component;
 use Livewire\WithPagination;
@@
     public string $q = '';

+    public string $sort = 'relevance';
+
+    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(): void
     {
         $this->q = (string) request('q', '');
     }

-    public function updatedQ(): void
+    public function updated(): void
     {
         $this->resetPage();
     }

+    public function clearFilters(): void
+    {
+        $this->sort = 'relevance';
+        $this->inStock = false;
+        $this->minPrice = null;
+        $this->maxPrice = null;
+        $this->types = [];
+        $this->vendors = [];
+        $this->resetPage();
+    }
+
     public function products(): LengthAwarePaginator
     {
-        return Product::query()
-            ->with(['variants.inventoryItem'])
-            ->withCount('variants')
+        return app(SearchService::class)->search(
+            $this->store(),
+            $this->q,
+            $this->filters(),
+            12,
+            $this->sort,
+        );
+    }
+
+    public function productTypes(): Collection
+    {
+        return Product::withoutGlobalScopes()
+            ->where('store_id', $this->store()->getKey())
             ->where('status', 'active')
             ->whereNotNull('published_at')
-            ->when(trim($this->q) !== '', function (Builder $query): void {
-                $search = '%'.trim($this->q).'%';
-
-                $query->where(function (Builder $query) use ($search): void {
-                    $query
-                        ->where('title', 'like', $search)
-                        ->orWhere('vendor', 'like', $search)
-                        ->orWhere('product_type', 'like', $search)
-                        ->orWhere('tags', 'like', $search);
-                });
-            })
-            ->latest('published_at')
-            ->paginate(12);
+            ->whereNotNull('product_type')
+            ->distinct()
+            ->orderBy('product_type')
+            ->pluck('product_type');
+    }
+
+    public function productVendors(): Collection
+    {
+        return Product::withoutGlobalScopes()
+            ->where('store_id', $this->store()->getKey())
+            ->where('status', 'active')
+            ->whereNotNull('published_at')
+            ->whereNotNull('vendor')
+            ->distinct()
+            ->orderBy('vendor')
+            ->pluck('vendor');
     }

     public function render(): mixed
     {
         return view('livewire.storefront.search.index', [
             'products' => $this->products(),
+            'productTypes' => $this->productTypes(),
+            'productVendors' => $this->productVendors(),
         ])->layout('layouts.storefront', [
             'title' => __('Search results'),
         ]);
     }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function filters(): array
+    {
+        return [
+            'in_stock' => $this->inStock,
+            'price_min' => $this->minPrice === null ? null : $this->minPrice * 100,
+            'price_max' => $this->maxPrice === null ? null : $this->maxPrice * 100,
+            'product_type' => $this->types,
+            'vendor' => $this->vendors,
+        ];
+    }
+
+    private function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }
*** Update File: resources/views/livewire/storefront/search/index.blade.php
@@
-

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

-

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

-
Try another search term or browse a collection.

-                    <flux:button :href="route('collections.index')" wire:navigate variant="ghost">Browse collections</flux:button>
+

+
+

+
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="search-type-filter-{{ $type }}" />
+                        @endforeach
+

+

+
+

+
Vendor

+

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

-        @else
-

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

+

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

+
+                <flux:select wire:model.live="sort" aria-label="Sort products" class="sm:max-w-56">
+                    <flux:select.option value="relevance">Relevance</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 another search term or adjust your filters.

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

+

+            @else
+

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

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

-        @endif
+            @endif
+

*** End Patch


Success. Updated the following files:
M app/Services/SearchService.php
M app/Livewire/Storefront/Search/Index.php
M resources/views/livewire/storefront/search/index.blade.php

@@ -274,6 +274,22 @@

-        if (filled($filters['vendor'] ?? null)) {
-            $builder->where('products.vendor', (string) $filters['vendor']);
+        $vendors = collect(Arr::wrap($filters['vendor'] ?? []))
+            ->filter(fn (mixed $vendor): bool => filled($vendor))
+            ->map(fn (mixed $vendor): string => (string) $vendor)
+            ->values()
+            ->all();
+
+        if ($vendors !== []) {
+            $builder->whereIn('products.vendor', $vendors);
         }

+        $productTypes = collect(Arr::wrap($filters['product_type'] ?? []))
+            ->filter(fn (mixed $productType): bool => filled($productType))
+            ->map(fn (mixed $productType): string => (string) $productType)
+            ->values()
+            ->all();
+
+        if ($productTypes !== []) {
+            $builder->whereIn('products.product_type', $productTypes);
+        }
+
         foreach (Arr::wrap($filters['tags'] ?? []) as $tag) {
@@ -365,6 +381,7 @@
         $tokens = preg_split('/[^\pL\pN]+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $tokens = array_slice($tokens, 0, 8);
+        $lastIndex = count($tokens) - 1;

         return collect($tokens)
-            ->take(8)
-            ->map(fn (string $token, int $index) => $index === count($tokens) - 1 ? "{$token}*" : $token)
+            ->map(fn (string $token, int $index): string => $index === $lastIndex ? "{$token}*" : $token)
             ->implode(' ');

@@ -15,23 +15,71 @@

-

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

-

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

-
Try another search term or browse a collection.

-                    <flux:button :href="route('collections.index')" wire:navigate variant="ghost">Browse collections</flux:button>
+

+
+

+
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="search-type-filter-{{ $type }}" />
+                        @endforeach
+

+

+
+

+
Vendor

+

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

-        @else
-

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

+

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

+
+                <flux:select wire:model.live="sort" aria-label="Sort products" class="sm:max-w-56">
+                    <flux:select.option value="relevance">Relevance</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 another search term or adjust your filters.

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

+

+            @else
+

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

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

-        @endif
+            @endif
+

@@ -5,4 +5,6 @@
 use App\Models\Product;
+use App\Models\Store;
+use App\Services\SearchService;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
-use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
 use Livewire\Component;
@@ -16,2 +18,20 @@

+    public string $sort = 'relevance';
+
+    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(): void
@@ -21,4 +41,15 @@

-    public function updatedQ(): void
+    public function updated(): void
+    {
+        $this->resetPage();
+    }
+
+    public function clearFilters(): void
     {
+        $this->sort = 'relevance';
+        $this->inStock = false;
+        $this->minPrice = null;
+        $this->maxPrice = null;
+        $this->types = [];
+        $this->vendors = [];
         $this->resetPage();
@@ -28,20 +59,33 @@
     {
-        return Product::query()
-            ->with(['variants.inventoryItem'])
-            ->withCount('variants')
+        return app(SearchService::class)->search(
+            $this->store(),
+            $this->q,
+            $this->filters(),
+            12,
+            $this->sort,
+        );
+    }
+
+    public function productTypes(): Collection
+    {
+        return Product::withoutGlobalScopes()
+            ->where('store_id', $this->store()->getKey())
             ->where('status', 'active')
             ->whereNotNull('published_at')
-            ->when(trim($this->q) !== '', function (Builder $query): void {
-                $search = '%'.trim($this->q).'%';
+            ->whereNotNull('product_type')
+            ->distinct()
+            ->orderBy('product_type')
+            ->pluck('product_type');
+    }

-                $query->where(function (Builder $query) use ($search): void {
-                    $query
-                        ->where('title', 'like', $search)
-                        ->orWhere('vendor', 'like', $search)
-                        ->orWhere('product_type', 'like', $search)
-                        ->orWhere('tags', 'like', $search);
-                });
-            })
-            ->latest('published_at')
-            ->paginate(12);
+    public function productVendors(): Collection
+    {
+        return Product::withoutGlobalScopes()
+            ->where('store_id', $this->store()->getKey())
+            ->where('status', 'active')
+            ->whereNotNull('published_at')
+            ->whereNotNull('vendor')
+            ->distinct()
+            ->orderBy('vendor')
+            ->pluck('vendor');
     }
@@ -52,2 +96,4 @@
             'products' => $this->products(),
+            'productTypes' => $this->productTypes(),
+            'productVendors' => $this->productVendors(),
         ])->layout('layouts.storefront', [
@@ -56,2 +102,25 @@
     }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function filters(): array
+    {
+        return [
+            'in_stock' => $this->inStock,
+            'price_min' => $this->minPrice === null ? null : $this->minPrice * 100,
+            'price_max' => $this->maxPrice === null ? null : $this->maxPrice * 100,
+            'product_type' => $this->types,
+            'vendor' => $this->vendors,
+        ];
+    }
+
+    private function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Services/SearchService.php\nM app/Livewire/Storefront/Search/Index.php\nM resources/views/livewire/storefront/search/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


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

+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
 use Livewire\Component;

 class Settings extends Component
 {
-    public function render()
+    use AuthorizesRequests;
+
+    #[Locked]
+    public int $storeId;
+
+    /**
+     * @var array<int, string>
+     */
+    public array $synonymGroups = [];
+
+    public string $stopWords = '';
+
+    public ?string $lastIndexedAt = null;
+
+    public bool $isReindexing = false;
+
+    public ?int $reindexProgress = null;
+
+    public function mount(): void
     {
-        return view('livewire.admin.search.settings');
+        $store = $this->store();
+
+        $this->authorize('update', $store);
+
+        $this->storeId = $store->getKey();
+
+        $settings = $this->settings();
+        $this->synonymGroups = collect($settings->synonyms_json ?? [])
+            ->map(fn (array $group): string => implode(', ', $group))
+            ->values()
+            ->all();
+        $this->stopWords = implode(', ', $settings->stop_words_json ?? []);
+        $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString();
+    }
+
+    public function addSynonymGroup(): void
+    {
+        $this->synonymGroups[] = '';
+    }
+
+    public function removeSynonymGroup(int $index): void
+    {
+        unset($this->synonymGroups[$index]);
+        $this->synonymGroups = array_values($this->synonymGroups);
+    }
+
+    public function save(): void
+    {
+        $this->authorize('update', $this->scopedStore());
+
+        $this->validate([
+            'synonymGroups' => ['array', 'max:50'],
+            'synonymGroups.*' => ['nullable', 'string', 'max:500'],
+            'stopWords' => ['nullable', 'string', 'max:2000'],
+        ], [], [
+            'synonymGroups.*' => 'synonym group',
+            'stopWords' => 'stop words',
+        ]);
+
+        $settings = $this->settings();
+        $settings->forceFill([
+            'synonyms_json' => $this->parseSynonyms(),
+            'stop_words_json' => $this->parseWordList($this->stopWords),
+        ])->save();
+
+        $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString();
+
+        session()->flash('status', 'Search settings saved');
+        $this->dispatch('toast', type: 'success', message: __('Search settings saved'));
+    }
+
+    public function triggerReindex(SearchService $search): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $this->isReindexing = true;
+        $this->reindexProgress = 10;
+
+        $count = $search->reindex($store);
+
+        $settings = $this->settings();
+        $settings->touch();
+
+        $this->lastIndexedAt = $settings->refresh()->updated_at?->toDayDateTimeString();
+        $this->reindexProgress = 100;
+        $this->isReindexing = false;
+
+        session()->flash('status', __('Search index rebuilt for :count products', ['count' => $count]));
+        $this->dispatch('toast', type: 'success', message: __('Search index rebuilt'));
+    }
+
+    public function pollReindexStatus(): void
+    {
+        $this->reindexProgress = $this->isReindexing ? $this->reindexProgress : null;
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.search.settings')->layout('layouts.app', [
+            'title' => __('Search settings'),
+        ]);
+    }
+
+    /**
+     * @return list<list<string>>
+     */
+    private function parseSynonyms(): array
+    {
+        return collect($this->synonymGroups)
+            ->map(fn (string $group): array => $this->parseWordList($group))
+            ->filter(fn (array $group): bool => count($group) >= 2)
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function parseWordList(string $words): array
+    {
+        return collect(explode(',', mb_strtolower($words)))
+            ->map(fn (string $word): string => trim($word))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    private function settings(): SearchSettings
+    {
+        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
+            'store_id' => $this->storeId ?? $this->store()->getKey(),
+        ]);
+    }
+
+    private function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
+    private function scopedStore(): Store
+    {
+        return Store::query()->findOrFail($this->storeId);
     }
 }
*** Update File: resources/views/livewire/admin/search/settings.blade.php
@@
-

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

+

+

+            <flux:heading size="xl">Search settings</flux:heading>
+            <flux:text class="mt-2">Tune storefront search behavior and rebuild the local SQLite index.</flux:text>
+

+
+        <flux:button wire:click="triggerReindex" icon="arrow-path" variant="ghost">
+            Reindex now
+        </flux:button>
+

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

+

+                <flux:heading size="lg">Synonyms</flux:heading>
+                <flux:text>Group equivalent search terms with comma-separated words.</flux:text>
+

+
+

+                @foreach ($synonymGroups as $index => $group)
+

+                        <flux:input wire:model="synonymGroups.{{ $index }}" placeholder="t-shirt, tee, tshirt" aria-label="Synonym group {{ $index + 1 }}" />
+                        <flux:button wire:click="removeSynonymGroup({{ $index }})" type="button" icon="trash" variant="ghost" aria-label="Remove synonym group {{ $index + 1 }}" />
+

+                    <flux:error name="synonymGroups.{{ $index }}" />
+                @endforeach
+

+
+            <flux:button wire:click="addSynonymGroup" type="button" icon="plus" variant="ghost">
+                Add synonym group
+            </flux:button>
+

+
+        <flux:separator />
+
+

+

+                <flux:heading size="lg">Stop words</flux:heading>
+                <flux:text>Separate excluded words with commas.</flux:text>
+

+
+            <flux:textarea wire:model="stopWords" rows="5" placeholder="the, a, an, is, are" />
+            <flux:error name="stopWords" />
+

+
+        <flux:separator />
+
+

+

+

+                    <flux:heading size="lg">Search index</flux:heading>
+                    <flux:text>Last indexed: {{ $lastIndexedAt ?? 'Never' }}</flux:text>
+

+
+                @if ($reindexProgress !== null)
+                    <flux:badge color="blue">{{ $reindexProgress }}% complete</flux:badge>
+                @endif
+

+

+
+

+            <flux:button type="submit" variant="primary">
+                Save
+            </flux:button>
+

+    </form>

*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Search/Settings.php
M resources/views/livewire/admin/search/settings.blade.php

@@ -1,3 +1,74 @@
-

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

+

+

+            <flux:heading size="xl">Search settings</flux:heading>
+            <flux:text class="mt-2">Tune storefront search behavior and rebuild the local SQLite index.</flux:text>
+

+
+        <flux:button wire:click="triggerReindex" icon="arrow-path" variant="ghost">
+            Reindex now
+        </flux:button>
+

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

+

+                <flux:heading size="lg">Synonyms</flux:heading>
+                <flux:text>Group equivalent search terms with comma-separated words.</flux:text>
+

+
+

+                @foreach ($synonymGroups as $index => $group)
+

+                        <flux:input wire:model="synonymGroups.{{ $index }}" placeholder="t-shirt, tee, tshirt" aria-label="Synonym group {{ $index + 1 }}" />
+                        <flux:button wire:click="removeSynonymGroup({{ $index }})" type="button" icon="trash" variant="ghost" aria-label="Remove synonym group {{ $index + 1 }}" />
+

+                    <flux:error name="synonymGroups.{{ $index }}" />
+                @endforeach
+

+
+            <flux:button wire:click="addSynonymGroup" type="button" icon="plus" variant="ghost">
+                Add synonym group
+            </flux:button>
+

+
+        <flux:separator />
+
+

+

+                <flux:heading size="lg">Stop words</flux:heading>
+                <flux:text>Separate excluded words with commas.</flux:text>
+

+
+            <flux:textarea wire:model="stopWords" rows="5" placeholder="the, a, an, is, are" />
+            <flux:error name="stopWords" />
+

+
+        <flux:separator />
+
+

+

+

+                    <flux:heading size="lg">Search index</flux:heading>
+                    <flux:text>Last indexed: {{ $lastIndexedAt ?? 'Never' }}</flux:text>
+

+
+                @if ($reindexProgress !== null)
+                    <flux:badge color="blue">{{ $reindexProgress }}% complete</flux:badge>
+                @endif
+

+

+
+

+            <flux:button type="submit" variant="primary">
+                Save
+            </flux:button>
+

+    </form>

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

+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
 use Livewire\Component;
@@ -8,5 +13,151 @@
 {
-    public function render()
+    use AuthorizesRequests;
+
+    #[Locked]
+    public int $storeId;
+
+    /**
+     * @var array<int, string>
+     */
+    public array $synonymGroups = [];
+
+    public string $stopWords = '';
+
+    public ?string $lastIndexedAt = null;
+
+    public bool $isReindexing = false;
+
+    public ?int $reindexProgress = null;
+
+    public function mount(): void
+    {
+        $store = $this->store();
+
+        $this->authorize('update', $store);
+
+        $this->storeId = $store->getKey();
+
+        $settings = $this->settings();
+        $this->synonymGroups = collect($settings->synonyms_json ?? [])
+            ->map(fn (array $group): string => implode(', ', $group))
+            ->values()
+            ->all();
+        $this->stopWords = implode(', ', $settings->stop_words_json ?? []);
+        $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString();
+    }
+
+    public function addSynonymGroup(): void
+    {
+        $this->synonymGroups[] = '';
+    }
+
+    public function removeSynonymGroup(int $index): void
+    {
+        unset($this->synonymGroups[$index]);
+        $this->synonymGroups = array_values($this->synonymGroups);
+    }
+
+    public function save(): void
     {
-        return view('livewire.admin.search.settings');
+        $this->authorize('update', $this->scopedStore());
+
+        $this->validate([
+            'synonymGroups' => ['array', 'max:50'],
+            'synonymGroups.*' => ['nullable', 'string', 'max:500'],
+            'stopWords' => ['nullable', 'string', 'max:2000'],
+        ], [], [
+            'synonymGroups.*' => 'synonym group',
+            'stopWords' => 'stop words',
+        ]);
+
+        $settings = $this->settings();
+        $settings->forceFill([
+            'synonyms_json' => $this->parseSynonyms(),
+            'stop_words_json' => $this->parseWordList($this->stopWords),
+        ])->save();
+
+        $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString();
+
+        session()->flash('status', 'Search settings saved');
+        $this->dispatch('toast', type: 'success', message: __('Search settings saved'));
+    }
+
+    public function triggerReindex(SearchService $search): void
+    {
+        $store = $this->scopedStore();
+
+        $this->authorize('update', $store);
+
+        $this->isReindexing = true;
+        $this->reindexProgress = 10;
+
+        $count = $search->reindex($store);
+
+        $settings = $this->settings();
+        $settings->touch();
+
+        $this->lastIndexedAt = $settings->refresh()->updated_at?->toDayDateTimeString();
+        $this->reindexProgress = 100;
+        $this->isReindexing = false;
+
+        session()->flash('status', __('Search index rebuilt for :count products', ['count' => $count]));
+        $this->dispatch('toast', type: 'success', message: __('Search index rebuilt'));
+    }
+
+    public function pollReindexStatus(): void
+    {
+        $this->reindexProgress = $this->isReindexing ? $this->reindexProgress : null;
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.search.settings')->layout('layouts.app', [
+            'title' => __('Search settings'),
+        ]);
+    }
+
+    /**
+     * @return list<list<string>>
+     */
+    private function parseSynonyms(): array
+    {
+        return collect($this->synonymGroups)
+            ->map(fn (string $group): array => $this->parseWordList($group))
+            ->filter(fn (array $group): bool => count($group) >= 2)
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function parseWordList(string $words): array
+    {
+        return collect(explode(',', mb_strtolower($words)))
+            ->map(fn (string $word): string => trim($word))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    private function settings(): SearchSettings
+    {
+        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
+            'store_id' => $this->storeId ?? $this->store()->getKey(),
+        ]);
+    }
+
+    private function store(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
+
+    private function scopedStore(): Store
+    {
+        return Store::query()->findOrFail($this->storeId);
     }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Search/Settings.php\nM resources/views/livewire/admin/search/settings.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


sed -n '1,120p' resources/views/livewire/admin/settings/index.blade.php


sed -n '1,120p' resources/views/livewire/admin/settings/index.blade.php

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

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

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

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

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

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

            <flux:separator />

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

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

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

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

            <flux:separator />

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

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

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

    </form>

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

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

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

                        Hostname
                        Type
                        Primary
                        TLS
                        Actions

                    @foreach ($domains as $domain)

getKey() }}">


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

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

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

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

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

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

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

            <flux:separator />

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

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

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

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

            <flux:separator />

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

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

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

    </form>

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

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

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

                        Hostname
                        Type
                        Primary
                        TLS
                        Actions

                    @foreach ($domains as $domain)

getKey() }}">


rg -n "flux:badge|flux:callout" resources/views/livewire/admin | head -50


rg -n "flux:badge|flux:callout" resources/views/livewire/admin | head -50

rg -n 'flux:badge|flux:callout' resources/views/livewire/admin

Question: flux:badge|flux:callout

resources/views/livewire/admin/themes/editor.blade.php:12:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/themes/index.blade.php:8:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/themes/index.blade.php:28:                        <flux:badge :color="$this->statusColor($theme)">{{ Str::headline($theme->status->value) }}</flux:badge>
resources/views/livewire/admin/pages/index.blade.php:38:                                <flux:badge :color="$this->statusColor($page->status)">{{ Str::headline($page->status->value) }}</flux:badge>
resources/views/livewire/admin/pages/form.blade.php:17:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/discounts/index.blade.php:60:                                <flux:badge :color="$typeColor">{{ Str::headline($discount->type->value) }}</flux:badge>
resources/views/livewire/admin/discounts/index.blade.php:65:                                <flux:badge :color="$statusColor">{{ Str::headline($status) }}</flux:badge>
resources/views/livewire/admin/search/settings.blade.php:14:        <flux:callout variant="success" icon="check-circle">
resources/views/livewire/admin/search/settings.blade.php:16:        </flux:callout>
resources/views/livewire/admin/search/settings.blade.php:63:                    <flux:badge color="blue">{{ $reindexProgress }}% complete</flux:badge>
resources/views/livewire/admin/discounts/form.blade.php:14:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/products/index.blade.php:91:                                <flux:badge :color="$statusColor">{{ Str::title($product->status->value) }}</flux:badge>
resources/views/livewire/admin/navigation/index.blade.php:8:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/inventory/index.blade.php:43:                                <flux:badge :color="$item->policy->value === 'continue' ? 'blue' : 'zinc'">{{ Str::title($item->policy->value) }}</flux:badge>
resources/views/livewire/admin/orders/index.blade.php:77:                                <flux:badge :color="$financialColor">{{ Str::headline($order->financial_status->value) }}</flux:badge>
resources/views/livewire/admin/orders/index.blade.php:80:                                <flux:badge :color="$fulfillmentColor">{{ Str::headline($order->fulfillment_status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:55:                <flux:badge>{{ Str::headline($order->status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:61:                <flux:badge :color="$order->financial_status->value === 'paid' ? 'green' : ($order->financial_status->value === 'pending' ? 'amber' : 'zinc')">
resources/views/livewire/admin/orders/show.blade.php:63:                </flux:badge>
resources/views/livewire/admin/orders/show.blade.php:69:                <flux:badge :color="$order->fulfillment_status->value === 'fulfilled' ? 'green' : ($order->fulfillment_status->value === 'partial' ? 'amber' : 'zinc')">
resources/views/livewire/admin/orders/show.blade.php:71:                </flux:badge>
resources/views/livewire/admin/orders/show.blade.php:114:                                <flux:badge>{{ Str::headline($payment->status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:131:                                    <flux:badge>{{ Str::headline($fulfillment->status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:209:                            <flux:badge>{{ Str::headline($refund->status->value) }}</flux:badge>
resources/views/livewire/admin/products/form.blade.php:19:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/settings/shipping.blade.php:16:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/settings/shipping.blade.php:50:                                        <flux:badge>{{ Str::headline($rate->type->value) }}</flux:badge>
resources/views/livewire/admin/customers/show.blade.php:34:                            <flux:badge :color="$customer->marketing_opt_in ? 'green' : 'zinc'">
resources/views/livewire/admin/customers/show.blade.php:36:                            </flux:badge>
resources/views/livewire/admin/customers/show.blade.php:76:                                        <flux:badge :color="$statusColor">{{ Str::headline($order->financial_status->value) }}</flux:badge>
resources/views/livewire/admin/customers/show.blade.php:111:                                        <flux:badge color="green">Default</flux:badge>
resources/views/livewire/admin/settings/index.blade.php:16:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/settings/index.blade.php:122:                            <flux:badge>{{ Str::headline($domain->type->value) }}</flux:badge>
resources/views/livewire/admin/settings/index.blade.php:125:                                    <flux:badge color="green">Primary</flux:badge>
resources/views/livewire/admin/settings/taxes.blade.php:16:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/collections/form.blade.php:13:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/collections/index.blade.php:54:                            <flux:badge :color="$statusColor">{{ Str::title($collection->status->value) }}</flux:badge>


Output:
Chunk ID: 45a6c5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1494
Output:
resources/views/livewire/admin/themes/editor.blade.php:12:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/themes/index.blade.php:8:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/themes/index.blade.php:28:                        <flux:badge :color="$this->statusColor($theme)">{{ Str::headline($theme->status->value) }}</flux:badge>
resources/views/livewire/admin/pages/index.blade.php:38:                                <flux:badge :color="$this->statusColor($page->status)">{{ Str::headline($page->status->value) }}</flux:badge>
resources/views/livewire/admin/pages/form.blade.php:17:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/discounts/index.blade.php:60:                                <flux:badge :color="$typeColor">{{ Str::headline($discount->type->value) }}</flux:badge>
resources/views/livewire/admin/discounts/index.blade.php:65:                                <flux:badge :color="$statusColor">{{ Str::headline($status) }}</flux:badge>
resources/views/livewire/admin/search/settings.blade.php:14:        <flux:callout variant="success" icon="check-circle">
resources/views/livewire/admin/search/settings.blade.php:16:        </flux:callout>
resources/views/livewire/admin/search/settings.blade.php:63:                    <flux:badge color="blue">{{ $reindexProgress }}% complete</flux:badge>
resources/views/livewire/admin/discounts/form.blade.php:14:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/products/index.blade.php:91:                                <flux:badge :color="$statusColor">{{ Str::title($product->status->value) }}</flux:badge>
resources/views/livewire/admin/navigation/index.blade.php:8:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/inventory/index.blade.php:43:                                <flux:badge :color="$item->policy->value === 'continue' ? 'blue' : 'zinc'">{{ Str::title($item->policy->value) }}</flux:badge>
resources/views/livewire/admin/orders/index.blade.php:77:                                <flux:badge :color="$financialColor">{{ Str::headline($order->financial_status->value) }}</flux:badge>
resources/views/livewire/admin/orders/index.blade.php:80:                                <flux:badge :color="$fulfillmentColor">{{ Str::headline($order->fulfillment_status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:55:                <flux:badge>{{ Str::headline($order->status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:61:                <flux:badge :color="$order->financial_status->value === 'paid' ? 'green' : ($order->financial_status->value === 'pending' ? 'amber' : 'zinc')">
resources/views/livewire/admin/orders/show.blade.php:63:                </flux:badge>
resources/views/livewire/admin/orders/show.blade.php:69:                <flux:badge :color="$order->fulfillment_status->value === 'fulfilled' ? 'green' : ($order->fulfillment_status->value === 'partial' ? 'amber' : 'zinc')">
resources/views/livewire/admin/orders/show.blade.php:71:                </flux:badge>
resources/views/livewire/admin/orders/show.blade.php:114:                                <flux:badge>{{ Str::headline($payment->status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:131:                                    <flux:badge>{{ Str::headline($fulfillment->status->value) }}</flux:badge>
resources/views/livewire/admin/orders/show.blade.php:209:                            <flux:badge>{{ Str::headline($refund->status->value) }}</flux:badge>
resources/views/livewire/admin/products/form.blade.php:19:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/settings/shipping.blade.php:16:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/settings/shipping.blade.php:50:                                        <flux:badge>{{ Str::headline($rate->type->value) }}</flux:badge>
resources/views/livewire/admin/customers/show.blade.php:34:                            <flux:badge :color="$customer->marketing_opt_in ? 'green' : 'zinc'">
resources/views/livewire/admin/customers/show.blade.php:36:                            </flux:badge>
resources/views/livewire/admin/customers/show.blade.php:76:                                        <flux:badge :color="$statusColor">{{ Str::headline($order->financial_status->value) }}</flux:badge>
resources/views/livewire/admin/customers/show.blade.php:111:                                        <flux:badge color="green">Default</flux:badge>
resources/views/livewire/admin/settings/index.blade.php:16:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/settings/index.blade.php:122:                            <flux:badge>{{ Str::headline($domain->type->value) }}</flux:badge>
resources/views/livewire/admin/settings/index.blade.php:125:                                    <flux:badge color="green">Primary</flux:badge>
resources/views/livewire/admin/settings/taxes.blade.php:16:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/collections/form.blade.php:13:        <flux:callout color="green" icon="check-circle">{{ session('status') }}</flux:callout>
resources/views/livewire/admin/collections/index.blade.php:54:                            <flux:badge :color="$statusColor">{{ Str::title($collection->status->value) }}</flux:badge>


Input:
*** Begin Patch
*** Update File: resources/views/livewire/admin/search/settings.blade.php
@@
-        <flux:callout variant="success" icon="check-circle">
+        <flux:callout color="green" icon="check-circle">
*** End Patch


Success. Updated the following files:
M resources/views/livewire/admin/search/settings.blade.php

@@ -13,3 +13,3 @@
     @if (session('status'))
-        <flux:callout variant="success" icon="check-circle">
+        <flux:callout color="green" icon="check-circle">
             {{ session('status') }}


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


/bin/zsh
-lc
sed -n '1,220p' tests/Pest.php

<?php

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
 // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}


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

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
 // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}


Input:
*** Begin Patch
*** Update File: tests/Feature/Search/SearchServiceTest.php
@@
-<?php
-
-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+<?php
+
+use App\Models\Product;
+use App\Models\SearchQuery;
+use App\Models\Store;
+use App\Services\SearchService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function searchServiceStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+test('search service returns matching products scoped to the store and logs the query', function (): void {
+    $store = searchServiceStore();
+    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();
+
+    Product::factory()
+        ->for($store)
+        ->withDefaultVariant(1999)
+        ->create([
+            'title' => 'Blue Viscose Search Shirt',
+            'handle' => 'blue-viscose-search-shirt',
+            'vendor' => 'Search Vendor',
+            'tags' => ['viscose', 'blue'],
+        ]);
+
+    Product::factory()
+        ->for($otherStore)
+        ->withDefaultVariant(1999)
+        ->create([
+            'title' => 'Blue Viscose Search Shirt Deluxe',
+            'handle' => 'blue-viscose-search-shirt-deluxe',
+            'vendor' => 'Other Vendor',
+            'tags' => ['viscose'],
+        ]);
+
+    $results = app(SearchService::class)->search($store, 'viscose search', [], 12);
+
+    expect($results->total())->toBe(1)
+        ->and($results->getCollection()->first()->title)->toBe('Blue Viscose Search Shirt');
+
+    $queryLog = SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->latest('id')->firstOrFail();
+
+    expect($queryLog->query)->toBe('viscose search')
+        ->and($queryLog->results_count)->toBe(1);
+});
+
+test('search service keeps the FTS index synchronized through product observer events', function (): void {
+    $store = searchServiceStore();
+
+    $product = Product::factory()
+        ->for($store)
+        ->withDefaultVariant(1599)
+        ->create([
+            'title' => 'Copper Index Sync Jacket',
+            'handle' => 'copper-index-sync-jacket',
+        ]);
+
+    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1);
+
+    $product->update(['title' => 'Graphite Index Sync Jacket']);
+
+    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0)
+        ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1);
+
+    $product->delete();
+
+    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
+});
+
+test('search service paginates unique matches', function (): void {
+    $store = searchServiceStore();
+
+    foreach (range(1, 25) as $index) {
+        Product::factory()
+            ->for($store)
+            ->withDefaultVariant(1000 + $index)
+            ->create([
+                'title' => "Pagination Merino Search {$index}",
+                'handle' => "pagination-merino-search-{$index}",
+            ]);
+    }
+
+    request()->query->set('page', 3);
+
+    $results = app(SearchService::class)->search($store, 'pagination merino', [], 12);
+
+    expect($results->total())->toBe(25)
+        ->and($results->currentPage())->toBe(3)
+        ->and($results->getCollection())->toHaveCount(1);
+});
*** Update File: tests/Feature/Api/StorefrontSearchApiTest.php
@@
-<?php
-
-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+<?php
+
+use App\Models\Collection as ProductCollection;
+use App\Models\Product;
+use App\Models\SearchQuery;
+use App\Models\Store;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function storefrontSearchApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+test('storefront search api returns store scoped product results with facets', function (): void {
+    $store = storefrontSearchApiStore();
+    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();
+
+    Product::factory()
+        ->for($store)
+        ->withDefaultVariant(2199)
+        ->create([
+            'title' => 'Cloud Cotton Search Tee',
+            'handle' => 'cloud-cotton-search-tee',
+            'vendor' => 'Search Vendor',
+            'product_type' => 'T-Shirts',
+            'tags' => ['cotton', 'cloud'],
+        ]);
+
+    Product::factory()
+        ->for($otherStore)
+        ->withDefaultVariant(2199)
+        ->create([
+            'title' => 'Cloud Cotton Search Tee Other Store',
+            'handle' => 'cloud-cotton-search-tee-other-store',
+            'vendor' => 'Other Vendor',
+            'product_type' => 'T-Shirts',
+            'tags' => ['cotton'],
+        ]);
+
+    $filters = urlencode(json_encode([
+        'vendor' => 'Search Vendor',
+        'price_min' => 1500,
+        'price_max' => 2500,
+        'in_stock' => true,
+    ], JSON_THROW_ON_ERROR));
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson("/api/storefront/v1/search?q=cloud%20cotton&filters={$filters}&sort=price_asc")
+        ->assertOk()
+        ->assertJsonPath('query', 'cloud cotton')
+        ->assertJsonPath('pagination.total', 1)
+        ->assertJsonPath('results.0.title', 'Cloud Cotton Search Tee')
+        ->assertJsonPath('results.0.price_amount', 2199)
+        ->assertJsonPath('facets.vendors.0.value', 'Search Vendor')
+        ->assertJsonPath('facets.price_range.min', 2199);
+
+    expect(SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->where('query', 'cloud cotton')->exists())->toBeTrue();
+});
+
+test('storefront search api returns empty results for no matches and validates input', function (): void {
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search?q=xyznonexistent')
+        ->assertOk()
+        ->assertJsonPath('pagination.total', 0)
+        ->assertJsonPath('results', []);
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search')
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors('q');
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search?q=test&filters=not-json')
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors('filters');
+});
+
+test('storefront search api paginates results', function (): void {
+    $store = storefrontSearchApiStore();
+
+    foreach (range(1, 25) as $index) {
+        Product::factory()
+            ->for($store)
+            ->withDefaultVariant(1200 + $index)
+            ->create([
+                'title' => "Api Pagination Linen Search {$index}",
+                'handle' => "api-pagination-linen-search-{$index}",
+            ]);
+    }
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search?q=api%20pagination%20linen&per_page=12&page=2')
+        ->assertOk()
+        ->assertJsonPath('pagination.total', 25)
+        ->assertJsonPath('pagination.current_page', 2)
+        ->assertJsonPath('pagination.last_page', 3)
+        ->assertJsonCount(12, 'results');
+});
+
+test('storefront search suggest api returns product and collection suggestions', function (): void {
+    $store = storefrontSearchApiStore();
+
+    Product::factory()
+        ->for($store)
+        ->withDefaultVariant(1899)
+        ->create([
+            'title' => 'Alpaca Suggest Search Jacket',
+            'handle' => 'alpaca-suggest-search-jacket',
+        ]);
+
+    ProductCollection::factory()->for($store)->create([
+        'title' => 'Alpaca Search Collection',
+        'handle' => 'alpaca-search-collection',
+        'status' => 'active',
+    ]);
+
+    $response = $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search/suggest?q=alpaca&limit=5')
+        ->assertOk()
+        ->assertJsonPath('query', 'alpaca');
+
+    expect(collect($response['suggestions'])->pluck('type')->all())->toContain('product', 'collection');
+});
*** Update File: tests/Feature/Admin/SearchSettingsTest.php
@@
-<?php
-
-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+<?php
+
+use App\Livewire\Admin\Search\Settings as SearchSettingsComponent;
+use App\Models\Product;
+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\SearchService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Livewire\Livewire;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminSearchSettingsStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminSearchSettingsUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+test('admin search settings route renders for store admins', function (): void {
+    $this->actingAs(adminSearchSettingsUser())
+        ->get('/admin/search/settings')
+        ->assertSuccessful()
+        ->assertSee('Search settings')
+        ->assertSee('Synonyms');
+});
+
+test('admin search settings saves synonyms and stop words', function (): void {
+    $store = adminSearchSettingsStore();
+    app()->instance('current_store', $store);
+
+    Livewire::actingAs(adminSearchSettingsUser())
+        ->test(SearchSettingsComponent::class)
+        ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt'])
+        ->set('stopWords', 'the, and, for')
+        ->call('save')
+        ->assertSee('Search settings saved');
+
+    $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
+
+    expect($settings->synonyms_json)->toBe([
+        ['hoodie', 'sweatshirt', 'pullover'],
+        ['tee', 't-shirt'],
+    ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']);
+});
+
+test('admin search settings can rebuild the search index', function (): void {
+    $store = adminSearchSettingsStore();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()
+        ->for($store)
+        ->withDefaultVariant(2999)
+        ->create([
+            'title' => 'Reindex Search Jacket',
+            'handle' => 'reindex-search-jacket',
+        ]);
+
+    DB::table('products_fts')->where('product_id', $product->getKey())->delete();
+
+    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0);
+
+    Livewire::actingAs(adminSearchSettingsUser())
+        ->test(SearchSettingsComponent::class)
+        ->call('triggerReindex')
+        ->assertSee('Search index rebuilt');
+
+    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
+});
*** End Patch


Success. Updated the following files:
M tests/Feature/Search/SearchServiceTest.php
M tests/Feature/Api/StorefrontSearchApiTest.php
M tests/Feature/Admin/SearchSettingsTest.php

@@ -2,6 +2,130 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Collection as ProductCollection;
+use App\Models\Product;
+use App\Models\SearchQuery;
+use App\Models\Store;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function storefrontSearchApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+test('storefront search api returns store scoped product results with facets', function (): void {
+    $store = storefrontSearchApiStore();
+    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();
+
+    Product::factory()
+        ->for($store)
+        ->withDefaultVariant(2199)
+        ->create([
+            'title' => 'Cloud Cotton Search Tee',
+            'handle' => 'cloud-cotton-search-tee',
+            'vendor' => 'Search Vendor',
+            'product_type' => 'T-Shirts',
+            'tags' => ['cotton', 'cloud'],
+        ]);
+
+    Product::factory()
+        ->for($otherStore)
+        ->withDefaultVariant(2199)
+        ->create([
+            'title' => 'Cloud Cotton Search Tee Other Store',
+            'handle' => 'cloud-cotton-search-tee-other-store',
+            'vendor' => 'Other Vendor',
+            'product_type' => 'T-Shirts',
+            'tags' => ['cotton'],
+        ]);
+
+    $filters = urlencode(json_encode([
+        'vendor' => 'Search Vendor',
+        'price_min' => 1500,
+        'price_max' => 2500,
+        'in_stock' => true,
+    ], JSON_THROW_ON_ERROR));
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson("/api/storefront/v1/search?q=cloud%20cotton&filters={$filters}&sort=price_asc")
+        ->assertOk()
+        ->assertJsonPath('query', 'cloud cotton')
+        ->assertJsonPath('pagination.total', 1)
+        ->assertJsonPath('results.0.title', 'Cloud Cotton Search Tee')
+        ->assertJsonPath('results.0.price_amount', 2199)
+        ->assertJsonPath('facets.vendors.0.value', 'Search Vendor')
+        ->assertJsonPath('facets.price_range.min', 2199);
+
+    expect(SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->where('query', 'cloud cotton')->exists())->toBeTrue();
+});
+
+test('storefront search api returns empty results for no matches and validates input', function (): void {
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search?q=xyznonexistent')
+        ->assertOk()
+        ->assertJsonPath('pagination.total', 0)
+        ->assertJsonPath('results', []);

-    $response->assertStatus(200);
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search')
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors('q');
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search?q=test&filters=not-json')
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors('filters');
+});
+
+test('storefront search api paginates results', function (): void {
+    $store = storefrontSearchApiStore();
+
+    foreach (range(1, 25) as $index) {
+        Product::factory()
+            ->for($store)
+            ->withDefaultVariant(1200 + $index)
+            ->create([
+                'title' => "Api Pagination Linen Search {$index}",
+                'handle' => "api-pagination-linen-search-{$index}",
+            ]);
+    }
+
+    $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search?q=api%20pagination%20linen&per_page=12&page=2')
+        ->assertOk()
+        ->assertJsonPath('pagination.total', 25)
+        ->assertJsonPath('pagination.current_page', 2)
+        ->assertJsonPath('pagination.last_page', 3)
+        ->assertJsonCount(12, 'results');
+});
+
+test('storefront search suggest api returns product and collection suggestions', function (): void {
+    $store = storefrontSearchApiStore();
+
+    Product::factory()
+        ->for($store)
+        ->withDefaultVariant(1899)
+        ->create([
+            'title' => 'Alpaca Suggest Search Jacket',
+            'handle' => 'alpaca-suggest-search-jacket',
+        ]);
+
+    ProductCollection::factory()->for($store)->create([
+        'title' => 'Alpaca Search Collection',
+        'handle' => 'alpaca-search-collection',
+        'status' => 'active',
+    ]);
+
+    $response = $this->withHeader('Host', 'shop.test')
+        ->getJson('/api/storefront/v1/search/suggest?q=alpaca&limit=5')
+        ->assertOk()
+        ->assertJsonPath('query', 'alpaca');
+
+    expect(collect($response['suggestions'])->pluck('type')->all())->toContain('product', 'collection');
 });

@@ -2,6 +2,100 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Product;
+use App\Models\SearchQuery;
+use App\Models\Store;
+use App\Services\SearchService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function searchServiceStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+test('search service returns matching products scoped to the store and logs the query', function (): void {
+    $store = searchServiceStore();
+    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();
+
+    Product::factory()
+        ->for($store)
+        ->withDefaultVariant(1999)
+        ->create([
+            'title' => 'Blue Viscose Search Shirt',
+            'handle' => 'blue-viscose-search-shirt',
+            'vendor' => 'Search Vendor',
+            'tags' => ['viscose', 'blue'],
+        ]);
+
+    Product::factory()
+        ->for($otherStore)
+        ->withDefaultVariant(1999)
+        ->create([
+            'title' => 'Blue Viscose Search Shirt Deluxe',
+            'handle' => 'blue-viscose-search-shirt-deluxe',
+            'vendor' => 'Other Vendor',
+            'tags' => ['viscose'],
+        ]);
+
+    $results = app(SearchService::class)->search($store, 'viscose search', [], 12);
+
+    expect($results->total())->toBe(1)
+        ->and($results->getCollection()->first()->title)->toBe('Blue Viscose Search Shirt');
+
+    $queryLog = SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->latest('id')->firstOrFail();
+
+    expect($queryLog->query)->toBe('viscose search')
+        ->and($queryLog->results_count)->toBe(1);
+});
+
+test('search service keeps the FTS index synchronized through product observer events', function (): void {
+    $store = searchServiceStore();
+
+    $product = Product::factory()
+        ->for($store)
+        ->withDefaultVariant(1599)
+        ->create([
+            'title' => 'Copper Index Sync Jacket',
+            'handle' => 'copper-index-sync-jacket',
+        ]);
+
+    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1);
+
+    $product->update(['title' => 'Graphite Index Sync Jacket']);
+
+    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0)
+        ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1);
+
+    $product->delete();
+
+    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
+});

-    $response->assertStatus(200);
+test('search service paginates unique matches', function (): void {
+    $store = searchServiceStore();
+
+    foreach (range(1, 25) as $index) {
+        Product::factory()
+            ->for($store)
+            ->withDefaultVariant(1000 + $index)
+            ->create([
+                'title' => "Pagination Merino Search {$index}",
+                'handle' => "pagination-merino-search-{$index}",
+            ]);
+    }
+
+    request()->query->set('page', 3);
+
+    $results = app(SearchService::class)->search($store, 'pagination merino', [], 12);
+
+    expect($results->total())->toBe(25)
+        ->and($results->currentPage())->toBe(3)
+        ->and($results->getCollection())->toHaveCount(1);
 });

@@ -2,6 +2,79 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Livewire\Admin\Search\Settings as SearchSettingsComponent;
+use App\Models\Product;
+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\SearchService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Livewire\Livewire;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminSearchSettingsStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminSearchSettingsUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+test('admin search settings route renders for store admins', function (): void {
+    $this->actingAs(adminSearchSettingsUser())
+        ->get('/admin/search/settings')
+        ->assertSuccessful()
+        ->assertSee('Search settings')
+        ->assertSee('Synonyms');
+});

-    $response->assertStatus(200);
+test('admin search settings saves synonyms and stop words', function (): void {
+    $store = adminSearchSettingsStore();
+    app()->instance('current_store', $store);
+
+    Livewire::actingAs(adminSearchSettingsUser())
+        ->test(SearchSettingsComponent::class)
+        ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt'])
+        ->set('stopWords', 'the, and, for')
+        ->call('save')
+        ->assertSee('Search settings saved');
+
+    $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
+
+    expect($settings->synonyms_json)->toBe([
+        ['hoodie', 'sweatshirt', 'pullover'],
+        ['tee', 't-shirt'],
+    ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']);
+});
+
+test('admin search settings can rebuild the search index', function (): void {
+    $store = adminSearchSettingsStore();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()
+        ->for($store)
+        ->withDefaultVariant(2999)
+        ->create([
+            'title' => 'Reindex Search Jacket',
+            'handle' => 'reindex-search-jacket',
+        ]);
+
+    DB::table('products_fts')->where('product_id', $product->getKey())->delete();
+
+    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0);
+
+    Livewire::actingAs(adminSearchSettingsUser())
+        ->test(SearchSettingsComponent::class)
+        ->call('triggerReindex')
+        ->assertSee('Search index rebuilt');
+
+    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
 });


Output: {"output":"Success. Updated the following files:\nM tests/Feature/Search/SearchServiceTest.php\nM tests/Feature/Api/StorefrontSearchApiTest.php\nM tests/Feature/Admin/SearchSettingsTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


sed -n '1,160p' database/factories/CollectionFactory.php


sed -n '1,160p' database/factories/CollectionFactory.php

<?php

namespace Database\Factories;

use App\Enums\CollectionStatus;
use App\Enums\CollectionType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Collection>
 */
class CollectionFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(2, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'description_html' => '<p>'.fake()->sentence().'</p>',
            'type' => CollectionType::Manual,
            'status' => CollectionStatus::Active,
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => CollectionStatus::Draft,
        ]);
    }

    public function automated(): static
    {
        return $this->state(fn (array $attributes): array => [
            'type' => CollectionType::Automated,
        ]);
    }
}


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

namespace Database\Factories;

use App\Enums\CollectionStatus;
use App\Enums\CollectionType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Collection>
 */
class CollectionFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(2, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'description_html' => '<p>'.fake()->sentence().'</p>',
            'type' => CollectionType::Manual,
            'status' => CollectionStatus::Active,
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => CollectionStatus::Draft,
        ]);
    }

    public function automated(): static
    {
        return $this->state(fn (array $attributes): array => [
            'type' => CollectionType::Automated,
        ]);
    }
}


The search slice now has migrations, models, API plumbing, observer sync, storefront filtering, and admin settings tests wired. I’m running Pint first because this touched many PHP files, then I’ll run the focused search/admin/API tests and fix whatever shakes out.


Output:
Chunk ID: d01eb8
Wall time: 0.6874 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


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


php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php


Output:
Chunk ID: 2e7af9
Wall time: 1.0009 seconds
Process running with session ID 44078
Original token count: 0
Output:


php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php

  ..........

  Tests:    10 passed (46 assertions)
  Duration: 3.00s


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

  ..........

  Tests:    10 passed (46 assertions)
  Duration: 3.00s


The focused search suite is green: 10 tests, 46 assertions. I’m expanding verification to the related storefront/API/admin areas before doing the browser smoke check.


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


php artisan route:list --name=admin.search --except-vendor


php artisan test --compact tests/Feature/Api tests/Feature/Search tests/Feature/Admin/SearchSettingsTest.php tests/Feature/Catalog/CatalogUiTest.php


Output:
Chunk ID: e25e15
Wall time: 0.1708 seconds
Process exited with code 0
Original token count: 62
Output:

  GET|HEAD       api/storefront/v1/search api.storefront.v1.search.index › Ap…
  GET|HEAD       api/storefront/v1/search/suggest api.storefront.v1.search.su…

                                                            Showing [2] routes


Output:
Chunk ID: 173cd9
Wall time: 0.1607 seconds
Process exited with code 0
Original token count: 21
Output:

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


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

  GET|HEAD       api/storefront/v1/search api.storefront.v1.search.index › Ap…
  GET|HEAD       api/storefront/v1/search/suggest api.storefront.v1.search.su…

                                                            Showing [2] routes


php artisan route:list --name=admin.search --except-vendor

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


Output:
Chunk ID: 38d488
Wall time: 1.0030 seconds
Process running with session ID 97674
Original token count: 0
Output:


Output:
Chunk ID: 918128
Wall time: 1.4696 seconds
Process exited with code 0
Original token count: 22
Output:

  .........................

  Tests:    25 passed (201 assertions)
  Duration: 7.43s


php artisan route:list --path=admin/search --except-vendor


Output:
Chunk ID: 34f67f
Wall time: 0.1466 seconds
Process exited with code 0
Original token count: 21
Output:

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


php artisan route:list --path=admin --except-vendor


Output:
Chunk ID: e13d3b
Wall time: 0.0275 seconds
Process exited with code 0
Original token count: 122
Output:

  POST       admin/logout ....................................... admin.logout
  GET|HEAD   api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › A…
  GET|HEAD   api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.s…
  POST       api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admi…
  POST       api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.…

                                                            Showing [5] routes


php artisan route:list --path=admin --except-vendor

  POST       admin/logout ....................................... admin.logout
  GET|HEAD   api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › A…
  GET|HEAD   api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.s…
  POST       api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admi…
  POST       api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.…

                                                            Showing [5] routes


php artisan route:list --path=admin/search