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' => [],
]);
}
}
Generated
+5 -5
View File
@@ -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",
@@ -0,0 +1,110 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `validation` translation lines — Laravel's own error-message group
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
* resolved by every `Validator::make()`/`$request->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<string, array{0: string, 1: string}>
*/
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'],
];
}
}
+5
View File
@@ -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;
+2
View File
@@ -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)
}
@@ -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' })
}
}
@@ -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 <form>
// 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))
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -5,7 +5,7 @@
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
--}}
<div class="bbk-cart" data-controller="bbk-cart" hidden>
<div class="bbk-cart-backdrop" data-action="bbk-cart#close"></div>
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
<aside
class="bbk-cart-panel"
@@ -12,17 +12,34 @@
// the shopper picked, not just the product in general.
$thumb = $variant?->getThumbnailImage() ?: null;
$variantLabel = $variant?->getOption();
// Not routed through checkout::'s own locale-explicit convention — this
// is a storefront route, so it follows the storefront's own (implicit
// locale) call shape, same as App\Catalog\ProductCard. Carries the
// variant id along so the product page can restore the same option the
// shopper actually has in their cart, not just default to the first one
// (see product-form-controller.js reading ?variant= on connect()).
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
@endphp
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media">
@if ($thumb)
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
</a>
@else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@endif
@endif
</div>
<div class="bbk-cart-item-detail">
<p class="bbk-cart-item-title">{{ $name }}</p>
@if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
@else
<p class="bbk-cart-item-title">{{ $name }}</p>
@endif
@if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif
@@ -1,15 +1,14 @@
@props(['review'])
<article class="flex flex-col gap-3 py-6 border-b border-black last:border-0">
<article class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<x-reviews-stars :rating="$review['rating']" :size="18" />
<span class="font-bold">
{{ $review['name'] ?? 'Ανώνυμος' }}
</span>
</div>
<time class="text-sm text-neutral-500 shrink-0">{{ $review['date'] }}</time>
<x-reviews-stars :rating="$review['rating']" :size="23" />
<div class="flex items-end gap-4">
<span class="text-2xl font-bold leading-none">
{{ $review['name'] ?? 'Ανώνυμος' }}
</span>
<time class="text-sm text-neutral-500 shrink-0 leading-none pb-0.5">{{ $review['date'] }}</time>
</div>
<p class="leading-relaxed">{{ $review['text'] }}</p>
@@ -24,4 +23,16 @@ class="w-24 h-24 object-cover border border-black"
</div>
@endif
@if (!empty($review['reply']))
<div class="mt-2 pl-4 border-l-2 border-neutral-300 flex flex-col gap-1">
<p class="text-sm font-bold">
{{ __('storefront.review.reply') }}
@if (!empty($review['replyDate']))
<span class="font-normal text-neutral-500">— {{ $review['replyDate'] }}</span>
@endif
</p>
<p class="leading-relaxed">{{ $review['reply'] }}</p>
</div>
@endif
</article>
@@ -1,23 +1,28 @@
@props(['product'])
@php $oldRating = (int) old('rating', 0); @endphp
<form
method="POST"
action="#"
action="{{ route('product.reviews.store', ['locale' => app()->getLocale(), 'product' => $product['id']]) }}"
class="flex flex-col gap-8 mt-10"
data-controller="star-rating"
data-star-rating-rating-value="{{ $oldRating }}"
data-action="submit->star-rating#validate"
>
@csrf
{{-- Rating --}}
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true">
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true" :error="$errors->first('rating')">
<div
id="rating"
data-controller="star-rating"
class="flex items-center gap-1.5 text-brand"
role="radiogroup"
aria-label="{{ __('storefront.review.rating') }}"
aria-required="true"
aria-describedby="rating-client-error"
>
<input type="hidden" name="rating" value="0" data-star-rating-target="input">
<input type="hidden" name="rating" value="{{ $oldRating }}" data-star-rating-target="input">
@for($i = 1; $i <= 5; $i++)
<button
type="button"
@@ -25,7 +30,7 @@ class="flex items-center gap-1.5 text-brand"
data-value="{{ $i }}"
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
aria-pressed="false"
aria-pressed="{{ $i === $oldRating ? 'true' : 'false' }}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -41,24 +46,21 @@ class="w-6 h-6"
</button>
@endfor
</div>
<p id="rating-client-error" data-star-rating-target="error" class="text-sm text-red-600" role="alert" hidden>{{ __('storefront.review.rating_required') }}</p>
</x-ui.field>
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true">
<x-ui.textarea id="review-content" name="content" :required="true" />
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true" :error="$errors->first('content')">
<x-ui.textarea id="review-content" name="content" :required="true" :rows="6">{{ old('content') }}</x-ui.textarea>
</x-ui.field>
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')">
<x-ui.input id="review-name" name="name" autocomplete="name" />
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')" :error="$errors->first('name')">
<x-ui.input id="review-name" name="name" autocomplete="name" :value="old('name')" />
</x-ui.field>
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')">
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" />
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')" :error="$errors->first('email')">
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" :value="old('email')" />
</x-ui.field>
<x-ui.checkbox id="review-save-info" name="save_info" >
<span class="text-sm">{{ __('storefront.review.save_info') }}</span>
</x-ui.checkbox>
<div>
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
</div>
@@ -3,6 +3,7 @@
'count' => 0,
'showCount' => false,
'size' => 24,
'linkable' => false,
])
<div class="flex items-center gap-3" {{ $attributes }}>
@@ -28,9 +29,17 @@ class="flex items-center gap-1.5 text-brand"
</div>
@if ($showCount && $count > 0)
<span class="text-sm text-neutral-500">
({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }})
</span>
@php $countText = '(' . trans_choice('storefront.customer_reviews', $count, ['count' => $count]) . ')'; @endphp
@if ($linkable)
<button
type="button"
data-action="click->review-count#activate"
class="text-sm text-neutral-500 hover:underline cursor-pointer"
aria-controls="tab-panel-reviews"
>{{ $countText }}</button>
@else
<span class="text-sm text-neutral-500">{{ $countText }}</span>
@endif
@endif
</div>
+7 -6
View File
@@ -1,9 +1,10 @@
@props(['tabs' => [], 'size' => 'md'])
@props(['tabs' => [], 'size' => 'md', 'active' => null])
@php
$sizeClasses = match($size) {
'lg' => 'text-[36px] font-extrabold [--slide-h:5px]',
default => 'text-xl font-bold [--slide-h:3px]',
};
$activeId = $active ?? ($tabs[0]['id'] ?? null);
@endphp
{{--
@@ -21,22 +22,22 @@
<div data-controller="tabs" {{ $attributes }}>
<div class="flex gap-10 mb-8" role="tablist">
@foreach($tabs as $i => $tab)
@foreach($tabs as $tab)
<button
type="button"
role="tab"
data-tabs-target="button"
data-action="click->tabs#show"
data-panel="{{ $tab['id'] }}"
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-active' : '' }}"
aria-selected="{{ $i === 0 ? 'true' : 'false' }}"
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $tab['id'] === $activeId ? 'is-active' : '' }}"
aria-selected="{{ $tab['id'] === $activeId ? 'true' : 'false' }}"
aria-controls="tab-panel-{{ $tab['id'] }}"
id="tab-btn-{{ $tab['id'] }}"
>{{ $tab['label'] }}</button>
@endforeach
</div>
@foreach($tabs as $i => $tab)
@foreach($tabs as $tab)
@php $panelKey = $tab['id']; $panel = $$panelKey ?? null; @endphp
<div
role="tabpanel"
@@ -44,7 +45,7 @@ class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-ac
id="tab-panel-{{ $tab['id'] }}"
aria-labelledby="tab-btn-{{ $tab['id'] }}"
tabindex="0"
@if($i !== 0) hidden @endif
@if($tab['id'] !== $activeId) hidden @endif
>{{ $panel }}</div>
@endforeach
</div>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>@yield('title', '3Dealer')</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
<o:AllowPNG/>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse !important; }
.email-container { width: 600px !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet" type="text/css">
<!--<![endif]-->
<style>
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; background-color: #e5e5e5; }
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
#MessageViewBody, #MessageWebViewDiv { width: 100% !important; }
u + #email-body a { color: inherit; text-decoration: none; }
@media only screen and (max-width: 480px) {
.email-container { width: 100% !important; max-width: 100% !important; }
.email-header { padding: 24px 20px 12px !important; }
.email-card { padding: 28px 20px !important; }
.email-footer { padding: 0 20px 32px !important; }
}
</style>
</head>
<body id="email-body" style="margin:0;padding:0;background-color:#e5e5e5;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#e5e5e5;">
@yield('preheader', '')
&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
</div>
<center role="article" aria-roledescription="email" style="width:100%;background-color:#e5e5e5;">
<!--[if mso]>
<table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" bgcolor="#e5e5e5"><tr><td>
<![endif]-->
<table role="presentation" class="email-container" width="100%" cellpadding="0" cellspacing="0" bgcolor="#e5e5e5" style="max-width:600px;margin:0 auto;">
<tr>
<td class="email-header" align="center" style="padding:32px 24px 16px;">
<a href="{{ url('/') }}" target="_blank" style="text-decoration:none;">
<img src="{{ asset('images/logo.png') }}" width="140" height="123" alt="3Dealer" style="display:inline-block;border:0;outline:none;text-decoration:none;height:auto;max-width:140px;width:100%;">
</a>
</td>
</tr>
<tr>
<td style="padding:0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="background-color:#ffffff;border-radius:12px;">
<tr>
<td class="email-card" style="padding:40px 32px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#000000;text-align:left;">
@yield('content')
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="email-footer" align="center" style="padding:24px 24px 40px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#7a7a7a;">
@yield('footer')
<p style="margin:12px 0 0;">
<a href="{{ url('/') }}" style="color:#7a7a7a;text-decoration:underline;">3dealer.gr</a>
&nbsp;&middot;&nbsp;&copy; {{ date('Y') }} 3Dealer
</p>
</td>
</tr>
</table>
<!--[if mso]>
</td></tr></table>
<![endif]-->
</center>
</body>
</html>
+15 -2
View File
@@ -149,7 +149,14 @@ class="absolute bottom-6 right-8 text-white text-sm"
{{ $product['name'] }}
</h1>
<x-reviews-stars :rating="$product['reviews']['average_rating'] ?? 0" :count="$product['reviews']['count']" :showCount="true" />
<x-reviews-stars
:rating="$product['reviews']['average_rating'] ?? 0"
:count="$product['reviews']['count']"
:showCount="true"
:linkable="true"
data-controller="review-count"
data-review-count-tabs-outlet="#product-tabs"
/>
@if($product['price'] !== null)
<p class="text-2xl font-bold" data-product-form-target="price">
@@ -196,7 +203,7 @@ class="flex items-stretch gap-10"
</div>
<x-ui.tabs class="mt-16" size="lg" :tabs="[
<x-ui.tabs id="product-tabs" class="mt-16 scroll-mt-28" size="lg" :active="request('tab')" :tabs="[
['id' => 'description', 'label' => __('storefront.product.description')],
['id' => 'reviews', 'label' => __('storefront.product.reviews') . ' (' . $product['reviews']['count'] . ')'],
]">
@@ -211,6 +218,10 @@ class="flex items-stretch gap-10"
</ul>
</x-slot>
<x-slot name="reviews">
@if(session('reviewSubmitted'))
<p class="mb-8 text-sm font-semibold" role="status">{{ __('storefront.review.thank_you') }}</p>
@endif
@if(!empty($product['reviews']['items']))
<div class="mb-10">
@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
</div>
+31
View File
@@ -0,0 +1,31 @@
@extends('emails.layout')
@section('title', 'Ο κωδικός σύνδεσής σου')
@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}")
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Ο κωδικός σύνδεσής σου
</h1>
<p style="margin:0 0 16px;">Γεια σου {{ $name }},</p>
<p style="margin:0 0 24px;">Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στο διαχειριστικό του 3dealer.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border-radius:8px;padding:20px;">
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#ffffff;">{{ $code }}</span>
</td>
</tr>
</table>
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για περιορισμένο χρονικό διάστημα και μπορεί να χρησιμοποιηθεί μία μόνο φορά.</p>
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email &mdash; ο λογαριασμός σου παραμένει ασφαλής.</p>
@endsection
@section('footer')
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο διαχειριστικό του 3dealer.</p>
@endsection
+4
View File
@@ -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',
);