Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.93% covered (danger)
36.93%
137 / 371
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebsiteController
36.93% covered (danger)
36.93%
137 / 371
36.84% covered (danger)
36.84%
7 / 19
2122.41
0.00% covered (danger)
0.00%
0 / 1
 refreshAvailability
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
3.33
 contact
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 collection
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 product
55.56% covered (warning)
55.56%
20 / 36
0.00% covered (danger)
0.00%
0 / 1
18.78
 quickView
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
90
 categoryProduct
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 colorSizes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 addToCart
21.05% covered (danger)
21.05%
32 / 152
0.00% covered (danger)
0.00%
0 / 1
472.85
 checkout
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 applyCoupon
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 checkoutEmbed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkoutProcess
65.62% covered (warning)
65.62%
21 / 32
0.00% covered (danger)
0.00%
0 / 1
12.29
 thankYou
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 loadMoreReviews
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 search
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 searchSuggest
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 searchQuery
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 applyShopFilters
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\admin\Product;
6use App\Models\admin\Stock;
7use App\Models\Brand;
8use App\Models\Category;
9use App\Models\Color;
10use App\Models\CustomerReview;
11use App\Models\Order;
12use App\Models\Page;
13use App\Models\ProductCollection;
14use App\Models\ProductVariation;
15use App\Service\CacheService;
16use App\Service\OrderService;
17use App\Services\Cart\CartService;
18use App\Services\Payment\PaymentManager;
19use App\Services\Pricing\PriceService;
20use App\Services\Shipping\ShippingService;
21use Illuminate\Http\Request;
22use Illuminate\Support\Facades\DB;
23use Illuminate\Support\Facades\Log;
24use Illuminate\Support\Facades\Route;
25use Illuminate\Support\Facades\Session;
26
27class WebsiteController extends Controller
28{
29    private function refreshAvailability(array $data): array
30    {
31        $product = $data['product']->fresh(['variations']);
32        abort_unless($product, 404);
33        $data['product'] = $product;
34        foreach ($data['variationsJson'] as &$row) {
35            $variant = $product->variations->firstWhere('id', $row['id']);
36            $row['in_stock'] = $variant && app(\App\Services\Stock\StockResolver::class)->canSell($product, 1, $variant);
37            $row['stock'] = $variant?->stock_quantity ?? 0;
38        }
39        unset($row);
40        return $data;
41    }
42
43    public function contact()
44    {
45        return view('website.contact');
46    }
47
48    public function page($slug)
49    {
50        $page = Page::query()->where('slug', $slug)->where('status', 'published')->first();
51
52        return view('website.page', compact('slug', 'page'));
53    }
54
55    public function collection(string $slug)
56    {
57        $collection = ProductCollection::where('slug', $slug)->where('is_active', true)->firstOrFail();
58        $products = $collection->products()->with('category')->paginate(24);
59
60        return view('website.collection', compact('collection', 'products'));
61    }
62
63    public function product($id)
64    {
65        // CACHED: everything below except $reviews (reviews are added
66        // independently of product edits, so they stay live and are cheap
67        // anyway). Busted immediately by Product::boot()/ProductVariation::
68        // boot() whenever this product or one of its variations changes —
69        // see app/Service/CacheService.php.
70        $data = CacheService::remember(CacheService::keyProduct($id), function () use ($id) {
71            $product = Product::with([
72                'variations.attributeValues.attribute',
73                'specs.group',
74            ])->findOrFail($id);
75
76            // Build color and size maps from variations
77            $colors = collect();
78            $sizes = collect();
79
80            foreach ($product->variations as $variation) {
81                foreach ($variation->attributeValues as $av) {
82                    $attrName = strtolower($av->attribute->name ?? '');
83                    if ($attrName === 'color') {
84                        $colors->push(['id' => $av->id, 'name' => $av->value]);
85                    } elseif ($attrName === 'size') {
86                        $sizes->push(['id' => $av->id, 'name' => $av->value]);
87                    }
88                }
89            }
90
91            $colors = $colors->unique('name')->values();
92            $sizes = $sizes->unique('name')->values()
93                ->sortBy(fn ($s) => is_numeric($s['name']) ? (float) $s['name'] : $s['name'])
94                ->values();
95
96            // Encode variations as JSON for JS use
97            $variationsJson = $product->variations->map(function ($v) use ($product) {
98                $inStock = app(\App\Services\Stock\StockResolver::class)->canSell($product, 1, $v);
99
100                return [
101                    'id' => $v->id,
102                    'price' => (! empty($v->price) && $v->price > 0) ? $v->price : ((! empty($product->selling_price) && $product->selling_price > 0) ? $product->selling_price : $product->price),
103                    'stock' => $v->stock_quantity,
104                    'in_stock' => $inStock,
105                    'attributes' => $v->attributeValues->mapWithKeys(fn ($av) => [
106                        strtolower($av->attribute->name ?? 'attr') => $av->value,
107                    ])->toArray(),
108                ];
109            })->values()->toArray();
110
111            $relatedProducts = Product::where('id', '!=', $product->id)->get();
112
113            return compact('product', 'colors', 'sizes', 'variationsJson', 'relatedProducts');
114        });
115
116        $reviews = CustomerReview::where('status', 1)->latest()->take(8)->get();
117
118        $data = $this->refreshAvailability($data);
119        return view('website.product', array_merge($data, compact('reviews')));
120    }
121
122    public function quickView($id)
123    {
124        // CACHED — same reasoning as product() above.
125        $data = CacheService::remember(CacheService::keyQuickView($id), function () use ($id) {
126            $product = Product::with(['images', 'variations.attributeValues.attribute'])->findOrFail($id);
127
128            $colors = collect();
129            $sizes = collect();
130
131            foreach ($product->variations as $variation) {
132                foreach ($variation->attributeValues as $av) {
133                    $attrName = strtolower($av->attribute->name ?? '');
134                    if ($attrName === 'color') {
135                        $colors->push(['id' => $av->id, 'name' => $av->value]);
136                    } elseif ($attrName === 'size') {
137                        $sizes->push(['id' => $av->id, 'name' => $av->value]);
138                    }
139                }
140            }
141            $colors = $colors->unique('name')->values();
142            $sizes = $sizes->unique('name')->values();
143
144            // Encode variations as JSON for JS use
145            $variationsJson = $product->variations->map(function ($v) use ($product) {
146                $inStock = app(\App\Services\Stock\StockResolver::class)->canSell($product, 1, $v);
147
148                return [
149                    'id' => $v->id,
150                    'price' => (! empty($v->price) && $v->price > 0) ? $v->price : ((! empty($product->selling_price) && $product->selling_price > 0) ? $product->selling_price : $product->price),
151                    'stock' => $v->stock_quantity,
152                    'in_stock' => $inStock,
153                    'attributes' => $v->attributeValues->mapWithKeys(fn ($av) => [
154                        strtolower($av->attribute->name ?? 'attr') => $av->value,
155                    ])->toArray(),
156                ];
157            })->values()->toArray();
158
159            return compact('product', 'colors', 'sizes', 'variationsJson');
160        });
161
162        return view('website.ajax.quick-view', $this->refreshAvailability($data));
163    }
164
165    public function categoryProduct($category_name, $categoryId)
166    {
167        // CACHED: keyed by category id. Busted immediately whenever a
168        // product in this category changes (Product::boot() forgets this
169        // exact key), or the category itself is edited/moved
170        // (Category::boot()).
171        $data = CacheService::remember(CacheService::keyCategoryProducts($categoryId), function () use ($categoryId) {
172            $category = Category::query()->findOrFail($categoryId);
173            $products = DB::table('products')
174                ->join('categories', 'products.category_id', '=', 'categories.id')
175                ->where('categories.id', $categoryId)
176                ->orWhereIn('categories.id', function ($query) use ($categoryId) {
177                    $query->select('id')
178                        ->from('categories')
179                        ->where('parent_id', $categoryId);
180                })
181                ->select('products.*', 'categories.name as category_name')
182                ->orderBy('products.position', 'asc')
183                ->orderByDesc('products.id')
184                ->get();
185
186            return compact('products', 'category');
187        });
188
189        return view('website.category-ways-product', $data);
190    }
191
192    public function colorSizes(Request $request)
193    {
194        $product = $request->product_id;
195        $color = $request->color_id;
196        // $sizes = colorSize($product, $color);
197        $sizes = Stock::query()->where('product_id', $product)->where('color_id', $color)->get();
198        $sizes_html = view('website.ajax.color-size', compact('sizes'))->render();
199
200        return response()->json(['sizes' => $sizes_html]);
201    }
202
203    public function addToCart(Request $request, PriceService $priceService)
204    {
205        if (isset($request->shipping)) {
206            // BUG FIX: this used to gate $checkout on prev_route()/is_checkout, which meant
207            // the header's cart drawer (used on every page, not just /checkout) fell back to
208            // the plain "Proceed To Checkout" link and never showed the Order Now + shipping/
209            // payment panel. The drawer is supposed to always show the full panel — only the
210            // actual checkout PAGE's inline form needs the submit-vs-link distinction, and
211            // that's handled in the button itself now (see cart-item.blade.php).
212            $checkout = true;
213            session()->put('shipping_cost', $request->shipping);
214            $cart_item = view('website.ajax.cart-item', compact('checkout'))->render();
215            $total_itam = session('cart') ? collect(session('cart'))->sum('qty') : 0;
216
217            return response()->json(
218                ['message' => 'Product Remove successfully!',
219                    'cart' => $cart_item,
220                    'total_item' => $total_itam,
221                ]);
222        }
223
224        if (isset($request->remove_id)) {
225            $cart = session('cart');
226            if ($cart) {
227                $cart = array_filter($cart, function ($item) use ($request) {
228                    return $item['stock_id'] != $request->remove_id;
229                });
230                session(['cart' => $cart]);
231            }
232            $checkout = true; // see fix note above — drawer always shows the full panel
233            $cart_item = view('website.ajax.cart-item', compact('checkout'))->render();
234            $total_itam = session('cart') ? collect(session('cart'))->sum('qty') : 0;
235
236            return response()->json(
237                ['message' => 'Product Remove successfully!',
238                    'cart' => $cart_item,
239                    'total_item' => $total_itam,
240                ]);
241        }
242        // Home page: update qty of existing cart item (from cart sidebar +/- buttons)
243        if ($request->item_id && ! $request->product_id) {
244            $stockId = $request->item_id;
245            $cart = session('cart');
246            if (isset($cart[$stockId])) {
247                app(\App\Services\Stock\InventoryService::class)->assertAvailable($stockId, $request->cart_qty);
248                $cart[$stockId]['qty'] = (int) $request->cart_qty;
249            }
250            Session::put('cart', $cart);
251            $checkout = true; // see fix note above — drawer always shows the full panel
252            $cart_item = view('website.ajax.cart-item', compact('checkout'))->render();
253            $total_itam = session('cart') ? collect(session('cart'))->sum('qty') : 0;
254
255            return response()->json([
256                'message' => 'Cart updated successfully!',
257                'cart' => $cart_item,
258                'total_item' => $total_itam,
259            ]);
260        }
261
262        // Product detail page: add by variation_id (new variation system)
263        if ($request->variation_id) {
264            $variation = ProductVariation::with('attributeValues.attribute')->find($request->variation_id);
265            $product = Product::find($request->product_id);
266            if (! $variation || ! $product || (int) $variation->product_id !== (int) $product->id) {
267                return response()->json(['message' => 'Variation not found'], 404);
268            }
269            $cart = Session::get('cart', []);
270
271            // Extract color and size from variation attributes
272            $color = '';
273            $size = '';
274            foreach ($variation->attributeValues as $av) {
275                $attrName = strtolower($av->attribute->name ?? '');
276                if ($attrName === 'color') {
277                    $color = $av->value;
278                }
279                if ($attrName === 'size') {
280                    $size = $av->value;
281                }
282            }
283
284            // Use var_{id} as cart key — order_details FK constraint already removed
285            $key = 'var_' . $variation->id;
286            $qty = app(\App\Services\Stock\InventoryService::class)->quantity($request->cart_qty);
287            app(\App\Services\Stock\InventoryService::class)->assertAvailable($key, ($cart[$key]['qty'] ?? 0) + $qty);
288
289            if (isset($cart[$key])) {
290                $cart[$key]['qty'] += $qty;
291            } else {
292                $basePrice = (! empty($variation->price) && $variation->price > 0) ? $variation->price : ((! empty($product->selling_price) && $product->selling_price > 0) ? $product->selling_price : $product->price);
293
294                $cart[$key] = [
295                    'stock_id' => $key,
296                    'qty' => $qty,
297                    'name' => $product->name,
298                    // Wholesale/customer-group pricing (Phase 7) — a no-op down to the
299                    // exact $basePrice above unless the feature is on and this shopper
300                    // is logged in with a group that has a matching price rule.
301                    'price' => $priceService->effectivePrice($product->id, $product->category_id, (float) $basePrice, auth('customer')->user(), $qty),
302                    // Per-category tax override (Phase 7) — CartService::totals() reads
303                    // this to resolve each line's rate; harmless while `tax_vat` is off.
304                    'category_id' => $product->category_id,
305                    'color' => $color,
306                    'size' => $size,
307                    'image' => $product->thumbnail,
308                ];
309            }
310            Session::put('cart', $cart);
311            $checkout = true; // see fix note above — drawer always shows the full panel
312            $cart_item = view('website.ajax.cart-item', compact('checkout'))->render();
313            $total_itam = collect(session('cart'))->sum('qty');
314
315            return response()->json([
316                'message' => 'Product added to cart!',
317                'cart' => $cart_item,
318                'total_item' => $total_itam,
319            ]);
320        }
321
322        // Home page product card: add by product_id directly
323        if ($request->product_id) {
324            $product = Product::find($request->product_id);
325            if (! $product) {
326                return response()->json(['message' => 'Product not found'], 404);
327            }
328            $cart = Session::get('cart', []);
329
330            // Use p_{id} as cart key — no Stock record needed (FK removed from order_details)
331            $key = 'p_' . $product->id;
332            $qty = app(\App\Services\Stock\InventoryService::class)->quantity($request->cart_qty);
333            app(\App\Services\Stock\InventoryService::class)->assertAvailable($key, ($cart[$key]['qty'] ?? 0) + $qty);
334
335            if (isset($cart[$key])) {
336                $cart[$key]['qty'] += $qty;
337            } else {
338                $basePrice = (! empty($product->selling_price) && $product->selling_price > 0) ? $product->selling_price : $product->price;
339
340                $cart[$key] = [
341                    'stock_id' => $key,
342                    'qty' => $qty,
343                    'name' => $product->name,
344                    'price' => $priceService->effectivePrice($product->id, $product->category_id, (float) $basePrice, auth('customer')->user(), $qty),
345                    'category_id' => $product->category_id,
346                    'color' => '',
347                    'size' => '',
348                    'image' => $product->thumbnail,
349                ];
350            }
351            Session::put('cart', $cart);
352            $checkout = true; // see fix note above — drawer always shows the full panel
353            $cart_item = view('website.ajax.cart-item', compact('checkout'))->render();
354            $total_itam = collect(session('cart'))->sum('qty');
355
356            return response()->json([
357                'message' => 'Product added to cart!',
358                'cart' => $cart_item,
359                'total_item' => $total_itam,
360            ]);
361        }
362
363        // Product detail page: add by stock_id
364        $cart = Session::get('cart', []);
365        $stockId = $request->stock_id;
366        $stock = DB::table('products')
367            ->select(
368                'products.id as product_id',
369                'products.name as product_name',
370                'products.selling_price as product_price',
371                'products.category_id as product_category_id',
372                'products.thumbnail',
373                'stocks.id as stock_id',
374                'colors.name as color_name',
375                'sizes.name as size_name',
376                'stocks.stock_in as in_stock'
377            )
378            ->join('stocks', 'stocks.product_id', '=', 'products.id')
379            ->join('colors', 'colors.id', '=', 'stocks.color_id')
380            ->join('sizes', 'sizes.id', '=', 'stocks.size_id')
381            ->where('stocks.id', $stockId)
382            ->first();
383        $qty = $request->qty ?? 1;
384        if ($stock) {
385            app(\App\Services\Stock\InventoryService::class)->assertAvailable($stockId, ($cart[$stockId]['qty'] ?? 0) + $qty);
386            if (isset($cart[$stockId])) {
387                $cart[$stockId]['qty'] += $qty;
388            } else {
389                $cart[$stockId] = [
390                    'stock_id' => $stockId,
391                    'qty' => $qty,
392                    'name' => $stock->product_name,
393                    'price' => $priceService->effectivePrice($stock->product_id, $stock->product_category_id, (float) $stock->product_price, auth('customer')->user(), $qty),
394                    'category_id' => $stock->product_category_id,
395                    'color' => $stock->color_name,
396                    'size' => $stock->size_name,
397                    'image' => $stock->thumbnail,
398                ];
399            }
400            Session::put('cart', $cart);
401        }
402        $checkout = true; // see fix note above — drawer always shows the full panel
403        $cart_item = view('website.ajax.cart-item', compact('checkout'))->render();
404        $total_itam = session('cart') ? collect(session('cart'))->sum('qty') : 0;
405
406        return response()->json([
407            'message' => 'Cart updated successfully!',
408            'cart' => $cart_item,
409            'total_item' => $total_itam,
410        ]);
411    }
412
413    public function checkout(CartService $cartService, ShippingService $shippingService, PaymentManager $paymentManager)
414    {
415        $cart = Session::get('cart', []);
416        $cart_item = view('website.ajax.cart-item')->render();
417        $total_itam = session('cart') ? collect(session('cart'))->sum('qty') : 0;
418        $totals = $cartService->totals();
419        $shippingZones = $shippingService->zones();
420        // Opt-in (Phase 8): a store that never enables anything beyond Cash on
421        // Delivery gets exactly one method and the checkout page shows no
422        // picker at all — see hasMoreThanCod() in checkout.blade.php.
423        $paymentGateways = $paymentManager->enabled();
424
425        return view('website.checkout', compact('cart_item', 'total_itam', 'totals', 'shippingZones', 'paymentGateways'));
426    }
427
428    /** Apply / remove a coupon code from the storefront checkout (AJAX). */
429    public function applyCoupon(Request $request, CartService $cartService)
430    {
431        if ($request->boolean('remove')) {
432            $cartService->clearCoupon();
433
434            return response()->json(['ok' => true, 'totals' => $cartService->totals()->toArray()]);
435        }
436
437        [$ok, $error] = $cartService->applyCoupon((string) $request->input('code', ''));
438
439        return response()->json([
440            'ok' => $ok,
441            'message' => $ok ? 'Coupon applied.' : $error,
442            'totals' => $cartService->totals()->toArray(),
443        ], $ok ? 200 : 422);
444    }
445
446    /**
447     * Same checkout data as checkout(), but rendered as a bare fragment
448     * (no @extends layout) so it can be AJAX-loaded straight into the
449     * Quick View modal's body — lets "Buy Now" finish the whole order
450     * (shipping + billing + confirm) without navigating off the current
451     * page. Submits to the exact same checkout.process route/controller
452     * method as the standalone /checkout page, so order creation logic
453     * is not duplicated anywhere.
454     */
455    public function checkoutEmbed()
456    {
457        return view('website.ajax.checkout-embed');
458    }
459
460    public function checkoutProcess(Request $request, PaymentManager $paymentManager)
461    {
462        // BUG FIX: this used to be $request->validate(['name', 'phone', 'address']) which
463        // is not a valid rules array (it's a plain list of strings), so none of these
464        // fields were actually being validated — a customer could submit an empty
465        // or garbage checkout form and it would still go through.
466        $request->validate([
467            'name' => 'required|string|max:255',
468            'phone' => 'required|string|max:20',
469            'address' => 'required|string|max:500',
470        ]);
471
472        // Phase 8: which gateway the shopper picked — falls back to 'cod' so a
473        // request from before the payment-method picker existed (or a store
474        // that only has COD enabled and shows no picker at all) behaves
475        // exactly as it always has.
476        $method = $request->input('payment_method', 'cod');
477        if (! $paymentManager->has($method) || ! $paymentManager->isOn($method)) {
478            $method = 'cod';
479        }
480
481        try {
482            DB::beginTransaction();
483            $customer = OrderService::storeCutomer();
484            $order = OrderService::storeOrder($customer);
485            if (! $order) {
486                DB::rollBack();
487
488                return redirect()->back()->withInput()->with('error', 'Your cart is empty.');
489            }
490
491            if ($method === 'cod') {
492                // Unchanged from before Phase 8 — always a Pending "Cash on
493                // Delivery" payment row, no redirect.
494                OrderService::paymentStore($order);
495                DB::commit();
496
497                if (in_array($request->order_via, ['whatsapp', 'messenger'], true)) {
498                    Session::put('order_via', $request->order_via);
499                }
500
501                return redirect()->route('thank.you', ['order' => $order->bar_code]);
502            }
503
504            // A real gateway: commit the order now (it exists regardless of
505            // whether the shopper actually completes payment off-site — same
506            // as COD, where "Pending" already means "not paid yet either").
507            DB::commit();
508
509            $result = $paymentManager->charge($method, $order);
510
511            if ($result->redirectUrl) {
512                return redirect()->away($result->redirectUrl);
513            }
514
515            // A driver that decided the outcome itself with nothing to
516            // redirect to (shouldn't happen for a real gateway, but handled
517            // rather than silently losing the failure).
518            return redirect()->route('thank.you', ['order' => $order->bar_code])
519                ->with('error', $result->message ?? 'Could not start the payment. Please try Cash on Delivery instead.');
520        } catch (\Throwable $e) {
521            DB::rollBack();
522            Log::error('Checkout failed: ' . $e->getMessage(), ['exception' => $e]);
523
524            return redirect()->back()->withInput()->with('error', $e instanceof \Illuminate\Validation\ValidationException
525                ? collect($e->errors())->flatten()->first()
526                : 'Something went wrong while placing your order. Please try again.');
527        }
528
529    }
530
531    public function thankYou(Request $request)
532    {
533        $order = null;
534        if ($request->order) {
535            // Look up by reference number (bar_code), NOT by numeric ID.
536            // This prevents customers from guessing other orders by changing ?order=22 → ?order=23.
537            $order = Order::with('customer')
538                ->where('bar_code', $request->order)
539                ->first();
540        }
541
542        // Pull the messaging channel from session (one-time use)
543        $order_via = Session::pull('order_via');
544
545        return view('website.thank-you', compact('order', 'order_via'));
546    }
547
548    public function loadMoreReviews(Request $request)
549    {
550        $skip = $request->get('skip', 0);
551        $reviews = CustomerReview::where('status', 1)
552            ->latest()
553            ->skip($skip)
554            ->take(8)
555            ->get();
556
557        $html = '';
558        foreach ($reviews as $review) {
559            $imageUrl = asset('storage/' . $review->image_path);
560            $html .= '<div class="col-xl-3 col-md-6 col-6 mb-4" data-aos="zoom-in">';
561            $html .= '<div class="card h-100 shadow-sm border-0">';
562            $html .= '<a href="' . $imageUrl . '" class="glightbox" data-gallery="customer-reviews">';
563            $html .= '<img src="' . $imageUrl . '" class="card-img-top rounded" style="height: 250px; object-fit: cover; width: 100%;">';
564            $html .= '</a>';
565            $html .= '</div></div>';
566        }
567
568        return response()->json([
569            'html' => $html,
570            'count' => $reviews->count(),
571        ]);
572    }
573
574    /* ---------------------------------------------------------------- search */
575
576    /** Full search results page. Filters: brand[], price_min/max, in_stock, sort. */
577    public function search(Request $request)
578    {
579        $term = trim((string) $request->input('q', ''));
580
581        $products = $this->applyShopFilters($this->searchQuery($term), $request)->paginate(24)->withQueryString();
582
583        $data = compact('term', 'products') + [
584            'brands' => Brand::orderBy('name')->get(['id', 'name']),
585            'heading' => $term !== '' ? 'Results for “' . $term . '”' : 'All products',
586        ];
587
588        return view('website.search', $data);
589    }
590
591    /** Lightweight autocomplete for the header search box. */
592    public function searchSuggest(Request $request)
593    {
594        $term = trim((string) $request->input('q', ''));
595        if (mb_strlen($term) < 2) {
596            return response()->json(['data' => []]);
597        }
598
599        $rows = $this->searchQuery($term)
600            ->limit(8)
601            ->get(['id', 'name', 'thumbnail', 'price', 'selling_price'])
602            ->map(fn ($p) => [
603                'id' => $p->id,
604                'name' => $p->name,
605                'url' => route('product', $p->id),
606                'thumb' => $p->thumbnail ? asset($p->thumbnail) : asset('dist/img/default-150x150.png'),
607                'price' => money($p->selling_price ?: $p->price),
608            ]);
609
610        return response()->json(['data' => $rows]);
611    }
612
613    /** Base query matching name / SKU / product code / keywords. */
614    private function searchQuery(string $term)
615    {
616        return Product::query()->when($term !== '', function ($q) use ($term) {
617            $like = '%' . $term . '%';
618            $q->where(function ($q) use ($like) {
619                $q->where('name', 'like', $like)
620                    ->orWhere('sku_no', 'like', $like)
621                    ->orWhere('product_code', 'like', $like)
622                    ->orWhere('keywords', 'like', $like);
623            });
624        });
625    }
626
627    /** Shared brand / price / stock / sort filters from the query string. */
628    private function applyShopFilters($query, Request $request)
629    {
630        $query
631            ->when($request->filled('category'), fn ($q) => $q->where('category_id', $request->input('category')))
632            ->when($request->filled('brand'), fn ($q) => $q->whereIn('brand_id', array_filter((array) $request->input('brand'))))
633            ->when($request->filled('price_min'), fn ($q) => $q->where('selling_price', '>=', (float) $request->input('price_min')))
634            ->when($request->filled('price_max'), fn ($q) => $q->where('selling_price', '<=', (float) $request->input('price_max')))
635            ->when($request->boolean('in_stock'), fn ($q) => $q->where('stock_status', '!=', 'out_of_stock'));
636
637        return match ($request->input('sort')) {
638            'price_low' => $query->orderBy('selling_price'),
639            'price_high' => $query->orderByDesc('selling_price'),
640            'name' => $query->orderBy('name'),
641            'oldest' => $query->orderBy('id'),
642            default => $query->orderByDesc('id'),
643        };
644    }
645}