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;
|
2026-09-22 19:00:34 +03:00
|
|
|
use Illuminate\Http\RedirectResponse;
|
2026-09-17 21:52:27 +03:00
|
|
|
use Illuminate\Http\Request;
|
2026-08-27 00:55:56 +03:00
|
|
|
use Illuminate\Http\Response;
|
2026-09-22 19:00:34 +03:00
|
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
|
use Lunar\Models\Product;
|
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-09-22 19:00:34 +03:00
|
|
|
use Modules\Core\Review\Models\ProductReview;
|
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-09-22 19:00:34 +03:00
|
|
|
$product = $this->mergeJustSubmittedReview($product);
|
|
|
|
|
|
2026-08-27 00:55:56 +03:00
|
|
|
$collection = $product['collections'][0] ?? null;
|
2026-09-25 13:47:58 +03:00
|
|
|
// dd($product);
|
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(),
|
2026-09-25 20:23:19 +03:00
|
|
|
// 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),
|
2026-09-21 18:53:45 +03:00
|
|
|
// 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-22 19:00:34 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
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-09-22 19:00:34 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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' => [],
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-07-31 17:41:55 +03:00
|
|
|
}
|