From 24851184c42796e1bfa424bdff7a7bd30c9d42a5 Mon Sep 17 00:00:00 2001 From: elvira Date: Tue, 22 Sep 2026 19:00:34 +0300 Subject: [PATCH] temp emails, reviews theming and form, minor change in checkout module, validation translations seeder --- .../Checkout/CheckoutController.php | 40 ++++++- app/Http/Controllers/ProductController.php | 102 ++++++++++++++++ composer.lock | 10 +- .../seeders/ValidationTranslationsSeeder.php | 110 ++++++++++++++++++ resources/css/checkout.css | 5 + resources/js/stimulus/index.js | 2 + .../js/stimulus/review-count-controller.js | 12 ++ .../js/stimulus/star-rating-controller.js | 19 ++- resources/js/stimulus/tabs-controller.js | 4 +- resources/views/checkout/drawer.blade.php | 2 +- .../checkout/partials/cart-line.blade.php | 21 +++- .../views/components/review-card.blade.php | 29 +++-- .../views/components/review-form.blade.php | 32 ++--- .../views/components/reviews-stars.blade.php | 15 ++- resources/views/components/ui/tabs.blade.php | 13 ++- resources/views/emails/layout.blade.php | 97 +++++++++++++++ resources/views/product/show.blade.php | 17 ++- .../views/vendor/core/auth/mail/otp.blade.php | 31 +++++ routes/web.php | 4 + 19 files changed, 518 insertions(+), 47 deletions(-) create mode 100644 database/seeders/ValidationTranslationsSeeder.php create mode 100644 resources/js/stimulus/review-count-controller.js create mode 100644 resources/views/emails/layout.blade.php create mode 100644 resources/views/vendor/core/auth/mail/otp.blade.php diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index 52c95bf..878855a 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -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 diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 315d2bd..db8c259 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -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' => [], + ]); + } } diff --git a/composer.lock b/composer.lock index 7bca38a..8c32ab3 100644 --- a/composer.lock +++ b/composer.lock @@ -3491,16 +3491,16 @@ }, { "name": "league/commonmark", - "version": "2.10.1", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "shasum": "" }, "require": { @@ -3594,7 +3594,7 @@ "type": "tidelift" } ], - "time": "2026-09-07T13:44:26+00:00" + "time": "2026-09-21T13:07:34+00:00" }, { "name": "league/config", diff --git a/database/seeders/ValidationTranslationsSeeder.php b/database/seeders/ValidationTranslationsSeeder.php new file mode 100644 index 0000000..e7a2367 --- /dev/null +++ b/database/seeders/ValidationTranslationsSeeder.php @@ -0,0 +1,110 @@ +validate()` call in the app + * (see CheckoutController::saveAddress(), CartController, ProductController), + * not just the checkout module. + * + * Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's + * LocalizationServiceProvider) merges this group over Laravel's file-based + * validation.php, which the project doesn't ship a `lang/` copy of — so without + * this, Greek requests fall back to Laravel's untranslated English defaults. + * Only the rule keys and field attributes actually in use are seeded; add more + * as new rules/fields show up. + * + * Additive and idempotent: a key that already exists is left untouched, so + * anything edited in the Filament Language Lines UI wins on a re-run. Runs + * explicitly — `php artisan db:seed --class=ValidationTranslationsSeeder` — it + * is not wired into DatabaseSeeder. + * + * Greek copy uses the project's informal register (εσύ/σου). + */ +class ValidationTranslationsSeeder extends Seeder +{ + public function run(): void + { + $translations = app(TranslationService::class); + + foreach ($this->lines() as $key => [$en, $el]) { + $exists = LanguageLine::query() + ->where('group', 'validation') + ->where('key', $key) + ->exists(); + + if ($exists) { + $this->command?->warn("validation.{$key} already exists — skipped"); + + continue; + } + + $translations->create('validation', $key, ['en' => $en, 'el' => $el]); + $this->command?->info("validation.{$key} added"); + } + } + + /** + * key => [English, Greek]. + * + * @return array + */ + private function lines(): array + { + return [ + // ── Rule messages ───────────────────────────────────────────── + 'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'], + 'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'], + 'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'], + 'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'], + 'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'], + 'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'], + 'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'], + 'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'], + 'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'], + + // ── Field names (checkout: billing/shipping address) ────────── + 'attributes.contact_email' => ['email', 'email'], + 'attributes.billing_first_name' => ['first name', 'όνομα'], + 'attributes.billing_last_name' => ['last name', 'επώνυμο'], + 'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'], + 'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'], + 'attributes.billing_line_one' => ['address', 'διεύθυνση'], + 'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'], + 'attributes.billing_city' => ['city', 'πόλη'], + 'attributes.billing_state' => ['region', 'νομό / περιοχή'], + 'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'], + 'attributes.billing_country_id' => ['country', 'χώρα'], + 'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'], + 'attributes.shipping_first_name' => ['first name', 'όνομα'], + 'attributes.shipping_last_name' => ['last name', 'επώνυμο'], + 'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'], + 'attributes.shipping_line_one' => ['address', 'διεύθυνση'], + 'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'], + 'attributes.shipping_city' => ['city', 'πόλη'], + 'attributes.shipping_state' => ['region', 'νομό / περιοχή'], + 'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'], + 'attributes.shipping_country_id' => ['country', 'χώρα'], + 'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'], + 'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'], + + // ── Field names (cart) ───────────────────────────────────────── + 'attributes.purchasable_id' => ['product', 'προϊόν'], + 'attributes.quantity' => ['quantity', 'ποσότητα'], + 'attributes.code' => ['coupon code', 'κωδικό κουπονιού'], + + // ── Field names (product reviews / stock check) ──────────────── + 'attributes.variant' => ['variant', 'παραλλαγή'], + 'attributes.rating' => ['rating', 'βαθμολογία'], + 'attributes.content' => ['review text', 'κείμενο κριτικής'], + 'attributes.name' => ['name', 'όνομα'], + 'attributes.email' => ['email', 'email'], + ]; + } +} diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 41c52ad..2fa900f 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -200,10 +200,15 @@ .bbk-cart-item-media img { .bbk-cart-item-detail { min-width: 0; } .bbk-cart-item-title { + display: block; margin: 0 0 0.25rem; font-weight: 600; + color: inherit; + text-decoration: none; } +a.bbk-cart-item-title:hover { text-decoration: underline; } + .bbk-cart-item-variant { margin: 0 0 0.25rem; font-size: 0.8125rem; diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index d9341e6..1883e16 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -15,6 +15,7 @@ import ProductFormController from './product-form-controller' import ProductGalleryController from './product-gallery-controller' import QuantityController from './quantity-controller' import RangeSliderController from './range-slider-controller' +import ReviewCountController from './review-count-controller' import StarRatingController from './star-rating-controller' import TabsController from './tabs-controller' @@ -31,6 +32,7 @@ export function registerControllers(application) { application.register('product-gallery', ProductGalleryController) application.register('quantity', QuantityController) application.register('range-slider', RangeSliderController) + application.register('review-count', ReviewCountController) application.register('star-rating', StarRatingController) application.register('tabs', TabsController) } diff --git a/resources/js/stimulus/review-count-controller.js b/resources/js/stimulus/review-count-controller.js new file mode 100644 index 0000000..5091091 --- /dev/null +++ b/resources/js/stimulus/review-count-controller.js @@ -0,0 +1,12 @@ +import { Controller } from '@hotwired/stimulus' + +// The review count sits next to the star rating, outside the tabs markup — +// too far apart in the DOM for a plain data-action, hence the outlet. +export default class extends Controller { + static outlets = ['tabs'] + + activate() { + this.tabsOutlet.activate('reviews') + this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } +} diff --git a/resources/js/stimulus/star-rating-controller.js b/resources/js/stimulus/star-rating-controller.js index e7ceb14..bdea748 100644 --- a/resources/js/stimulus/star-rating-controller.js +++ b/resources/js/stimulus/star-rating-controller.js @@ -1,9 +1,25 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { - static targets = ['star', 'input'] + static targets = ['star', 'input', 'error'] static values = { rating: { type: Number, default: 0 } } + connect() { + this.#fill(this.ratingValue) + } + + // Bound to the form's submit event (this controller sits on the
+ // itself, not just the star widget) — a plain `required` on the hidden + // rating input would never surface: browsers exclude type="hidden" from + // constraint validation entirely, so there'd be nothing to see or hear. + validate(event) { + if (this.ratingValue < 1) { + event.preventDefault() + this.errorTarget.hidden = false + this.starTargets[0]?.focus() + } + } + hover(event) { this.#fill(parseInt(event.currentTarget.dataset.value)) } @@ -16,6 +32,7 @@ export default class extends Controller { const val = parseInt(event.currentTarget.dataset.value) this.ratingValue = val this.inputTarget.value = val + this.errorTarget.hidden = true this.starTargets.forEach(star => { star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val)) diff --git a/resources/js/stimulus/tabs-controller.js b/resources/js/stimulus/tabs-controller.js index 019ae22..2907c6b 100644 --- a/resources/js/stimulus/tabs-controller.js +++ b/resources/js/stimulus/tabs-controller.js @@ -4,10 +4,10 @@ export default class extends Controller { static targets = ['button', 'panel'] show(event) { - this.#activate(event.currentTarget.dataset.panel) + this.activate(event.currentTarget.dataset.panel) } - #activate(panelId) { + activate(panelId) { this.buttonTargets.forEach(btn => { const active = btn.dataset.panel === panelId btn.classList.toggle('is-active', active) diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php index 0da4bb2..24a98ed 100644 --- a/resources/views/checkout/drawer.blade.php +++ b/resources/views/checkout/drawer.blade.php @@ -5,7 +5,7 @@ .bbk-* classes from its own stylesheet. No host components, no Tailwind. --}} - @@ -211,6 +218,10 @@ class="flex items-stretch gap-10" + @if(session('reviewSubmitted')) +

{{ __('storefront.review.thank_you') }}

+ @endif + @if(!empty($product['reviews']['items']))
@foreach($product['reviews']['items'] as $review) @@ -220,6 +231,8 @@ class="flex items-stretch gap-10" 'date' => $review['reviewed_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['reviewed_at'])->translatedFormat('d M Y') : '', 'text' => $review['body'], 'image' => $review['media'][0]['url'] ?? null, + 'reply' => $review['reply'] ?? null, + 'replyDate' => $review['replied_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['replied_at'])->translatedFormat('d M Y') : null, ]" /> @endforeach
diff --git a/resources/views/vendor/core/auth/mail/otp.blade.php b/resources/views/vendor/core/auth/mail/otp.blade.php new file mode 100644 index 0000000..411a06a --- /dev/null +++ b/resources/views/vendor/core/auth/mail/otp.blade.php @@ -0,0 +1,31 @@ +@extends('emails.layout') + +@section('title', 'Ο κωδικός σύνδεσής σου') + +@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}") + +@section('content') +

+ Ο κωδικός σύνδεσής σου +

+ +

Γεια σου {{ $name }},

+ +

Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στο διαχειριστικό του 3dealer.

+ + + + + +
+ {{ $code }} +
+ +

Ο κωδικός ισχύει για περιορισμένο χρονικό διάστημα και μπορεί να χρησιμοποιηθεί μία μόνο φορά.

+ +

Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email — ο λογαριασμός σου παραμένει ασφαλής.

+@endsection + +@section('footer') +

Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο διαχειριστικό του 3dealer.

+@endsection diff --git a/routes/web.php b/routes/web.php index 3509120..908da3d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,6 +33,10 @@ 'product.stock-check', ); + Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name( + 'product.reviews.store', + ); + Route::get('/category/{id}', [CategoryController::class, 'show'])->name( 'category.show', );