Files
3dealer/app/Http/Controllers/ProductController.php
T

145 lines
5.6 KiB
PHP
Raw Normal View History

2026-07-31 17:41:55 +03:00
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\ProductService;
2026-07-31 17:41:55 +03:00
class ProductController extends Controller
{
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);
}
public function show(string $locale, int $id)
2026-07-31 17:41:55 +03:00
{
$product = $this->products->getById($id);
abort_if($product === null, Response::HTTP_NOT_FOUND);
2026-07-31 17:41:55 +03:00
$collection = $product['collections'][0] ?? null;
2026-07-31 17:41:55 +03:00
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
return view('product.show', [
'collection' => $collection,
'product' => $product,
'productOptions' => $productOptions,
'variantsData' => $variantsData,
2026-07-31 17:41:55 +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];
}
/**
* 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
}