temp emails, reviews theming and form, minor change in checkout module, validation translations seeder

This commit is contained in:
elvira
2026-09-22 19:00:34 +03:00
parent a107184010
commit 24851184c4
19 changed files with 518 additions and 47 deletions
@@ -74,13 +74,27 @@ public function show(string $locale): View
$cart->refresh()->recalculate();
}
$paymentMethods = $this->checkout->getPaymentMethods();
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
// no longer offered (method disabled/removed since) — auto-select
// the first one, same as a manual click would, so the payment
// section (and the Stripe Element mounting under it) isn't sitting
// inert behind an unchecked radio. A still-valid previous choice is
// left alone.
$firstMethod = $paymentMethods->first();
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
}
return view('checkout::page', [
'cart' => $cart,
'lines' => $lines,
'billingAddress' => $cart?->billingAddress,
'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $shippingOptions,
'paymentMethods' => $this->checkout->getPaymentMethods(),
'paymentMethods' => $paymentMethods,
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
'storeCountry' => $storeCountry,
'countries' => $storeCountry
@@ -300,6 +314,30 @@ public function placeOrder(string $locale, Request $request): JsonResponse
], 422);
}
// Same check Lunar's own ValidateCartForOrderCreation runs inside
// initiatePayment() (a product unpublished/deleted after it was
// added to the cart) — checked here first so the shopper is told
// which product is the problem, rather than falling into the
// catch-all "complete your billing/shipping details" message below,
// which is what actually happened and is generic to every
// CartException reason, misleading when the real cause is a line,
// not an address.
$unavailableLines = $this->cart->activeLines($cart)->filter(
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
);
if ($unavailableLines->isNotEmpty()) {
$names = $unavailableLines
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
->filter()
->implode(', ');
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
], 422);
}
// The one incomplete-cart case worth a specific message + pointing the
// shopper at the right section: a region resolving 2+ methods needs an
// explicit pick (no auto-select), easy to miss since nothing else on
+102
View File
@@ -5,10 +5,14 @@
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Review\Models\ProductReview;
class ProductController extends Controller
{
@@ -38,6 +42,8 @@ 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;
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
@@ -112,6 +118,40 @@ private function buildOptionPicker(int $productId): array
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
@@ -141,4 +181,66 @@ public function checkStock(string $locale, Request $request): JsonResponse
'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' => [],
]);
}
}