listingPage->build( $listing, fn (array $query) => route('products', $query), ); return view('products.index', $data); } public function show(string $locale, int $id) { $product = $this->products->getById($id); abort_if($product === null, Response::HTTP_NOT_FOUND); $product = $this->mergeJustSubmittedReview($product); $collection = $product['collections'][0] ?? null; // dd($product); [$productOptions, $variantsData] = $this->buildOptionPicker($id); return view('product.show', [ 'collection' => $collection, 'product' => $product, 'productOptions' => $productOptions, 'variantsData' => $variantsData, ]); } /** * One button/swatch group per product option (a product can have several * — e.g. a custom-photo product with size + style + person-count, each * combination resolved client-side to one exact ProductVariant, see * product-form-controller.js) plus the per-variant data the picker * resolves a selection against. * * Reads live Eloquent rather than the Meilisearch-indexed $product array * the rest of the page uses (same reasoning as checkStock() below): the * index has no concept of option/value display order (Lunar's * `position` column) or a stable value id, both of which the picker * needs — to render values in the merchant's intended order, and to * match a combination back to one exact variant without relying on * translated label strings staying unique. * * @return array{0: array, 1: array} */ private function buildOptionPicker(int $productId): array { $variants = ProductVariant::query() ->where('product_id', $productId) ->with(['values.option', 'prices', 'images']) ->get(); $productOptions = $variants ->flatMap(fn (ProductVariant $variant) => $variant->values) ->unique('id') ->groupBy(fn ($value) => $value->option->handle) ->map(function ($values, $handle) { $sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]); return [ 'handle' => $handle, 'label' => $sorted->first()->option->translate('name'), // The "Color" option type is the only one that writes a // hex code into meta (see boboko/core's ColorOptionType) // — its presence is how we tell a color option (swatches) // from any other option (buttons). 'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)), 'values' => $sorted->map(fn ($v) => [ 'id' => $v->id, 'label' => $v->translate('name'), 'hex' => $v->meta['hex'] ?? null, ])->values()->all(), ]; }) ->values() ->all(); $variantsData = $variants->map(fn (ProductVariant $variant) => [ 'id' => $variant->id, 'price' => $variant->prices->first()?->price?->decimal(), 'image' => $variant->images->first()?->getUrl(), // Live from the DB (not the index's in_stock), with Lunar's own // purchasability rule — drives the disabled add-to-cart button. 'inStock' => $variant->canBeFulfilledAtQuantity(1), // handle => selected value id, for matching a combination of // selections back to this variant — see selectVariant() in // product-form-controller.js. 'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(), ])->values()->all(); return [$productOptions, $variantsData]; } /** * Meilisearch's own write API is itself async — addDocuments() enqueues an * indexing task and returns immediately, and Laravel\Scout\Engines\ * MeilisearchEngine::update() never waits on that task, so even * $product->searchableSync() (which only skips OUR queue) can still land * the shopper back on this page before Meilisearch has actually processed * the write. storeReview() flashes the review it just created for exactly * this one next request; splice it in here rather than trust the index is * already caught up. Guarded by id so a race the other way — the index * DID catch up in time — doesn't show the same review twice. */ private function mergeJustSubmittedReview(array $product): array { $justSubmitted = session('justSubmittedReview'); if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) { return $product; } $items = $product['reviews']['items'] ?? []; if (collect($items)->contains('id', $justSubmitted['id'])) { return $product; } $items = [$justSubmitted, ...$items]; $product['reviews']['items'] = $items; $product['reviews']['count'] = count($items); $product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1); return $product; } /** * A storefront-owned, checkout-module-independent stock check — the * product page's "Add to cart" calls this first and only submits to the * checkout module's own add-to-cart endpoint once this says `ok`. Reads * the live Eloquent ProductVariant directly (not the Meilisearch index * ProductService otherwise reads from, which can lag behind an actual * sale until the next reindex) via the SAME method Lunar's own * CartLineStock validator calls, so this can never disagree with what * the module's own server-side check would decide. */ public function checkStock(string $locale, Request $request): JsonResponse { $data = $request->validate([ 'variant' => ['required', 'integer'], 'quantity' => ['nullable', 'integer', 'min:1'], ]); $variant = ProductVariant::find($data['variant']); $quantity = $data['quantity'] ?? 1; if ($variant === null) { return response()->json(['ok' => true]); } return response()->json([ 'ok' => $variant->canBeFulfilledAtQuantity($quantity), 'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(), ]); } /** * boboko/core's product_reviews table has no moderation/status column, so * this goes live immediately — no approval queue to land in. */ public function storeReview(string $locale, Request $request, Product $product): RedirectResponse { $reviewsUrl = route('product.show', [ 'locale' => $locale, 'id' => $product->id, 'tab' => 'reviews', ]).'#product-tabs'; $validator = Validator::make($request->all(), [ 'rating' => ['required', 'integer', 'between:1,5'], 'content' => ['required', 'string'], 'name' => ['nullable', 'string', 'max:255'], 'email' => ['required', 'email'], ]); if ($validator->fails()) { return redirect($reviewsUrl)->withErrors($validator)->withInput(); } $data = $validator->validated(); // Not $product->reviews()->create(...): that relation only exists via a // Product::macro() registered in CorePlugin::register(Panel $panel), which // Filament calls solely when the /boboko admin panel boots — never on a // plain storefront request, where the macro is simply undefined. $review = ProductReview::create([ 'product_id' => $product->id, 'rating' => $data['rating'], 'body' => $data['content'], 'reviewer_name' => $data['name'] ?? null, 'reviewer_email' => $data['email'], 'reviewed_at' => now(), 'source' => 'storefront', ]); // ReviewServiceProvider also reindexes on the model's `created` event, but // queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page // load. Syncing here skips our queue too, but Meilisearch's own write API // is itself async on top of that (see mergeJustSubmittedReview()), so this // alone still isn't a guarantee — it's the flash below that actually is. $product->searchableSync(); return redirect($reviewsUrl) ->with('reviewSubmitted', true) ->with('justSubmittedReview', [ 'id' => $review->id, 'product_id' => $review->product_id, 'title' => $review->title, 'body' => $review->body, 'rating' => $review->rating, 'reviewed_at' => $review->reviewed_at?->timestamp, 'reviewer_name' => $review->reviewer_name, 'reply' => null, 'replied_at' => null, 'media' => [], ]); } }