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); $collection = $product['collections'][0] ?? null; [$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(), // 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]; } /** * 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(), ]); } }