status: completed

input:
*** Begin Patch
*** Update File: app/Observers/ProductObserver.php
@@
 use App\Models\Product;
+use App\Services\SearchService;

 class ProductObserver
 {
-    /**
-     * Handle the Product "created" event.
-     */
     public function created(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "updated" event.
-     */
     public function updated(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "deleted" event.
-     */
     public function deleted(Product $product): void
     {
-        //
-    }
-
-    /**
-     * Handle the Product "restored" event.
-     */
-    public function restored(Product $product): void
-    {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }

-    /**
-     * Handle the Product "force deleted" event.
-     */
     public function forceDeleted(Product $product): void
     {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }
 }
*** Update File: app/Providers/AppServiceProvider.php
@@
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Http\Middleware\ResolveStore;
+use App\Models\Product;
 use App\Models\Store;
+use App\Observers\ProductObserver;
 use App\Services\NavigationService;
 use App\Services\Payments\MockPaymentProvider;
+use App\Services\SearchService;
 use App\Services\ThemeSettingsService;
@@
         $this->app->singleton(ThemeSettingsService::class);
         $this->app->singleton(NavigationService::class);
+        $this->app->singleton(SearchService::class);
@@
         RateLimiter::for('checkout', function (Request $request): Limit {
             $sessionId = $request->hasSession() ? $request->session()->getId() : null;

             return Limit::perMinute(10)->by($sessionId ?: $request->ip());
         });

+        RateLimiter::for('search', function (Request $request): Limit {
+            return Limit::perMinute(30)->by($request->ip());
+        });
+
+        RateLimiter::for('analytics', function (Request $request): Limit {
+            return Limit::perMinute(60)->by($request->ip());
+        });
+
+        Product::observe(ProductObserver::class);
+
         Authenticate::redirectUsing(function (Request $request): string {
*** Update File: routes/api.php
@@
 use App\Http\Controllers\Api\Storefront\V1\CartController;
 use App\Http\Controllers\Api\Storefront\V1\CartLineController;
 use App\Http\Controllers\Api\Storefront\V1\CheckoutController;
 use App\Http\Controllers\Api\Storefront\V1\OrderController as StorefrontOrderController;
+use App\Http\Controllers\Api\Storefront\V1\SearchController as StorefrontSearchController;
 use Illuminate\Support\Facades\Route;
@@
         Route::middleware('throttle:api.storefront')->group(function (): void {
             Route::post('carts', [CartController::class, 'store'])->name('carts.store');
             Route::get('carts/{cart}', [CartController::class, 'show'])->name('carts.show');
             Route::post('carts/{cart}/lines', [CartLineController::class, 'store'])->name('carts.lines.store');
             Route::put('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'update'])->name('carts.lines.update');
             Route::delete('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'destroy'])->name('carts.lines.destroy');
         });
+
+        Route::middleware('throttle:search')->group(function (): void {
+            Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index');
+            Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest');
+        });
*** Update File: routes/web.php
@@
 use App\Livewire\Admin\Pages\Form as AdminPageForm;
 use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
 use App\Livewire\Admin\Products\Form as AdminProductForm;
 use App\Livewire\Admin\Products\Index as AdminProductsIndex;
+use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
 use App\Livewire\Admin\Settings\Index as AdminSettingsIndex;
@@
     Route::livewire('settings', AdminSettingsIndex::class)->name('settings.index');
     Route::livewire('settings/shipping', AdminSettingsShipping::class)->name('settings.shipping');
     Route::livewire('settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes');
+    Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings');
     Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');
*** Update File: resources/views/layouts/app/sidebar.blade.php
@@
                 <flux:sidebar.group :heading="__('Configuration')" class="grid">
                     <flux:sidebar.item icon="cog-6-tooth" :href="route('admin.settings.index')" :current="request()->routeIs('admin.settings.*')" wire:navigate>
                         {{ __('Settings') }}
                     </flux:sidebar.item>
+
+                    <flux:sidebar.item icon="magnifying-glass" :href="route('admin.search.settings')" :current="request()->routeIs('admin.search.*')" wire:navigate>
+                        {{ __('Search') }}
+                    </flux:sidebar.item>
                 </flux:sidebar.group>
*** End Patch


stdout:
Success. Updated the following files:
M app/Observers/ProductObserver.php
M app/Providers/AppServiceProvider.php
M routes/api.php
M routes/web.php
M resources/views/layouts/app/sidebar.blade.php

type: update

unified_diff:
@@ -9,2 +9,3 @@
 use App\Http\Controllers\Api\Storefront\V1\OrderController as StorefrontOrderController;
+use App\Http\Controllers\Api\Storefront\V1\SearchController as StorefrontSearchController;
 use Illuminate\Support\Facades\Route;
@@ -23,2 +24,7 @@

+        Route::middleware('throttle:search')->group(function (): void {
+            Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index');
+            Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest');
+        });
+
         Route::middleware('throttle:checkout')->group(function (): void {

unified_diff:
@@ -9,5 +9,8 @@
 use App\Http\Middleware\ResolveStore;
+use App\Models\Product;
 use App\Models\Store;
+use App\Observers\ProductObserver;
 use App\Services\NavigationService;
 use App\Services\Payments\MockPaymentProvider;
+use App\Services\SearchService;
 use App\Services\ThemeSettingsService;
@@ -42,2 +45,3 @@
         $this->app->singleton(NavigationService::class);
+        $this->app->singleton(SearchService::class);
     }
@@ -85,2 +89,12 @@

+        RateLimiter::for('search', function (Request $request): Limit {
+            return Limit::perMinute(30)->by($request->ip());
+        });
+
+        RateLimiter::for('analytics', function (Request $request): Limit {
+            return Limit::perMinute(60)->by($request->ip());
+        });
+
+        Product::observe(ProductObserver::class);
+
         Authenticate::redirectUsing(function (Request $request): string {

unified_diff:
@@ -19,2 +19,3 @@
 use App\Livewire\Admin\Products\Index as AdminProductsIndex;
+use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
 use App\Livewire\Admin\Settings\Index as AdminSettingsIndex;
@@ -87,2 +88,3 @@
     Route::livewire('settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes');
+    Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings');
     Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');

unified_diff:
@@ -5,2 +5,3 @@
 use App\Models\Product;
+use App\Services\SearchService;

@@ -8,40 +9,20 @@
 {
-    /**
-     * Handle the Product "created" event.
-     */
     public function created(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "updated" event.
-     */
     public function updated(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "deleted" event.
-     */
     public function deleted(Product $product): void
     {
-        //
-    }
-
-    /**
-     * Handle the Product "restored" event.
-     */
-    public function restored(Product $product): void
-    {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }

-    /**
-     * Handle the Product "force deleted" event.
-     */
     public function forceDeleted(Product $product): void
     {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }

unified_diff:
@@ -65,2 +65,6 @@
                     </flux:sidebar.item>
+
+                    <flux:sidebar.item icon="magnifying-glass" :href="route('admin.search.settings')" :current="request()->routeIs('admin.search.*')" wire:navigate>
+                        {{ __('Search') }}
+                    </flux:sidebar.item>
                 </flux:sidebar.group>

status: completed


output: {"output":"Success. Updated the following files:\nM app/Observers/ProductObserver.php\nM app/Providers/AppServiceProvider.php\nM routes/api.php\nM routes/web.php\nM resources/views/layouts/app/sidebar.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php
@@
 namespace App\Http\Requests\Api\Storefront\V1;

 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;

 class SearchProductsRequest extends FormRequest
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }

     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:200'],
+            'filters' => ['nullable', 'array'],
+            'filters.collection_id' => ['nullable', 'integer', 'min:1'],
+            'filters.price_min' => ['nullable', 'integer', 'min:0'],
+            'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'],
+            'filters.in_stock' => ['nullable', 'boolean'],
+            'filters.tags' => ['nullable', 'array', 'max:20'],
+            'filters.tags.*' => ['string', 'max:100'],
+            'filters.vendor' => ['nullable', 'string', 'max:255'],
+            'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if (! is_string($this->input('filters'))) {
+            return;
+        }
+
+        $decoded = json_decode((string) $this->input('filters'), true);
+
+        $this->merge([
+            'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded)
+                ? $decoded
+                : '__invalid_filters__',
+        ]);
+    }
 }
*** Update File: app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php
@@
 class SuggestProductsRequest extends FormRequest
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }

     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:100'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:10'],
         ];
     }
 }
*** Update File: app/Http/Resources/Storefront/V1/SearchProductResource.php
@@
 namespace App\Http\Resources\Storefront\V1;

+use App\Enums\InventoryPolicy;
+use App\Models\ProductVariant;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;

 class SearchProductResource extends JsonResource
 {
@@
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        $variant = $this->relationLoaded('variants')
+            ? $this->variants->first()
+            : null;
+
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'vendor' => $this->vendor,
+            'product_type' => $this->product_type,
+            'price_amount' => $variant?->price_amount,
+            'compare_at_amount' => $variant?->compare_at_amount,
+            'currency' => $variant?->currency ?? $this->store?->default_currency,
+            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
+            'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false,
+            'tags' => $this->tags ?? [],
+        ];
+    }
+
+    private function variantIsInStock(ProductVariant $variant): bool
+    {
+        $inventory = $variant->inventoryItem;
+
+        if ($inventory === null) {
+            return false;
+        }
+
+        return $inventory->availableQuantity() > 0
+            || $inventory->policy === InventoryPolicy::Continue;
     }
 }
*** Update File: app/Http/Controllers/Api/Storefront/V1/SearchController.php
@@
 namespace App\Http\Controllers\Api\Storefront\V1;

 use App\Http\Controllers\Controller;
+use App\Http\Requests\Api\Storefront\V1\SearchProductsRequest;
+use App\Http\Requests\Api\Storefront\V1\SuggestProductsRequest;
+use App\Http\Resources\Storefront\V1\SearchProductResource;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;

 class SearchController extends Controller
 {
+    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $filters = $validated['filters'] ?? [];
+        $query = trim((string) $validated['q']);
+
+        $results = $search->search(
+            $this->currentStore(),
+            $query,
+            $filters,
+            (int) ($validated['per_page'] ?? 24),
+            (string) ($validated['sort'] ?? 'relevance'),
+        );
+
+        return response()->json([
+            'query' => $query,
+            'results' => SearchProductResource::collection($results->getCollection())->resolve($request),
+            'facets' => $search->facets($this->currentStore(), $query, $filters),
+            'pagination' => [
+                'current_page' => $results->currentPage(),
+                'per_page' => $results->perPage(),
+                'total' => $results->total(),
+                'last_page' => $results->lastPage(),
+            ],
+        ]);
+    }
+
+    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $query = trim((string) $validated['q']);
+
+        return response()->json([
+            'query' => $query,
+            'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)),
+        ]);
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }
*** End Patch


output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/V1/SearchController.php:
namespace App\Http\Controllers\Api\Storefront\V1;

use App\Http\Controllers\Controller;

class SearchController extends Controller
{
}


cmd: sed -n '1,160p' app/Http/Controllers/Api/Storefront/V1/SearchController.php


cmd: sed -n '1,140p' app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php && sed -n '1,120p' app/Http/Resources/Storefront/V1/SearchProductResource.php


command: sed -n '1,160p' app/Http/Controllers/Api/Storefront/V1/SearchController.php

SearchController.php

type: read

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Storefront\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    //
}


output:
Chunk ID: 2d7c9a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 45
Output:
<?php

namespace App\Http\Controllers\Api\Storefront\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    //
}


command: sed -n '1,140p' app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php && sed -n '1,120p' app/Http/Resources/Storefront/V1/SearchProductResource.php

SearchProductResource.php

aggregated_output:
<?php

namespace App\Http\Requests\Api\Storefront\V1;

use Illuminate\Foundation\Http\FormRequest;

class SearchProductsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace App\Http\Resources\Storefront\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SearchProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }
}


output:
Chunk ID: 07bedd
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 243
Output:
<?php

namespace App\Http\Requests\Api\Storefront\V1;

use Illuminate\Foundation\Http\FormRequest;

class SearchProductsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace App\Http\Resources\Storefront\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SearchProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }
}


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php
@@
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
@@
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:200'],
+            'filters' => ['nullable', 'array'],
+            'filters.collection_id' => ['nullable', 'integer', 'min:1'],
+            'filters.price_min' => ['nullable', 'integer', 'min:0'],
+            'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'],
+            'filters.in_stock' => ['nullable', 'boolean'],
+            'filters.tags' => ['nullable', 'array', 'max:20'],
+            'filters.tags.*' => ['string', 'max:100'],
+            'filters.vendor' => ['nullable', 'string', 'max:255'],
+            'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if (! is_string($this->input('filters'))) {
+            return;
+        }
+
+        $decoded = json_decode((string) $this->input('filters'), true);
+
+        $this->merge([
+            'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded)
+                ? $decoded
+                : '__invalid_filters__',
+        ]);
+    }
 }
*** Update File: app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php
@@
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:100'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:10'],
         ];
     }
 }
*** Update File: app/Http/Resources/Storefront/V1/SearchProductResource.php
@@
 namespace App\Http\Resources\Storefront\V1;

+use App\Enums\InventoryPolicy;
+use App\Models\ProductVariant;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
@@
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        $variant = $this->relationLoaded('variants')
+            ? $this->variants->first()
+            : null;
+
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'vendor' => $this->vendor,
+            'product_type' => $this->product_type,
+            'price_amount' => $variant?->price_amount,
+            'compare_at_amount' => $variant?->compare_at_amount,
+            'currency' => $variant?->currency ?? $this->store?->default_currency,
+            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
+            'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false,
+            'tags' => $this->tags ?? [],
+        ];
+    }
+
+    private function variantIsInStock(ProductVariant $variant): bool
+    {
+        $inventory = $variant->inventoryItem;
+
+        if ($inventory === null) {
+            return false;
+        }
+
+        return $inventory->availableQuantity() > 0
+            || $inventory->policy === InventoryPolicy::Continue;
     }
 }
*** Update File: app/Http/Controllers/Api/Storefront/V1/SearchController.php
@@
 use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
+use App\Http\Requests\Api\Storefront\V1\SearchProductsRequest;
+use App\Http\Requests\Api\Storefront\V1\SuggestProductsRequest;
+use App\Http\Resources\Storefront\V1\SearchProductResource;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;

 class SearchController extends Controller
 {
-    //
+    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $filters = $validated['filters'] ?? [];
+        $query = trim((string) $validated['q']);
+        $store = $this->currentStore();
+
+        $results = $search->search(
+            $store,
+            $query,
+            $filters,
+            (int) ($validated['per_page'] ?? 24),
+            (string) ($validated['sort'] ?? 'relevance'),
+        );
+
+        return response()->json([
+            'query' => $query,
+            'results' => SearchProductResource::collection($results->getCollection())->resolve($request),
+            'facets' => $search->facets($store, $query, $filters),
+            'pagination' => [
+                'current_page' => $results->currentPage(),
+                'per_page' => $results->perPage(),
+                'total' => $results->total(),
+                'last_page' => $results->lastPage(),
+            ],
+        ]);
+    }
+
+    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $query = trim((string) $validated['q']);
+
+        return response()->json([
+            'query' => $query,
+            'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)),
+        ]);
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php
M app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php
M app/Http/Resources/Storefront/V1/SearchProductResource.php
M app/Http/Controllers/Api/Storefront/V1/SearchController.php

unified_diff:
@@ -5,3 +5,8 @@
 use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
+use App\Http\Requests\Api\Storefront\V1\SearchProductsRequest;
+use App\Http\Requests\Api\Storefront\V1\SuggestProductsRequest;
+use App\Http\Resources\Storefront\V1\SearchProductResource;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;

@@ -9,3 +14,49 @@
 {
-    //
+    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $filters = $validated['filters'] ?? [];
+        $query = trim((string) $validated['q']);
+        $store = $this->currentStore();
+
+        $results = $search->search(
+            $store,
+            $query,
+            $filters,
+            (int) ($validated['per_page'] ?? 24),
+            (string) ($validated['sort'] ?? 'relevance'),
+        );
+
+        return response()->json([
+            'query' => $query,
+            'results' => SearchProductResource::collection($results->getCollection())->resolve($request),
+            'facets' => $search->facets($store, $query, $filters),
+            'pagination' => [
+                'current_page' => $results->currentPage(),
+                'per_page' => $results->perPage(),
+                'total' => $results->total(),
+                'last_page' => $results->lastPage(),
+            ],
+        ]);
+    }
+
+    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $query = trim((string) $validated['q']);
+
+        return response()->json([
+            'query' => $query,
+            'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)),
+        ]);
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }

unified_diff:
@@ -8,8 +8,5 @@
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@ -17,4 +14,2 @@
     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
@@ -24,3 +19,4 @@
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:100'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:10'],
         ];

unified_diff:
@@ -5,2 +5,3 @@
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;

@@ -8,8 +9,5 @@
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@ -17,4 +15,2 @@
     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
@@ -24,5 +20,31 @@
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:200'],
+            'filters' => ['nullable', 'array'],
+            'filters.collection_id' => ['nullable', 'integer', 'min:1'],
+            'filters.price_min' => ['nullable', 'integer', 'min:0'],
+            'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'],
+            'filters.in_stock' => ['nullable', 'boolean'],
+            'filters.tags' => ['nullable', 'array', 'max:20'],
+            'filters.tags.*' => ['string', 'max:100'],
+            'filters.vendor' => ['nullable', 'string', 'max:255'],
+            'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if (! is_string($this->input('filters'))) {
+            return;
+        }
+
+        $decoded = json_decode((string) $this->input('filters'), true);
+
+        $this->merge([
+            'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded)
+                ? $decoded
+                : '__invalid_filters__',
+        ]);
+    }
 }

unified_diff:
@@ -4,2 +4,4 @@

+use App\Enums\InventoryPolicy;
+use App\Models\ProductVariant;
 use Illuminate\Http\Request;
@@ -16,3 +18,31 @@
     {
-        return parent::toArray($request);
+        $variant = $this->relationLoaded('variants')
+            ? $this->variants->first()
+            : null;
+
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'vendor' => $this->vendor,
+            'product_type' => $this->product_type,
+            'price_amount' => $variant?->price_amount,
+            'compare_at_amount' => $variant?->compare_at_amount,
+            'currency' => $variant?->currency ?? $this->store?->default_currency,
+            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
+            'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false,
+            'tags' => $this->tags ?? [],
+        ];
+    }
+
+    private function variantIsInStock(ProductVariant $variant): bool
+    {
+        $inventory = $variant->inventoryItem;
+
+        if ($inventory === null) {
+            return false;
+        }
+
+        return $inventory->availableQuantity() > 0
+            || $inventory->policy === InventoryPolicy::Continue;
     }


output: {"output":"Success. Updated the following files:\nM app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php\nM app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php\nM app/Http/Resources/Storefront/V1/SearchProductResource.php\nM app/Http/Controllers/Api/Storefront/V1/SearchController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: sed -n '1,260p' app/Livewire/Storefront/Collections/Show.php && sed -n '1,260p' resources/views/livewire/storefront/collections/show.blade.php


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


command: sed -n '1,260p' app/Livewire/Storefront/Collections/Show.php && sed -n '1,260p' resources/views/livewire/storefront/collections/show.blade.php

show.blade.php

aggregated_output:
<?php

namespace App\Livewire\Storefront\Collections;

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

class Show extends Component
{
    use WithPagination;

    public string $handle;

    public string $sort = 'featured';

    public bool $inStock = false;

    public ?int $minPrice = null;

    public ?int $maxPrice = null;

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

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

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

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

    public function clearFilters(): void
    {
        $this->inStock = false;
        $this->minPrice = null;
        $this->maxPrice = null;
        $this->types = [];
        $this->vendors = [];
        $this->resetPage();
    }

    public function collection(): Collection
    {
        return Collection::query()
            ->where('handle', $this->handle)
            ->where('status', 'active')
            ->firstOrFail();
    }

    public function products(): LengthAwarePaginator
    {
        $collection = $this->collection();

        return $collection->products()
            ->with(['variants.inventoryItem'])
            ->withCount('variants')
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->when($this->inStock, function (Builder $query): void {
                $query->whereHas('variants.inventoryItem', function (Builder $query): void {
                    $query
                        ->where('policy', 'continue')
                        ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved');
                });
            })
            ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types))
            ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors))
            ->when($this->minPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100));
            })
            ->when($this->maxPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100));
            })
            ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at'))
            ->paginate(12);
    }

    public function productTypes(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('product_type')
            ->distinct()
            ->orderBy('product_type')
            ->pluck('product_type');
    }

    public function productVendors(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('vendor')
            ->distinct()
            ->orderBy('vendor')
            ->pluck('vendor');
    }

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

        return view('livewire.storefront.collections.show', [
            'collection' => $collection,
            'products' => $this->products(),
            'productTypes' => $this->productTypes(),
            'productVendors' => $this->productVendors(),
        ])->layout('layouts.storefront', [
            'title' => $collection->title,
        ]);
    }
}

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

{{ $collection->title }}

        @if ($collection->description_html)

{!! $collection->description_html !!}

        @endif

Filters

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

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

Price

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

Product type

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

Vendor

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

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

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

            @if ($products->isEmpty())

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

No products found

Try adjusting your filters or browse another collection.

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

            @else

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

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


output:
Chunk ID: dd220d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2206
Output:
<?php

namespace App\Livewire\Storefront\Collections;

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

class Show extends Component
{
    use WithPagination;

    public string $handle;

    public string $sort = 'featured';

    public bool $inStock = false;

    public ?int $minPrice = null;

    public ?int $maxPrice = null;

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

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

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

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

    public function clearFilters(): void
    {
        $this->inStock = false;
        $this->minPrice = null;
        $this->maxPrice = null;
        $this->types = [];
        $this->vendors = [];
        $this->resetPage();
    }

    public function collection(): Collection
    {
        return Collection::query()
            ->where('handle', $this->handle)
            ->where('status', 'active')
            ->firstOrFail();
    }

    public function products(): LengthAwarePaginator
    {
        $collection = $this->collection();

        return $collection->products()
            ->with(['variants.inventoryItem'])
            ->withCount('variants')
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->when($this->inStock, function (Builder $query): void {
                $query->whereHas('variants.inventoryItem', function (Builder $query): void {
                    $query
                        ->where('policy', 'continue')
                        ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved');
                });
            })
            ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types))
            ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors))
            ->when($this->minPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100));
            })
            ->when($this->maxPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100));
            })
            ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at'))
            ->paginate(12);
    }

    public function productTypes(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('product_type')
            ->distinct()
            ->orderBy('product_type')
            ->pluck('product_type');
    }

    public function productVendors(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('vendor')
            ->distinct()
            ->orderBy('vendor')
            ->pluck('vendor');
    }

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

        return view('livewire.storefront.collections.show', [
            'collection' => $collection,
            'products' => $this->products(),
            'productTypes' => $this->productTypes(),
            'productVendors' => $this->productVendors(),
        ])->layout('layouts.storefront', [
            'title' => $collection->title,
        ]);
    }
}

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

{{ $collection->title }}

        @if ($collection->description_html)

{!! $collection->description_html !!}

        @endif

Filters

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

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

Price

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

Product type

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

Vendor

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

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

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

            @if ($products->isEmpty())

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

No products found

Try adjusting your filters or browse another collection.

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

            @else

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

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


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

aggregated_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>


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>


status: completed

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


stdout:
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

unified_diff:
@@ -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(' ');

unified_diff:
@@ -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
+

unified_diff:
@@ -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}}


status: completed

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


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

unified_diff:
@@ -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>

unified_diff:
@@ -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}}


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


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

aggregated_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() }}">