2026-07-31 17:41:55 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
|
|
2026-09-03 16:28:10 +03:00
|
|
|
use App\Catalog\ProductListing;
|
|
|
|
|
use App\Catalog\ProductListingPage;
|
2026-09-17 21:52:27 +03:00
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
2026-08-27 00:55:56 +03:00
|
|
|
use Illuminate\Http\Response;
|
2026-09-17 21:52:27 +03:00
|
|
|
use Lunar\Models\ProductVariant;
|
2026-08-27 11:46:33 +03:00
|
|
|
use Modules\Core\Catalog\Services\ProductService;
|
2026-07-31 17:41:55 +03:00
|
|
|
|
|
|
|
|
class ProductController extends Controller
|
|
|
|
|
{
|
2026-09-03 16:28:10 +03:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly ProductService $products,
|
|
|
|
|
private readonly ProductListingPage $listingPage,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* All products — the category page without a collection scope. Filters,
|
|
|
|
|
* sort, pagination and the shared listing body all work identically.
|
|
|
|
|
*/
|
|
|
|
|
public function index(string $locale)
|
|
|
|
|
{
|
|
|
|
|
$listing = ProductListing::fromRequest(request());
|
|
|
|
|
|
|
|
|
|
$data = $this->listingPage->build(
|
|
|
|
|
$listing,
|
|
|
|
|
fn (array $query) => route('products', $query),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return view('products.index', $data);
|
|
|
|
|
}
|
2026-08-27 00:55:56 +03:00
|
|
|
|
|
|
|
|
public function show(string $locale, int $id)
|
2026-07-31 17:41:55 +03:00
|
|
|
{
|
2026-08-27 00:55:56 +03:00
|
|
|
$product = $this->products->getById($id);
|
|
|
|
|
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
2026-07-31 17:41:55 +03:00
|
|
|
|
2026-08-27 00:55:56 +03:00
|
|
|
$collection = $product['collections'][0] ?? null;
|
2026-07-31 17:41:55 +03:00
|
|
|
|
2026-09-21 18:53:45 +03:00
|
|
|
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
2026-09-17 21:52:27 +03:00
|
|
|
|
2026-08-27 00:55:56 +03:00
|
|
|
return view('product.show', [
|
2026-08-29 00:17:34 +03:00
|
|
|
'collection' => $collection,
|
2026-08-27 00:55:56 +03:00
|
|
|
'product' => $product,
|
2026-09-21 18:53:45 +03:00
|
|
|
'productOptions' => $productOptions,
|
2026-08-27 00:55:56 +03:00
|
|
|
'variantsData' => $variantsData,
|
2026-07-31 17:41:55 +03:00
|
|
|
]);
|
|
|
|
|
}
|
2026-09-17 21:52:27 +03:00
|
|
|
|
2026-09-21 18:53:45 +03:00
|
|
|
/**
|
|
|
|
|
* 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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 21:52:27 +03:00
|
|
|
/**
|
|
|
|
|
* 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(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-07-31 17:41:55 +03:00
|
|
|
}
|