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
+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