28 Commits
Author SHA1 Message Date
elvira 540da1af24 reviews photos clickable and contact form functionality 2026-09-24 21:03:59 +03:00
elvira e28faea546 3dealer favicon 2026-09-24 19:31:36 +03:00
elvira 8055c003ac reverted a migration and some 3dealer functionality that should be in core 2026-09-24 19:22:40 +03:00
elvira 94803bfb67 terms acceptance during login, checkout connection to user account and login process 2026-09-24 19:08:13 +03:00
elvira cf3681260b account pages: login, order history, order details, account details, wishlist 2026-09-24 17:27:58 +03:00
elvira 580adac33a product custom fields 2026-09-23 17:53:23 +03:00
elvira a4240b6474 composer 2026-09-23 14:46:20 +03:00
arvanitakis 326e1fac54 Updating search.php for indexing 2026-09-23 09:48:25 +03:00
elvira e3a6267059 fix in product page links and tabs, fixes in email template, minor change in reviews theming 2026-09-22 19:18:55 +03:00
elvira 24851184c4 temp emails, reviews theming and form, minor change in checkout module, validation translations seeder 2026-09-22 19:00:34 +03:00
elvira a107184010 product options layout and add to cart button in homepage 2026-09-21 18:53:45 +03:00
elvira af0245de43 ux fix on product increase 2026-09-17 21:56:58 +03:00
elvira afa1993c53 stock check, variants in cart, variant buttons in product page 2026-09-17 21:52:39 +03:00
arvanitakis fe55cb5f33 Fix: Removing Duplicate Event Dispatching for UserCreated model 2026-09-16 23:22:32 +03:00
arvanitakis 038ea05d56 Feature: translations on regions and countries on payment methods 2026-09-15 23:53:59 +03:00
elvira ba6d68c5b9 payment fix 2026-09-15 19:20:59 +03:00
elvira 1598f053ee payment fix 2026-09-15 15:04:40 +03:00
arvanitakis 279339a095 Feat: Updating config for boboko order updates 2026-09-14 20:17:34 +03:00
elvira df9374a070 autosave issue in checkout - pt1 2026-09-14 20:13:40 +03:00
elvira 97c12efdb1 removed resetShippingManifest() workaround from CheckoutController 2026-09-14 16:30:45 +03:00
elvira 2cac70c53a payment beginning (stripe) 2026-09-09 19:16:20 +03:00
elvira 46b3f29674 marketing consent to recovery consent and connection with core 2026-09-09 14:42:23 +03:00
elvira 7a8b9cf4c0 marketing consent 2026-09-09 14:09:59 +03:00
elvira 0fb606ff3f checkout and shipping part 2026-09-09 13:51:00 +03:00
elvira 2d85591a43 checkout process - start 2026-09-08 20:41:49 +03:00
elvira 7577426f49 cart drawer and general structure 2026-09-04 19:39:37 +03:00
elvira c5f7b28aa0 Merge branch 'elv' into cart-temp 2026-09-04 13:21:58 +03:00
elvira 6c7d11ca78 minor change in contact page 2026-09-04 12:58:09 +03:00
133 changed files with 7969 additions and 806 deletions
+1
View File
@@ -59,6 +59,7 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
CONTACT_EMAIL=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
+29
View File
@@ -0,0 +1,29 @@
# 3dealer
Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md).
## Scheduled jobs
These run in the `scheduler` container (`php artisan schedule:work`).
| Command | When | What it does |
|---|---|---|
| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. |
| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. |
## Product custom fields
Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line.
| Field type | Storefront input | Limit |
|---|---|---|
| Short text | text input | 255 characters |
| Long text | textarea | 2,000 characters |
| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB |
- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`.
- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours.
- **Emails show text answers only**, never photos.
- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely.
- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page.
- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`.
+11 -1
View File
@@ -19,7 +19,7 @@ final class ProductCard
{
/**
* @param array<string, mixed> $product one item from ProductService's localized array shape
* @return array{name: ?string, price: ?string, image: ?string, href: string}
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool}
*/
public static function fromIndexed(array $product): array
{
@@ -28,6 +28,16 @@ public static function fromIndexed(array $product): array
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['id' => $product['id']]),
// The card's quick "Add to cart" always adds this variant, same
// default Modules\Core\Catalog\Services\ProductService::
// variantSummaries() and product/show.blade.php both use — no
// picker at listing-grid scope, unlike the product page's own
// color swatches.
'variantId' => $product['variants'][0]['id'] ?? null,
// A product with custom fields (photo upload, engraving text…)
// can't be quick-added from a card — the card links to the
// product page instead, even when every field is optional.
'hasCustomFields' => ! empty($product['custom_fields']),
];
}
}
+11 -3
View File
@@ -17,7 +17,14 @@
*/
final class ProductListingPage
{
private const PER_PAGE = 12;
private const PER_PAGE = 40;
/** Category pages rarely have enough products to fill a page, so this
* ceiling is high enough to act as "no pagination" in practice — the
* pagination component self-hides via hasPages() when everything fits.
* If a category ever exceeds it, pagination reappears as a safety net
* rather than silently truncating results. */
private const CATEGORY_PER_PAGE = 200;
public function __construct(
private readonly ProductService $products,
@@ -33,6 +40,7 @@ public function __construct(
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
{
$filters = $listing->filters($collectionId);
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
// Listing reads from the Meilisearch index via ProductService/
// ProductSearchService, not Eloquent. Both return a
@@ -45,12 +53,12 @@ public function build(ProductListing $listing, Closure $url, ?int $collectionId
query: $query,
filters: $filters,
sort: $listing->sort,
perPage: self::PER_PAGE,
perPage: $perPage,
page: $listing->page,
)
: $this->products->list(
filters: $filters,
perPage: self::PER_PAGE,
perPage: $perPage,
page: $listing->page,
sort: $listing->sort,
);
@@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\CustomFieldUploadController;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Lunar\Models\CartLine;
use Lunar\Models\OrderLine;
/**
* Custom-field photos are uploaded the moment a shopper picks them (see
* CustomFieldUploadController), before add-to-cart — so a shopper who picks a
* photo and then leaves, or picks a different one, leaves a file nobody
* references. This deletes those: any upload older than the grace period that
* no cart line or order line's meta.custom_fields points to.
*
* The grace period covers a shopper still on the product page with a picked
* but not-yet-added photo. A file referenced by a cart line is kept for as
* long as that cart line exists; once the line (or its cart) is removed, the
* next run deletes the file. Order line references are kept indefinitely.
*/
class PruneCustomFieldUploads extends Command
{
protected $signature = 'custom-fields:prune-uploads {--hours=24 : Only delete unreferenced uploads older than this}';
protected $description = 'Delete custom-field photo uploads not referenced by any cart or order line';
public function handle(): int
{
$disk = Storage::disk(CustomFieldUploadController::DISK);
$referenced = $this->referencedPaths();
$cutoff = now()->subHours((int) $this->option('hours'))->getTimestamp();
$deleted = 0;
foreach ($disk->files(CustomFieldUploadController::DIRECTORY) as $path) {
if (isset($referenced[$path]) || $disk->lastModified($path) > $cutoff) {
continue;
}
$disk->delete($path);
$deleted++;
}
$this->info("Deleted {$deleted} unreferenced custom-field upload(s).");
return self::SUCCESS;
}
/**
* @return array<string, true>
*/
private function referencedPaths(): array
{
$paths = [];
foreach ([CartLine::class, OrderLine::class] as $model) {
$model::query()
->where('meta', 'like', '%custom_fields%')
->select('meta')
->cursor()
->each(function ($line) use (&$paths) {
foreach ($line->meta['custom_fields'] ?? [] as $field) {
if (! empty($field['path'])) {
$paths[$field['path']] = true;
}
}
});
}
return $paths;
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Lunar\Models\Country;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* The profile page: name, invoice details, the one address, and account
* deletion. The customer keeps a single address, flagged as both the
* shipping and billing default, so allowing several later is a UI change
* only (the data is already Lunar's own addresses table).
*/
class AccountController extends Controller
{
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
private const STORE_COUNTRY_ISO3 = 'GRC';
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
public function __construct(
private readonly CustomerAccountService $account,
) {}
public function show(string $locale, Request $request): View
{
$user = $request->user();
$customer = $this->account->customer($user);
return view('account.show', [
'user' => $user,
'customer' => $customer,
'address' => $this->defaultAddress($user),
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
]);
}
public function update(string $locale, Request $request): RedirectResponse
{
$user = $request->user();
$country = $this->storeCountry();
// The address is all-or-nothing: typing any part of it makes the rest
// (and the name, which Lunar requires on every address) required.
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
$data = $request->validate([
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'invoice' => ['boolean'],
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
'state' => [
'nullable',
'string',
'required_with:'.$anyAddressField,
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
],
'contact_phone' => ['nullable', 'string', 'max:30'],
'recovery_consent' => ['boolean'],
]);
$invoice = $request->boolean('invoice');
$customer = $this->account->updateProfile($user, [
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'company_name' => $invoice ? $data['company_name'] : null,
]);
// Written directly: core's updateProfile() allowlists `vat_no`, but
// Lunar's column is `tax_identifier`, so it can't go through there yet.
$customer->update(['tax_identifier' => $invoice ? $data['tax_identifier'] : null]);
if (filled($data['line_one'] ?? null)) {
$addressData = [
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'country_id' => $country->id,
'contact_email' => $user->email,
'shipping_default' => true,
'billing_default' => true,
];
$existing = $this->defaultAddress($user);
$existing
? $this->account->updateAddress($user, $existing->id, $addressData)
: $this->account->createAddress($user, $addressData);
}
$this->updateRecoveryConsent($customer, $request->boolean('recovery_consent'));
return redirect()->route('account')->with('status', __('storefront.account.saved'));
}
/**
* "Email me a reminder if I don't finish my order", as a standing choice.
* Stored on the customer in the same meta shape the checkout writes (see
* CheckoutController::rememberRecoveryConsent()), and applied to the
* current cart too, so opting out stops reminders for it right away.
*/
private function updateRecoveryConsent($customer, bool $consent): void
{
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
// Only an existing cart; never create one just to record this.
$cart = app(CartService::class)->current();
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
app(CheckoutService::class)->setRecoveryConsent($consent);
}
}
/**
* Self-service deletion: opens core's 30-day grace-period erasure request
* (which blocks the login right away) and logs out. Logging back in within
* the grace period cancels it; see core's docs/privacy.md.
*/
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
{
$user = $request->user();
$privacy->requestErasureForUser($user, $user);
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
}
private function defaultAddress($user)
{
$addresses = collect($this->account->addresses($user));
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
}
private function storeCountry(): Country
{
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
}
}
@@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Mail\EmailChangeCodeMail;
use App\Mail\EmailChangedNoticeMail;
use App\Services\GuestOrderClaimer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* Changing the login email: new email → 6-digit code sent to that address →
* switched. The email is the login, so it only changes once the shopper has
* proved they can receive mail there; a typo can never lock them out.
*
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
*
* Core has no email-change flow, so the pending change lives in the session
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
*/
class EmailController extends Controller
{
private const SESSION_KEY = 'email_change';
private const EXPIRY_MINUTES = 10;
private const MAX_ATTEMPTS = 5;
private const SEND_LIMIT = 3;
private const SEND_DECAY_SECONDS = 600;
public function edit(string $locale, Request $request): View
{
return view('account.email', ['user' => $request->user()]);
}
public function send(string $locale, Request $request): RedirectResponse
{
$user = $request->user();
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
$validated = $request->validate([
'email' => [
'required',
'email',
'max:255',
Rule::notIn([$user->email]),
Rule::unique($user->getTable(), 'email')->ignore($user->id),
],
], [
'email.not_in' => __('storefront.account.email_same'),
'email.unique' => __('storefront.account.email_taken'),
]);
if (! $this->sendCode($request, $validated['email'])) {
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
}
return redirect()->route('account.email.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
return redirect()->route('account.email.edit');
}
return view('account.email-code', ['email' => $pending['email']]);
}
public function resend(string $locale, Request $request): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
return redirect()->route('account.email.edit');
}
if (! $this->sendCode($request, $pending['email'])) {
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
}
return back()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, GuestOrderClaimer $orders): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
return redirect()->route('account.email.edit');
}
$validated = $request->validate(['code' => ['required', 'digits:6']]);
$valid = $pending['code_hash'] !== null
&& now()->timestamp < $pending['expires_at']
&& Hash::check($validated['code'], $pending['code_hash']);
if (! $valid) {
// Too many wrong guesses burns the code; only "resend" helps then.
$pending['attempts']++;
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
$pending['code_hash'] = null;
}
$request->session()->put(self::SESSION_KEY, $pending);
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$user = $request->user();
// Someone may have signed up with this address since the code was sent.
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
}
$oldEmail = $user->email;
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
// Lets the owner notice if someone else changed it from a hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
// The code just proved they own the new address too.
$orders->claim($user);
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
}
private function sendCode(Request $request, string $email): bool
{
$limiterKey = 'email-change:'.$request->user()->id;
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
return false;
}
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$request->session()->put(self::SESSION_KEY, [
'email' => $email,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
]);
Mail::to($email)->send(new EmailChangeCodeMail($code));
return true;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Services\CustomerAccountService;
/**
* Order history. Every lookup goes through CustomerAccountService, which only
* ever returns the logged-in user's own placed orders, so an order id from
* the URL can't reach someone else's order.
*/
class OrderController extends Controller
{
public function __construct(
private readonly CustomerAccountService $account,
) {}
public function index(string $locale, Request $request): View
{
return view('account.orders.index', [
'orders' => $this->account->orders($request->user(), 15),
]);
}
public function show(string $locale, Request $request, int $orderId): View
{
try {
$order = $this->account->order($request->user(), $orderId);
} catch (OrderNotFoundException) {
abort(404);
}
// By type, like the checkout confirmation: the method may since have
// been disabled, but the order still shows what was used.
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
return view('account.orders.show', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
]);
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Services\UserOtpService;
/**
* Passwordless customer login: email → emailed 6-digit code → logged in.
* Login and registration are the same flow — UserOtpService::generateAndSend()
* find-or-creates the user (and its Customer).
*
* All the security lives in UserOtpService: per-email code-request throttling
* (OtpThrottledException), wrong-guess lockout, the Auth::login() itself (which
* also regenerates the session and merges the guest cart via Lunar's Login
* listener) and the session-registry record. This controller only moves the
* shopper between the two steps, carrying the email in the session rather
* than the URL.
*/
class LoginController extends Controller
{
/**
* `?redirect=/el/checkout` (e.g. from the checkout's login tab) becomes the
* intended URL that verify() returns to. Only a same-site path is accepted:
* no scheme, no protocol-relative `//host`, so it can't redirect off-site.
*/
public function create(string $locale, Request $request): View
{
$redirect = (string) $request->query('redirect', '');
if (preg_match('#^/(?![/\\\\])#', $redirect)) {
$request->session()->put('url.intended', url($redirect));
}
return view('auth.login');
}
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
]);
$email = Str::lower(trim($validated['email']));
try {
$otp->generateAndSend($email);
} catch (OtpThrottledException) {
return back()->withInput()->withErrors([
'email' => __('storefront.auth.too_many_codes'),
]);
}
$request->session()->put('login.email', $email);
return redirect()->route('login.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$email = $request->session()->get('login.email');
if (! $email) {
return redirect()->route('login');
}
return view('auth.login-code', ['email' => $email]);
}
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
$email = $request->session()->get('login.email');
if (! $email) {
return redirect()->route('login');
}
try {
$otp->generateAndSend($email);
} catch (OtpThrottledException) {
return back()->withErrors([
'code' => __('storefront.auth.too_many_codes'),
]);
}
return back()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
$email = $request->session()->get('login.email');
if (! $email) {
return redirect()->route('login');
}
$validated = $request->validate([
'code' => ['required', 'digits:6'],
]);
// Wrong, expired, or locked out after too many guesses — core doesn't
// say which, so neither do we; the page offers "resend code" for all three.
if (! $otp->validate($email, $validated['code'], $request)) {
return back()->withErrors([
'code' => __('storefront.auth.invalid_code'),
]);
}
$request->session()->forget('login.email');
return redirect()->intended(route('home'));
}
public function destroy(string $locale, Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('home');
}
}
@@ -0,0 +1,233 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Closure;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
use Modules\Core\Cart\Services\CartService;
/**
* Thin storefront cart endpoints for the checkout module. Every action mutates
* the session cart via boboko-core's CartService and returns the same
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
* that fragment in place (no JSON, no client-side templating). $cart / $lines
* for the partial come from the view composer in CheckoutModuleServiceProvider.
*/
class CartController extends Controller
{
public function __construct(
private readonly CartService $cart,
) {}
/**
* CartException here is Lunar's own add_to_cart validation pipeline
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
* "not enough stock at this quantity" for a tracked (purchasable =
* in_stock) variant. Its own message is an untranslated, hardcoded
* English string not meant for storefront display, so this returns our
* own translated one instead rather than passing it through — a
* storefront.* key rather than checkout.*, since this is a catalog/stock
* concern the storefront owns, not something specific to the portable
* checkout module.
*/
public function add(string $locale, Request $request): View|JsonResponse
{
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
'custom_fields' => ['nullable', 'array'],
]);
$variant = ProductVariant::findOrFail($data['purchasable_id']);
try {
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
} catch (ValidationException $e) {
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
}
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
} catch (CartException) {
return $this->stockError($variant);
}
return view('checkout::partials.cart-body');
}
/**
* The shopper's answers to the product's custom fields (boboko-core's
* Product::$custom_fields — {key, type: text|textarea|file, label,
* required}), as cart line meta. Lunar copies CartLine.meta onto the
* OrderLine at order creation, so this is also what the order keeps.
*
* Only keys the product actually defines are kept, nested under
* `custom_fields` — line meta also carries behavior flags (core's
* `saved_for_later` zeroes the line's price), so shopper input must never
* be merged into it directly. Label and type are snapshotted alongside
* each value so the cart/order still reads correctly if the product's
* fields are edited later.
*
* A `file` answer is the opaque reference returned by the host's upload
* endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't
* decide which files are acceptable (that's the host's upload endpoint) —
* it only checks the reference is genuine and the file still exists.
*
* Two adds with identical answers merge into one line (Lunar matches
* existing lines on meta); different answers stay separate lines.
*/
private function customFieldsMeta(ProductVariant $variant, array $input): array
{
$fields = collect($variant->product?->custom_fields ?? [])->keyBy('key');
if ($fields->isEmpty()) {
return [];
}
$validated = Validator::make(
$input,
$fields->map(fn (array $field) => [
($field['required'] ?? false) ? 'required' : 'nullable',
'string',
match ($field['type']) {
'textarea' => 'max:2000',
'file' => function (string $attribute, mixed $value, Closure $fail) {
if ($this->decodeUpload($value) === null) {
$fail('validation.uploaded')->translate();
}
},
default => 'max:255',
},
])->all(),
[],
$fields->map(fn (array $field) => $field['label'])->all(),
)->validate();
$answers = $fields
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
->map(fn (array $field) => [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
...($field['type'] === 'file'
? $this->fileAnswer($this->decodeUpload($validated[$field['key']]))
: ['value' => $validated[$field['key']]]),
])
->values()
->all();
return $answers === [] ? [] : ['custom_fields' => $answers];
}
/**
* @return array{disk: string, path: string, name: string, mime: ?string}|null
*/
private function decodeUpload(string $reference): ?array
{
try {
$upload = json_decode(Crypt::decryptString($reference), true);
} catch (DecryptException) {
return null;
}
if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) {
return null;
}
return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null;
}
private function fileAnswer(array $upload): array
{
return [
'value' => $upload['name'],
'disk' => $upload['disk'],
'path' => $upload['path'],
'mime' => $upload['mime'] ?? null,
];
}
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
{
$quantity = (int) $request->validate([
'quantity' => ['required', 'integer', 'min:0'],
])['quantity'];
try {
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
} catch (CartException) {
$variant = CartLine::find($line)?->purchasable;
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
}
return view('checkout::partials.cart-body');
}
/**
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
* checked against (stock, for a tracked in_stock variant) — telling the
* shopper how many are actually left beats a generic "not enough stock"
* they'd otherwise have to guess around by trial and error.
*/
private function stockError(?ProductVariant $variant): JsonResponse
{
$available = $variant?->getTotalInventory() ?? 0;
return response()->json([
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
], 422);
}
public function remove(string $locale, int $line): View
{
$this->cart->removeLine($line);
return view('checkout::partials.cart-body');
}
/**
* A bad code is a normal, expected outcome here (typo, expired code), not
* an error state for the request — it re-renders the same cart-body
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
* + swap in bbk-cart-controller stays the one code path for every cart
* mutation.
*/
public function applyCoupon(string $locale, Request $request): View
{
$code = $request->validate([
'code' => ['required', 'string'],
])['code'];
$couponError = false;
try {
$this->cart->applyCoupon($code);
} catch (InvalidCouponException) {
$couponError = true;
}
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
}
public function removeCoupon(string $locale): View
{
$this->cart->removeCoupon();
return view('checkout::partials.cart-body');
}
}
@@ -0,0 +1,699 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Facades\CartSession;
use Lunar\Models\Cart;
use Lunar\Models\Country;
use Lunar\Models\Order;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* The checkout page — one page, sections (contact / billing / shipping /
* shipping method), reviewed against boboko-core's CheckoutService. Stops
* short of payment for this slice (see project memory).
*
* The address form and the shipping-method radios **autosave** — no submit
* buttons. `saveAddress()` / `selectShippingOption()` are called by
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
* server-rendered fragments (shipping options + order summary) plus any
* field errors, rather than redirecting. Address validation is deliberately
* lenient — nothing is rejected mid-typing; required-field enforcement is the
* job of the (not-yet-built) "Continue to payment" gate.
*
* Guests type their email; the login tab links to the storefront's own
* `login` route and back. Logged in: the email is the account's (forced in
* saveAddress()), the first visit prefills addresses from the account
* (prefillFromAccount()), and Lunar's Login listener has already attached the
* cart, so the placed order lands in the account's history.
*
* Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden
* field, forced server-side). Set it to null for the full country picker (the
* portable path for a multi-country boboko store).
*/
class CheckoutController extends Controller
{
private const STORE_COUNTRY_ISO3 = 'GRC';
public function __construct(
private readonly CartService $cart,
private readonly CheckoutService $checkout,
private readonly CustomerAccountService $account,
) {}
public function show(string $locale): View
{
$cart = $this->cart->current();
$lines = $cart ? $this->cart->activeLines($cart) : collect();
$storeCountry = $this->storeCountry();
$shippingOptions = collect();
// Captured before prefillFromAccount(), which may recreate the address
// row (dropping its shipping_option) — same reason as in saveAddress().
$previousOption = $cart?->shippingAddress?->shipping_option;
if ($cart && Auth::check()) {
$cart = $this->prefillFromAccount($cart);
// Nothing chosen on this cart yet: carry over the account's standing
// opt-in (an explicit earlier choice, recorded with its own
// timestamp/policy version). Never opts anyone in by default.
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
$cart = $this->checkout->setRecoveryConsent(true);
}
}
if ($cart?->shippingAddress) {
$shippingOptions = $this->syncShipping($cart, $previousOption);
// Cart's CachesProperties::refresh() explicitly nulls total/
// subTotal/shippingTotal/etc. back to their defaults — every
// Lunar call site pairs it with recalculate() for exactly that
// reason. Bare refresh() here was leaving $cart->total null on
// reload, which fed a 0 amount straight into the Stripe Element.
$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' => $paymentMethods,
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
'storeCountry' => $storeCountry,
'countries' => $storeCountry
? collect()
: Country::orderBy('name')->get(['id', 'name']),
'regions' => $storeCountry
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
: collect(),
]);
}
public function saveAddress(string $locale, Request $request): JsonResponse
{
$storeCountry = $this->storeCountry();
$sameAsBilling = $request->boolean('same_as_billing');
// Only the fields shipping rates resolve against — if none of these
// changed (shopper edited their name, phone, email, …) there's no point
// re-quoting shipping or re-rendering the summary.
$addressBefore = $this->cart->current()?->shippingAddress;
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
// setShippingAddress() below always deletes + recreates this row (see
// syncShipping()'s docblock) — capture what was selected NOW, before
// it's gone, so it can be carried forward onto the fresh row.
$previousOption = $addressBefore?->shipping_option;
$stateRule = $storeCountry
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
: ['nullable', 'string', 'max:255'];
$countryRule = $storeCountry
? ['nullable']
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
// Lenient — only format checks. Anything that fails is simply left out
// of what gets persisted, and reported back for inline display.
$validator = Validator::make($request->all(), [
'contact_email' => ['nullable', 'email'],
'billing_first_name' => ['nullable', 'string', 'max:255'],
'billing_last_name' => ['nullable', 'string', 'max:255'],
'billing_company_name' => ['nullable', 'string', 'max:255'],
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
'billing_line_one' => ['nullable', 'string', 'max:255'],
'billing_city' => ['nullable', 'string', 'max:255'],
'billing_state' => $stateRule,
'billing_postcode' => ['nullable', 'string', 'max:20'],
'billing_country_id' => $countryRule,
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_first_name' => ['nullable', 'string', 'max:255'],
'shipping_last_name' => ['nullable', 'string', 'max:255'],
'shipping_line_one' => ['nullable', 'string', 'max:255'],
'shipping_city' => ['nullable', 'string', 'max:255'],
'shipping_state' => $stateRule,
'shipping_postcode' => ['nullable', 'string', 'max:20'],
'shipping_country_id' => $countryRule,
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
]);
$errors = $validator->errors()->toArray();
$data = $validator->valid();
// Logged in: the order email is always the account's. It isn't a field
// on the page then, and a submitted value isn't trusted.
if ($user = Auth::user()) {
$data['contact_email'] = $user->email;
}
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
// Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields
// stay in the DOM (just hidden) when it isn't, so ignore what they send.
$wantsInvoice = $request->boolean('wants_invoice');
$billing = [
'first_name' => $data['billing_first_name'] ?? null,
'last_name' => $data['billing_last_name'] ?? null,
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
'line_one' => $data['billing_line_one'] ?? null,
'city' => $data['billing_city'] ?? null,
'state' => $data['billing_state'] ?? null,
'postcode' => $data['billing_postcode'] ?? null,
'country_id' => $billingCountryId,
'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['billing_contact_phone'] ?? null,
];
$shipping = $sameAsBilling
? [
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
]
: [
'first_name' => $data['shipping_first_name'] ?? null,
'last_name' => $data['shipping_last_name'] ?? null,
'line_one' => $data['shipping_line_one'] ?? null,
'city' => $data['shipping_city'] ?? null,
'state' => $data['shipping_state'] ?? null,
'postcode' => $data['shipping_postcode'] ?? null,
'country_id' => $shippingCountryId,
'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['shipping_contact_phone'] ?? null,
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
];
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'ship_to_billing' => $sameAsBilling,
'wants_invoice' => $wantsInvoice,
];
$cart->save();
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
// Deliberately its own scope, not merged with any future newsletter opt-in.
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
if (Auth::check()) {
$this->rememberRecoveryConsent($request->boolean('recovery_consent'));
}
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
$rateChanged = $rateKeyAfter != $rateKeyBefore;
// setShippingAddress() above always deletes and recreates the
// CartAddress row (Lunar's AddAddress action), which drops whatever
// shipping_option was previously selected — regardless of whether the
// rate-determining fields actually changed. So this always has to run
// to restore/re-validate it, even on a save that only touched e.g. the
// phone number. Only the fragment RE-RENDER is skippable when nothing
// rate-relevant moved — the re-select itself is not optional.
$options = $this->syncShipping($cart, $previousOption);
if (! $rateChanged) {
return $this->fragments($cart, null, $errors);
}
return $this->fragments($cart, $options, $errors);
}
public function selectShippingOption(string $locale, Request $request): JsonResponse
{
$identifier = (string) $request->input('shipping_option');
try {
$this->checkout->selectShippingOption($identifier);
} catch (InvalidShippingOptionException) {
// Re-render with whatever is currently valid; no hard error surfaced.
}
$cart = $this->cart->current();
$options = $cart?->shippingAddress
? $this->checkout->getShippingOptions()
: collect();
return $this->fragments($cart, $options);
}
/**
* Autosave-select a payment method (radio change). Persists it via
* CheckoutService (which also records it on Cart::meta and re-snapshots
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
*/
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
{
$type = (string) $request->input('payment_type');
try {
$this->checkout->selectPaymentMethod($type);
} catch (UnknownPaymentTypeException) {
// Radio value out of sync with what's offered — ignore, the summary
// just won't reflect a method fee. place-order re-checks properly.
}
return response()->json([
'summaryHtml' => view('checkout::partials.cart-body')->render(),
]);
}
/**
* The real submit — the hard gate. Re-selects the payment method (fresh
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
* creates the draft order, records terms acceptance, and charges the driver.
* Returns JSON the bbk-payment controller routes on:
* { redirect } — placed, go to confirmation
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
* { status: 'failed', message } — declined
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
*/
public function placeOrder(string $locale, Request $request): JsonResponse
{
if (! $request->boolean('terms_accepted')) {
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
try {
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
} catch (UnknownPaymentTypeException) {
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
}
$cart = $this->cart->current();
// Captured now, before initiatePayment() can place the order — Lunar's
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
// (i.e. has an order with placed_at set), which happens synchronously
// for an immediately-captured payment. Any later $this->cart->current()
// call in this same flow (here, or in a subsequent orderStatus() poll
// once the 3-D Secure webhook sets placed_at) would then resolve to
// that fresh, order-less cart instead of the one that was just placed.
// Storing the real cart id ourselves, under our own session key,
// sidesteps CartSession entirely for the rest of the placement flow.
session(['checkout.cart_id' => $cart?->id]);
// Lunar's own ValidateCartForOrderCreation (order_create validator)
// never checks for this — an empty cart with a valid billing address
// sails straight through it and would place a real, zero-line order.
// The disabled "place order" button is only the client-side half of
// this fix; this is the half that actually matters.
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.cart_empty'),
], 422);
}
// Lenient autosave never requires these; this is the gate.
if (data_get($cart, 'meta.wants_invoice')
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.invoice_required'),
], 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
// the page demands it. Everything else CartException catches below.
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.shipping_method_required'),
'field' => 'shipping_option',
], 422);
}
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
$data = $request->filled('payment_method')
? ['payment_method' => (string) $request->input('payment_method')]
: [];
try {
$result = $this->checkout->initiatePayment(
$fingerprint,
termsAccepted: true,
policyVersion: (string) config('legal.terms_version'),
data: $data,
);
} catch (FingerprintMismatchException) {
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
} catch (CartException $e) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.payment_incomplete_details'),
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
], 422);
} catch (TermsNotAcceptedException) {
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
// Pending with no continuation (cash-on-delivery, or any other
// deferred/offline method) means CheckoutService::initiatePayment()
// already created the placed order — money just hasn't changed
// hands yet. Only a Pending WITH a continuation (Stripe's client
// secret) means the shopper still has something to do before the
// order exists as far as the storefront is concerned.
return match (true) {
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
$result->status === PaymentResultStatus::Pending => response()->json([
'status' => 'pending',
'clientSecret' => $result->continuation?->value,
]),
default => response()->json([
'status' => 'failed',
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
'retriable' => $result->retriable,
], 422),
};
}
/**
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
* -> ApplyResolvedPaymentStatus sets placed_at.
*/
public function orderStatus(string $locale): JsonResponse
{
$order = $this->placedOrder();
if (! $order) {
return response()->json(['placed' => false]);
}
session(['checkout.order_id' => $order->id]);
CartSession::forget();
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
}
public function confirmation(string $locale): View|RedirectResponse
{
$orderId = session('checkout.order_id');
$order = $orderId
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
: null;
if (! $order) {
return redirect()->route('products', $locale);
}
// Looked up by type rather than a stored relation — the method may since
// have been disabled/deleted, but the order still needs to show what was
// actually used at the time.
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
return view('checkout::confirmation', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
]);
}
private function orderPlacedResponse(string $locale): JsonResponse
{
if ($order = $this->placedOrder()) {
session(['checkout.order_id' => $order->id]);
}
CartSession::forget();
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
}
private function placedOrder(): ?Order
{
$cartId = session('checkout.cart_id');
if ($cartId === null) {
return null;
}
return Order::where('cart_id', $cartId)
->whereNotNull('placed_at')
->latest('placed_at')
->first();
}
/**
* Re-resolve shipping options for the cart's current address and keep the
* selection sane: auto-select when exactly one resolves, or carry a
* previous pick forward when it's still among the resolved options.
*
* $previousOption must be captured by the CALLER before setShippingAddress()
* runs — Lunar's AddAddress action always deletes and recreates the
* CartAddress row on every save (see saveAddress()), so by the time this
* runs, $address->shipping_option is unconditionally null regardless of
* what was selected a moment ago. There is nothing meaningful left to read
* off $address itself; $previousOption is the only source of truth for
* "what was chosen before this save wiped the row." show() passes the
* address's own (not-just-wiped) current value, since nothing recreated
* anything in that path.
*
* Always (re-)applies the resolved target via selectShippingOption() rather
* than comparing against the (always-blank, post-recreation) current value
* — the fresh row needs the write regardless of whether the decision
* "which option" actually changed.
*
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
*/
private function syncShipping(Cart $cart, ?string $previousOption): Collection
{
if (! $cart->shippingAddress) {
return collect();
}
$options = $this->checkout->getShippingOptions();
$target = match (true) {
$options->count() === 1 => $options->first()->identifier,
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
default => null,
};
if ($target !== null) {
try {
$this->checkout->selectShippingOption($target);
} catch (InvalidShippingOptionException) {
// $target came from $options itself — shouldn't happen, stay defensive
}
}
return $options;
}
/**
* $options === null means "nothing money-relevant changed" — acknowledge the
* save (and any field errors) without re-rendering the shipping options or
* the order summary, so a plain name/phone edit is a cheap round-trip.
*/
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
{
return response()->json([
'errors' => collect($errors)
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
->all(),
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $options,
])->render(),
// Composer (CheckoutModuleServiceProvider) fills $cart / $lines.
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
]);
}
/**
* Logged-in shopper: fills any BLANK cart address field from the account
* (name, saved default address, phone, email), on every checkout load, so
* an account filled in after checkout started still shows up. Never
* overwrites anything already in the cart.
*
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
* (meta.account_prefilled): someone who then clears them or unticks the
* box for this order shouldn't get them back on the next reload.
*
* Writes only when something actually changes, so a normal reload costs
* nothing extra.
*/
private function prefillFromAccount(Cart $cart): Cart
{
$user = Auth::user();
$customer = $this->account->customer($user);
$addresses = collect($this->account->addresses($user));
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
$firstPass = ! data_get($cart, 'meta.account_prefilled');
$fromAccount = array_filter([
'first_name' => $customer?->first_name ?: $saved?->first_name,
'last_name' => $customer?->last_name ?: $saved?->last_name,
'line_one' => $saved?->line_one,
'city' => $saved?->city,
'state' => $saved?->state,
'postcode' => $saved?->postcode,
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
'contact_email' => $user->email,
'contact_phone' => $saved?->contact_phone,
], 'filled');
$invoice = $firstPass
? array_filter([
'company_name' => $customer?->company_name,
'tax_identifier' => $customer?->tax_identifier,
], 'filled')
: [];
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
$fillBlanks = function (?array $current, array $values) {
$current ??= [];
foreach ($values as $key => $value) {
if (blank($current[$key] ?? null)) {
$current[$key] = $value;
}
}
return $current;
};
$billingBefore = $cart->billingAddress?->only($fields);
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
$shipping = $shipToBilling
? [
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
]
: $fillBlanks($shippingBefore, $fromAccount);
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
}
if ($firstPass) {
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'account_prefilled' => true,
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
];
$cart->save();
}
return $cart;
}
/**
* The shopper's latest reminder choice, kept on their customer record
* (meta, same shape CheckoutService::setRecoveryConsent() writes on the
* cart) so their next checkout starts from it. The storefront's account
* page reads/writes the same keys. Candidate for a boboko-core method.
*/
private function rememberRecoveryConsent(bool $consent): void
{
$customer = $this->account->customer(Auth::user());
if (! $customer || (bool) data_get($customer, 'meta.recovery_consent') === $consent) {
return;
}
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
private function storeCountry(): ?Country
{
if (self::STORE_COUNTRY_ISO3 === null) {
return null;
}
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Serves a file answer to a product custom field (a cart/order line's
* meta.custom_fields, see CartController::customFieldsMeta()) from its private
* disk. Only reachable through a temporary signed URL — generated per render
* by checkout::partials.line-custom-fields — so disk and path in the query
* can't be tampered with, and a link stops working once it expires.
*/
class CustomFieldFileController extends Controller
{
public function __invoke(string $locale, Request $request): StreamedResponse
{
$disk = Storage::disk((string) $request->query('disk'));
$path = (string) $request->query('path');
abort_unless($disk->exists($path), 404);
return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']);
}
}
+62 -1
View File
@@ -2,10 +2,71 @@
namespace App\Http\Controllers;
use App\Http\Requests\ContactRequest;
use App\Mail\ContactConfirmationMail;
use App\Mail\ContactMessageMail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View;
use Throwable;
/**
* Contact form. Nothing is stored: the message is emailed to the store
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
* synchronously so a failed store email can be reported back on the form.
*
* Limited per IP in here rather than with throttle middleware, so the limit
* shows as a message on the form instead of a bare 429 page.
*/
class ContactController extends Controller
{
public function index(string $locale)
private const SEND_LIMIT = 3;
private const SEND_DECAY_SECONDS = 600;
public function index(string $locale): View
{
return view('contact');
}
public function send(string $locale, ContactRequest $request): RedirectResponse
{
$key = 'contact:'.$request->ip();
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
}
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
$data = $request->validated();
$to = config('services.contact.email');
try {
if (blank($to)) {
throw new \RuntimeException('CONTACT_EMAIL is not set.');
}
Mail::to($to)->send(new ContactMessageMail(
$data['name'],
$data['email'],
$data['message'],
$locale,
));
} catch (Throwable $e) {
Log::error('Contact form: store email failed', ['exception' => $e]);
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
}
// The store already has the message, so a failed confirmation is only logged.
try {
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
} catch (Throwable $e) {
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
}
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Validator;
/**
* Stores the shopper's photo for a product custom field of type `file` (see
* boboko-core's Product::$custom_fields) the moment it's picked on the product
* page — before add-to-cart, see custom-field-upload-controller.js.
*
* Which files are acceptable is a per-site decision (this site: photographs),
* so it lives here in the storefront, not in the checkout module. The module's
* add-to-cart endpoint only ever receives the opaque `reference` returned
* below — an encrypted {disk, path, name, mime} payload, so a shopper can
* neither forge a reference to some other private file nor read the path —
* and copies it onto the cart line's meta (see Checkout\CartController::
* customFieldsMeta()).
*
* Stored on the private `local` disk: these are customers' personal photos,
* never reachable by a public URL. Uploads nobody added to a cart are removed
* by the custom-fields:prune-uploads command (see PruneCustomFieldUploads).
*/
class CustomFieldUploadController extends Controller
{
public const DISK = 'local';
public const DIRECTORY = 'custom-field-uploads';
public const MAX_KILOBYTES = 10240;
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
/**
* For the file input's `accept` attribute — same list the server enforces.
*/
public static function accept(): string
{
return '.'.implode(',.', self::EXTENSIONS);
}
public function store(string $locale, Request $request): JsonResponse
{
// `label` is the admin-authored field label, only used as the
// :attribute in the validation message shown next to that field.
$validator = Validator::make(
$request->all(),
['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]],
[],
['file' => (string) $request->input('label', 'file')],
);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
$file = $request->file('file');
$path = $file->store(self::DIRECTORY, self::DISK);
abort_if($path === false, 500);
return response()->json([
'reference' => Crypt::encryptString(json_encode([
'disk' => self::DISK,
'path' => $path,
'name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
])),
]);
}
}
+199 -5
View File
@@ -4,8 +4,15 @@
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
{
@@ -35,18 +42,205 @@ 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;
$variantsData = $this->products->variantSummaries($product);
$firstVariant = $product['variants'][0] ?? null;
$option = $firstVariant['options'][0]['option'] ?? null;
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
return view('product.show', [
'collection' => $collection,
'product' => $product,
'option' => $option,
'productOptions' => $productOptions,
'variantsData' => $variantsData,
]);
}
/**
* 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(),
// 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];
}
/**
* 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
* 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(),
]);
}
/**
* 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' => [],
]);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Services\Wishlist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService;
class WishlistController extends Controller
{
public function __construct(
private readonly Wishlist $wishlist,
) {}
/**
* The account's wishlist page.
*/
public function index(string $locale, ProductService $products): View
{
return view('account.wishlist', ['products' => $this->products($products)]);
}
/**
* The same list for a guest, from their cookie. Logged-in users are sent
* to the account version.
*/
public function guest(string $locale, ProductService $products): View|RedirectResponse
{
if (auth()->check()) {
return redirect()->route('account.wishlist');
}
return view('wishlist.guest', ['products' => $this->products($products)]);
}
/**
* Adds or removes a product, for guests and logged-in shoppers alike. The
* heart button's Stimulus controller asks for JSON; without JS the form
* posts normally and comes back to the same page.
*/
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
{
abort_unless(Product::whereKey($productId)->exists(), 404);
$active = $this->wishlist->toggle($productId);
if ($request->expectsJson()) {
return response()->json(['active' => $active]);
}
return back();
}
/**
* Product cards for the current wishlist, newest first. Products no longer
* in the search index (deleted, unpublished) are simply skipped.
*/
private function products(ProductService $products): Collection
{
return collect($this->wishlist->ids())
->map(fn (int $id) => $products->getById($id))
->filter()
->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)])
->values();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ContactRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'max:5000'],
];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Listeners;
use App\Services\GuestOrderClaimer;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. UserAuthenticated only fires after a valid login code.
*/
class ClaimGuestOrdersOnLogin
{
public function __construct(
private readonly GuestOrderClaimer $claimer,
) {}
public function handle(UserAuthenticated $event): void
{
$this->claimer->claim($event->user);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Listeners;
use App\Services\Wishlist;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. Runs inside the login request, so it can read the guest
* wishlist cookie and queue its removal.
*/
class MergeGuestWishlistOnLogin
{
public function __construct(
private readonly Wishlist $wishlist,
) {}
public function handle(UserAuthenticated $event): void
{
$this->wishlist->mergeGuestInto($event->user);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Listeners;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Customer\Services\CustomerAccountService;
/**
* When a logged-in shopper places an order and their account has no saved
* address yet, the order's shipping address (and their name, if the profile
* has none) becomes the account's. Never overwrites anything already saved.
*
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
* webhook, where nobody is logged in. Picked up by listener discovery.
*/
class SaveAddressFromFirstOrder
{
public function __construct(
private readonly CustomerAccountService $account,
) {}
public function handle(OrderPlaced $event): void
{
$order = $event->order;
$user = $order->user;
$shipping = $order->shippingAddress;
if (! $user || ! $shipping || ! $shipping->line_one) {
return;
}
$customer = $this->account->customer($user);
if (! $customer) {
return;
}
if (! $customer->first_name && ! $customer->last_name) {
$this->account->updateProfile($user, [
'first_name' => $shipping->first_name,
'last_name' => $shipping->last_name,
]);
}
if (collect($this->account->addresses($user))->isNotEmpty()) {
return;
}
$this->account->createAddress($user, [
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
'contact_email' => $user->email,
'shipping_default' => true,
'billing_default' => true,
]);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to whoever submitted the contact form. Deliberately generic: it never
* repeats what they wrote, so the form can't be used to deliver arbitrary text
* to someone else's inbox.
*/
class ContactConfirmationMail extends Mailable
{
public function envelope(): Envelope
{
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
}
public function content(): Content
{
return new Content(view: 'emails.contact-confirmation');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
* sender, so replying from the inbox goes straight to them.
*/
class ContactMessageMail extends Mailable
{
public function __construct(
public readonly string $senderName,
public readonly string $senderEmail,
public readonly string $body,
public readonly string $senderLocale,
) {}
public function envelope(): Envelope
{
return new Envelope(
replyTo: [new Address($this->senderEmail, $this->senderName)],
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
);
}
public function content(): Content
{
return new Content(view: 'emails.contact-message');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The code that confirms a new login email — see Account\EmailController.
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
}
public function content(): Content
{
return new Content(view: 'emails.email-change-code');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once the login email has changed (see
* Account\EmailController), so the owner notices a takeover. The new address
* is shown masked, e.g. "n•••@example.com".
*/
class EmailChangedNoticeMail extends Mailable
{
public readonly string $maskedEmail;
public function __construct(string $newEmail)
{
[$local, $domain] = explode('@', $newEmail, 2);
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Το email του λογαριασμού σου άλλαξε');
}
public function content(): Content
{
return new Content(view: 'emails.email-changed-notice');
}
}
-4
View File
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
protected $dispatchesEvents = [
'created' => UserCreated::class,
];
/**
* The attributes that are mass assignable.
*
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* One product on a logged-in user's wishlist. Guests' wishlists live in a
* cookie instead — see App\Services\Wishlist.
*/
class WishlistItem extends Model
{
protected $fillable = ['user_id', 'product_id'];
}
+7
View File
@@ -17,6 +17,13 @@
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// One instance per request: it caches the guest cookie's ids, so a
// toggle and a later has() in the same request agree.
$this->app->scoped(\App\Services\Wishlist::class);
}
public function boot(): void
{
Telemetry::optOut();
@@ -0,0 +1,53 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Modules\Core\Cart\Services\CartService;
/**
* The seam for the (currently in-repo) cart + checkout module.
*
* Everything the module needs to boot inside a host app lives here: its view /
* component namespaces, its routes, and the composer that feeds the
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
* here — same as storefront.* elsewhere in this app, they resolve through
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
* manually there rather than shipped as lang/ files. The module's own files
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
* 3dealer-specific.
*
* When the module is extracted to boboko-core, this class is deleted and its
* body becomes the package's own ServiceProvider. The only other host wiring
* is the one registerCheckout() call in resources/js/app.js and the two lines
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
*/
class CheckoutModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
Route::middleware('web')->group(base_path('routes/checkout.php'));
// The drawer is rendered on every page (from the layout) and its body
// partial is re-rendered on every cart mutation — both need the current
// cart without a controller in the loop.
View::composer(
['checkout::drawer', 'checkout::partials.cart-body'],
function (ViewInstance $view) {
$service = app(CartService::class);
$cart = $service->current();
$view->with('cart', $cart);
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
},
);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Order;
/**
* Attaches placed guest orders to an account when their billing email matches
* the account's email. Only ever called right after the shopper has proved
* they own that email (a login code, or the code confirming an email change),
* which is what makes matching on email safe.
*
* Orders already belonging to any customer or user are never touched.
*/
class GuestOrderClaimer
{
public function claim(Authenticatable $user): int
{
$customer = $user->latestCustomer();
if (! $customer || ! $user->email) {
return 0;
}
return Order::query()
->whereNotNull('placed_at')
->whereNull('customer_id')
->whereNull('user_id')
->whereHas('billingAddress', fn ($query) => $query
->whereRaw('lower(contact_email) = ?', [strtolower($user->email)]))
->update([
'customer_id' => $customer->id,
'user_id' => $user->id,
]);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Services;
use App\Models\WishlistItem;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
/**
* The current shopper's wishlist, product ids only.
*
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
* is written to the database for anonymous visitors. On login the cookie is
* merged into the account and cleared (MergeGuestWishlistOnLogin).
*/
class Wishlist
{
public const COOKIE = 'wishlist';
private const COOKIE_MINUTES = 60 * 24 * 365;
// Keeps the cookie well under the 4KB browser limit.
private const GUEST_MAX = 100;
/** @var array<int>|null ids for this request, including a toggle just made */
private ?array $guestIds = null;
/** @return array<int> newest first */
public function ids(): array
{
if ($user = Auth::user()) {
return WishlistItem::where('user_id', $user->id)
->latest('id')
->pluck('product_id')
->all();
}
return $this->guestIds();
}
public function has(int $productId): bool
{
return in_array($productId, $this->ids(), true);
}
/**
* @return bool whether the product is on the wishlist afterwards
*/
public function toggle(int $productId): bool
{
if ($user = Auth::user()) {
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
if ($deleted) {
return false;
}
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
return true;
}
$ids = $this->guestIds();
if (in_array($productId, $ids, true)) {
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
return false;
}
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
return true;
}
public function remove(int $productId): void
{
if ($this->has($productId)) {
$this->toggle($productId);
}
}
/**
* Moves the guest cookie's products onto $user's wishlist and clears it.
*/
public function mergeGuestInto(Authenticatable $user): void
{
$ids = $this->guestIds();
if ($ids === []) {
return;
}
// Oldest first, so the newest cookie item also ends up newest here.
foreach (array_reverse($ids) as $productId) {
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
}
$this->guestIds = [];
Cookie::queue(Cookie::forget(self::COOKIE));
}
/** @return array<int> */
private function guestIds(): array
{
if ($this->guestIds !== null) {
return $this->guestIds;
}
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
return $this->guestIds = is_array($decoded)
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
: [];
}
/** @param array<int> $ids */
private function storeGuestIds(array $ids): void
{
$this->guestIds = $ids;
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
}
}
+12 -1
View File
@@ -11,7 +11,18 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
// Laravel's priority list would otherwise run `auth` before core's
// `locale` middleware, so the redirects below would build URLs before
// URL::defaults(['locale' => …]) is set, throwing a missing-parameter error.
$middleware->prependToPriorityList(
before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class,
);
// Both resolve inside the {locale} group, after the `locale` middleware
// has set URL::defaults(['locale' => …]), so route() needs no locale arg.
$middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo(fn () => route('home'));
})
->withExceptions(function (Exceptions $exceptions): void {
//
+2
View File
@@ -1,9 +1,11 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\CheckoutModuleServiceProvider;
use App\Providers\PanelServiceProvider;
return [
AppServiceProvider::class,
PanelServiceProvider::class,
CheckoutModuleServiceProvider::class,
];
Generated
+233 -431
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
<?php
/*
* Per-site settings for the cart + checkout module (see
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
* publishable config when the module moves to boboko-core.
*/
return [
/*
* Name of the storefront's login route. The checkout's login tab and the
* confirmation page link to it with `?redirect=<checkout path>`, so the
* login page must send the shopper back there afterwards (3dealer's
* Auth\LoginController does). null: no login offered in checkout at all.
*/
'login_route' => 'login',
];
+94 -7
View File
@@ -58,11 +58,23 @@
| make sense for the store you're building.
|
*/
'draft_status' => 'awaiting-payment',
'draft_status' => 'awaiting_payment',
// Two sequences — carrier and store-pickup — see Modules\Core\Order\
// Services\OrderStatusFlow, the single source of truth for which
// transitions are actually offered from a given status. Payment
// timing (prepaid vs. cash-on-delivery) does not affect this
// sequence at all — see Order::paid/paid_at instead, an entirely
// independent field with no status value of its own.
//
// 'favourite' controls whether a status gets its own tab in the order
// list topbar (Lunar\Admin\...\ListOrders::getDefaultTabs()) — set
// only on the main-journey statuses so the topbar isn't cluttered
// with every branch/exception value; every status (favourite or not)
// remains reachable via the table's own status filter/search.
'statuses' => [
'awaiting-payment' => [
'awaiting_payment' => [
'label' => 'Awaiting Payment',
'color' => '#848a8c',
'mailers' => [],
@@ -70,17 +82,25 @@
'favourite' => true,
],
'payment-offline' => [
'label' => 'Payment Offline',
'processing' => [
'label' => 'Processing',
'color' => '#0A81D7',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'payment-received' => [
'label' => 'Payment Received',
'color' => '#6a67ce',
'ready_for_dispatch' => [
'label' => 'Ready for Dispatch',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'ready_for_pickup' => [
'label' => 'Ready for Pickup',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => true,
@@ -88,11 +108,78 @@
'dispatched' => [
'label' => 'Dispatched',
'color' => '#0A81D7',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'delivery_failed' => [
'label' => 'Delivery Failed',
'color' => '#c0392b',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'picked_up' => [
'label' => 'Picked Up',
'color' => '#3f7a5c',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
// The parcel arriving and the return window opening are the same
// moment — one status value covers both.
'delivered' => [
'label' => 'Delivered',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'completed' => [
'label' => 'Completed',
'color' => '#3f7a5c',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'return_requested' => [
'label' => 'Return Requested',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'returned' => [
'label' => 'Returned',
'color' => '#c0392b',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'partially_refunded' => [
'label' => 'Partially Refunded',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'refunded' => [
'label' => 'Refunded',
'color' => '#c0392b',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
],
/*
+1 -1
View File
@@ -46,6 +46,6 @@
| to include in the count below.
|
*/
'order_count_statuses' => ['payment-received'],
'order_count_statuses' => ['processing'],
];
+25 -1
View File
@@ -20,7 +20,17 @@
Lunar\Models\Collection::class,
Lunar\Models\Customer::class,
Lunar\Models\Order::class,
Lunar\Models\Product::class,
// Modules\Core\Catalog\Models\Product, not Lunar\Models\Product —
// both share the same underlying Meilisearch index name, so
// listing the base class here too would make every reindex
// (this default list is always merged in, even when a specific
// model is passed on the CLI — see Lunar\Console\Commands\
// ScoutIndexerCommand::handle()) run the base class's indexing a
// second time right after the subclass's, silently overwriting
// every document with one missing custom_fields/order_count
// (the whole reason Modules\Core\Catalog\Models\Product exists —
// see its own docblock). Caught in practice.
Modules\Core\Catalog\Models\Product::class,
Lunar\Models\ProductOption::class,
/*
@@ -49,6 +59,20 @@
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
// Lunar\Models\Product::class, NOT Modules\Core\Catalog\Models\
// Product::class — Lunar\Base\Traits\Searchable::indexer() (and
// getFilterableAttributes()/getSortableAttributes(), same trait)
// reads `$config[self::class]`, and `self::class` inside a TRAIT
// METHOD is a compile-time literal bound to whichever class first
// `use`s the trait — Lunar\Models\Product, since the subclass
// never re-declares indexer() itself — regardless of which
// instance actually calls the method at runtime. Keying this by
// the subclass here made the lookup miss entirely, silently
// falling back to Lunar\Search\ScoutIndexer's own near-empty
// filterable/sortable field list — confirmed live: it wiped every
// real filterable/sortable attribute the index had (including
// ones that already worked, like collection_ids), not just the
// new order_count one. Caught in practice, reverted.
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
],
+18
View File
@@ -35,9 +35,27 @@
],
],
// Where contact-form messages are sent (ContactController).
'contact' => [
'email' => env('CONTACT_EMAIL'),
],
'stoic' => [
'sso_secret' => env('STOIC_SSO_SECRET'),
'host' => env('STOIC_HOST'),
],
// Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver.
// `key` is the SECRET key (this ecosystem's convention — StripeManager calls
// Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the
// publishable key for Stripe.js; `webhooks.lunar` is the signing secret the
// webhook route verifies against.
'stripe' => [
'key' => env('STRIPE_SECRET'),
'public_key' => env('STRIPE_PUBLIC_KEY'),
'webhooks' => [
'lunar' => env('STRIPE_WEBHOOK_SECRET'),
],
],
];
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wishlist_items', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('wishlist_items');
}
};
@@ -0,0 +1,183 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `checkout` translation lines for the cart drawer and the checkout
* page (see the checkout module under resources/views/checkout).
*
* Additive and idempotent: a group/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=CheckoutTranslationsSeeder` — it is
* not wired into DatabaseSeeder.
*
* Greek copy uses the project's informal register (εσύ/σου). When the checkout
* module moves to boboko-core this becomes the module's own default-strings
* seeder.
*/
class CheckoutTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'checkout')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("checkout.{$key} already exists — skipped");
continue;
}
$translations->create('checkout', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("checkout.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Cart drawer + order summary ──────────────────────────────
'cart.title' => ['Your cart', 'Το καλάθι σου'],
'cart.close' => ['Close', 'Κλείσιμο'],
'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'],
'cart.quantity' => ['Quantity', 'Ποσότητα'],
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'],
'cart.tax' => ['VAT', 'ΦΠΑ'],
'cart.total' => ['Total', 'Σύνολο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'],
'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'],
'cart.coupon_apply' => ['Apply', 'Εφαρμογή'],
'cart.coupon_remove' => ['Remove', 'Αφαίρεση'],
'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'],
// ── Checkout page ────────────────────────────────────────────
'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'],
'page.guest_tab' => ['Guest', 'Ως επισκέπτης'],
'page.login_tab' => ['Log in', 'Σύνδεση'],
'page.email_label' => ['Email', 'Email'],
'page.recovery_consent' => [
"Email me a reminder if I don't finish my order",
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'page.login_email_label' => ['Email', 'Email'],
'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
'page.login_coming_soon' => [
'Login is coming soon — continue as a guest for now.',
'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.',
],
'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'],
'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'],
'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'],
'page.first_name' => ['First name', 'Όνομα'],
'page.last_name' => ['Last name', 'Επώνυμο'],
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
'page.city' => ['City', 'Πόλη'],
'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'],
'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'],
'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'page.country' => ['Country', 'Χώρα'],
'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'],
'page.phone' => ['Phone', 'Τηλέφωνο'],
'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'],
'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'],
'page.saving' => ['Saving…', 'Αποθήκευση…'],
'page.saved' => ['Saved', 'Αποθηκεύτηκε'],
'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'],
'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'],
'page.shipping_method_empty' => [
'Add your shipping address to see delivery options.',
'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.',
],
'page.shipping_method_none' => [
'No delivery options are available for this address.',
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
],
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
'page.shipping_option_invalid' => [
'That shipping option is no longer available.',
'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.',
],
'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'],
'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'],
// ── Payment step ────────────────────────────────────────────
'page.payment_heading' => ['Payment', 'Πληρωμή'],
'page.payment_method_none' => [
'No payment methods are available right now.',
'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.',
],
'page.terms_accept' => [
"I accept the <a href=':terms' target='_blank'>Terms of Sale</a> and the <a href=':privacy' target='_blank'>Privacy Policy</a>",
"Αποδέχομαι τους <a href=':terms' target='_blank'>Όρους Πώλησης</a> και την <a href=':privacy' target='_blank'>Πολιτική Απορρήτου</a>",
],
'page.terms_required' => [
'You must accept the terms to place your order.',
'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.',
],
'page.withdrawal_notice' => [
"You have a 14-day right of withdrawal. <a href=':link' target='_blank'>See details</a>.",
"Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. <a href=':link' target='_blank'>Δες λεπτομέρειες</a>.",
],
'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'],
'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'],
'page.shipping_method_required' => [
'Choose a shipping method below to continue.',
'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.',
],
'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'],
'page.payment_incomplete_details' => [
'Complete your billing and shipping details above.',
'Συμπλήρωσε τα στοιχεία χρέωσης και αποστολής παραπάνω.',
],
'page.payment_cart_changed' => [
'Your cart changed. Refresh the page and place your order again.',
'Το καλάθι σου άλλαξε. Ανανέωσε τη σελίδα και ολοκλήρωσε ξανά.',
],
'page.payment_processing' => ['Confirming your payment…', 'Επιβεβαίωση πληρωμής…'],
'page.payment_processing_slow' => [
"Your payment is still processing. You'll get an email once it's confirmed.",
'Η πληρωμή σου επεξεργάζεται ακόμη. Θα λάβεις email μόλις επιβεβαιωθεί.',
],
// ── Confirmation page ──────────────────────────────────────
'page.confirmation_title' => ['Your order', 'Η παραγγελία σου'],
'page.confirmation_heading' => [
'Thank you! Your order is confirmed.',
'Ευχαριστούμε! Η παραγγελία σου καταχωρήθηκε.',
],
'page.confirmation_order_number' => ['Order number', 'Αριθμός παραγγελίας'],
'page.confirmation_email_note' => [
'A confirmation email will follow shortly.',
'Θα λάβεις email επιβεβαίωσης σύντομα.',
],
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
];
}
}
@@ -0,0 +1,115 @@
<?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 δεν είναι έγκυρη.'],
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :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'],
];
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 116 KiB

View File
+21
View File
@@ -0,0 +1,21 @@
{
"name": "3dealer",
"short_name": "3dealer",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+6
View File
@@ -1,6 +1,12 @@
@import "tailwindcss";
@import "./fonts.css";
@import "./dropdown.css";
/* Note for anyone theming the checkout module (resources/css/checkout.css,
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any
@layer — so override it with plain rules here too, not from inside
@layer components/utilities, which would lose to it. See checkout.css's
file-level comment for why. */
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
+856
View File
@@ -0,0 +1,856 @@
/*
* Cart + checkout module — generic default styling.
*
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
* @layer ordering statement) to sit between Tailwind's `base` and
* `components` — in theory enough to beat Preflight's element resets while
* still losing to a host override. In practice a build tool processing each
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
* cross-file ordering information that trick depends on, so it silently
* didn't work: Preflight's `button { background-color: transparent }`,
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
* `.bbk-*` rule below regardless of specificity — buttons with no
* background, no border, wrong font-size.
*
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
* host's own stylesheet (see the @vite call in the layout <head>), so:
*
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
* same specificity, later in source order
* - a later host rule with a MORE specific selector wins regardless
* - a host rule inside `@layer components`/`@layer utilities` does NOT
* win — unlayered always beats layered. Theme this module from plain
* rules in app.css, not from inside a Tailwind layer.
*
* Two ways to theme this, cheapest first:
*
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
* font without touching a single selector below.
*
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
*
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
* above) for anything structural (spacing, layout) the variables don't
* cover.
*
* This file's own look is a deliberately neutral placeholder — inoffensive,
* not "designed" — so a project always has something reasonable before it
* themes; it is not meant to be edited per project.
*/
:root {
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bbk-color-text: #18181b;
--bbk-color-muted: #71717a;
--bbk-color-bg: #ffffff;
--bbk-color-bg-muted: #f4f4f5;
--bbk-color-border: #e4e4e7;
--bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626;
--bbk-radius: 8px;
--bbk-radius-sm: 4px;
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.bbk-cart[hidden] { display: none; }
.bbk-cart {
position: fixed;
inset: 0;
z-index: 1000;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-cart-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
.bbk-cart-panel {
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
width: min(420px, 100vw);
height: 100%;
background: var(--bbk-color-bg);
box-shadow: var(--bbk-shadow);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
.bbk-cart-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.5rem 1.25rem;
border-bottom: 1px solid var(--bbk-color-border);
}
.bbk-cart-heading {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
.bbk-cart-dismiss,
.bbk-cart-item-remove,
.bbk-cart-qty-btn {
cursor: pointer;
background: none;
border: 0;
padding: 0;
font: inherit;
line-height: 1;
color: var(--bbk-color-muted);
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-dismiss {
font-size: 1.75rem;
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--bbk-radius-sm);
flex-shrink: 0;
}
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-dismiss:focus-visible,
.bbk-cart-item-remove:focus-visible,
.bbk-cart-qty-btn:focus-visible,
.bbk-cart-qty-input:focus-visible,
.bbk-cart-checkout:focus-visible,
.bbk-cart-coupon-input:focus-visible,
.bbk-cart-coupon-submit:focus-visible,
.bbk-cart-coupon-remove:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.bbk-cart-panel-body {
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1.5rem;
}
.bbk-cart-items {
list-style: none;
margin: 0 0 2rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-cart-item {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 0.875rem;
align-items: start;
}
.bbk-cart-item-media img {
display: block;
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.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;
color: var(--bbk-color-muted);
}
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
.bbk-line-fields {
display: grid;
gap: 0.25rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.bbk-line-field-file {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.bbk-line-field-file img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 0;
}
.bbk-cart-item-unit {
margin: 0 0 0.625rem;
color: var(--bbk-color-muted);
}
.bbk-cart-item-aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
.bbk-cart-item-remove {
font-size: 1.125rem;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bbk-cart-qty {
display: inline-flex;
align-items: center;
gap: 0;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
overflow: hidden;
}
.bbk-cart-qty-btn {
width: 1.75rem;
height: 1.75rem;
background: var(--bbk-color-bg-muted);
}
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
.bbk-cart-qty-input {
width: 2.25rem;
height: 1.75rem;
border: 0;
border-left: 1px solid var(--bbk-color-border);
border-right: 1px solid var(--bbk-color-border);
text-align: center;
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
appearance: textfield;
-moz-appearance: textfield;
}
.bbk-cart-qty-input::-webkit-outer-spin-button,
.bbk-cart-qty-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.bbk-cart-summary {
padding-top: 1.25rem;
border-top: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.bbk-cart-summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
.bbk-cart-summary-pending {
color: var(--bbk-color-muted);
font-size: 0.8125rem;
}
.bbk-cart-summary-row--total {
margin-top: 0.375rem;
padding-top: 0.875rem;
border-top: 1px solid var(--bbk-color-border);
font-size: 1.0625rem;
font-weight: 700;
}
.bbk-cart-coupon-form {
display: flex;
gap: 0.5rem;
}
.bbk-cart-coupon-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-cart-coupon-submit {
flex: 0 0 auto;
padding: 0.5rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
.bbk-cart-coupon-applied {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.bbk-cart-coupon-code {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.bbk-cart-coupon-remove {
flex: 0 0 auto;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
text-decoration: underline;
cursor: pointer;
transition: color 0.15s ease;
}
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-coupon-error {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-error {
margin: 0;
padding: 0.75rem 1.5rem 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-add-to-cart-error {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-checkout {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-cart-checkout:hover { opacity: 0.85; }
.bbk-cart-checkout:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.bbk-cart-empty {
text-align: center;
color: var(--bbk-color-muted);
padding: 2.5rem 0;
}
/* ───────────────────────────────────────────────────────────────────
Checkout page — two columns: fields on the left, order summary (the
same cart-body partial the drawer uses) on the right.
─────────────────────────────────────────────────────────────────── */
.bbk-checkout-page {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 5rem;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-checkout-heading {
margin: 0 0 2rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-checkout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 3rem;
align-items: start;
}
@media (max-width: 860px) {
.bbk-checkout { grid-template-columns: 1fr; }
}
.bbk-checkout-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bbk-checkout-section {
padding-bottom: 2rem;
border-bottom: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.bbk-checkout-section-heading {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.bbk-checkout-note {
margin: 0;
color: var(--bbk-color-muted);
font-size: 0.875rem;
}
/* Contact: "logged in as" line, or the guest login prompt */
.bbk-checkout-logged-in,
.bbk-checkout-login-prompt { margin: 0; }
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
/* Fields */
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
.bbk-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.bbk-field-row { grid-template-columns: 1fr; }
}
.bbk-field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-muted);
}
.bbk-field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-field-input:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-field-input:disabled {
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
.bbk-field-error {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
/* A fixed, non-editable field value (e.g. the store's single country). */
.bbk-field-static {
margin: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
textarea.bbk-field-input { resize: vertical; }
.bbk-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* For a full-sentence label that can wrap — align the box to the first line. */
/* "I want an invoice": company/ΑΦΜ only while ticked */
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--bbk-color-muted);
}
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
.bbk-checkout-shipping-fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Shipping method */
.bbk-checkout-shipping-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-shipping-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-shipping-option-detail {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.bbk-checkout-shipping-option-name { font-weight: 600; }
.bbk-checkout-shipping-option-description {
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-shipping-option-price { font-weight: 600; }
/* The single auto-selected option — a fixed line, not a choosable radio. */
.bbk-checkout-shipping-confirmed {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius-sm);
}
/* Autosave status line under the address form. */
.bbk-checkout-status {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
/* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-checkout-continue:hover { opacity: 0.85; }
.bbk-checkout-continue:disabled {
cursor: not-allowed;
opacity: 0.4;
}
/* Order summary column */
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
.bbk-checkout-summary {
padding: 1.5rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius);
background: var(--bbk-color-bg);
}
.bbk-checkout-summary-heading {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
nowhere further to send you from here. */
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
/* ── Payment ───────────────────────────────────────────────────────── */
.bbk-checkout-payment-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-payment-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-payment-option-name { font-weight: 600; }
.bbk-payment-element { margin: 0.25rem 0; }
.bbk-checkout-withdrawal {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-withdrawal a { color: inherit; }
.bbk-checkout-error {
margin: 0;
font-size: 0.875rem;
color: var(--bbk-color-danger);
}
/* Processing overlay — fixed, covers the page while a payment confirms. */
.bbk-checkout-processing {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
text-align: center;
padding: 1.5rem;
}
.bbk-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--bbk-color-border);
border-top-color: var(--bbk-color-accent);
border-radius: 50%;
animation: bbk-spin 0.8s linear infinite;
}
@keyframes bbk-spin {
to { transform: rotate(360deg); }
}
/* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font);
color: var(--bbk-color-text);
}
.bbk-confirmation-heading {
margin: 0 0 1rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
gap: 2.5rem;
}
@media (min-width: 640px) {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-lines {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-confirmation-line {
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-confirmation-address-heading {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.bbk-address-lines {
font-style: normal;
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
+5
View File
@@ -10,8 +10,13 @@ window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
import { registerCheckout } from "./checkout";
const application = Application.start();
application.debug = false;
registerControllers(application);
// Portable cart + checkout module (destined for boboko-core). Owns its own
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
registerCheckout(application);
@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
@@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the slide-in cart drawer. One instance, on the drawer root in
// checkout/drawer.blade.php.
//
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
// this drawer's own line forms) and swaps in the server-rendered cart body
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
document.addEventListener('keydown', this.onKeydown)
// Prime the host with the count rendered server-side on page load.
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
}
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
if (event.detail?.html) this.replaceBody(event.detail.html)
this.open()
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
}
open() {
if (!this.element.hidden) return
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
panel.removeEventListener('transitionend', done)
}
panel.addEventListener('transitionend', done)
}
// change on a line quantity input, or submit of a line's remove form
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
step(event) {
event.preventDefault()
const form = event.target.closest('form')
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
emitUpdated(node) {
if (!node) return
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
detail: {
count: parseInt(node.dataset.bbkCartCount || '0', 10),
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
},
}))
}
}
@@ -0,0 +1,204 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the checkout page's left column: contact tabs, the same-as-billing
// toggle, and — the bulk of it — autosaving the address form and the shipping
// method with no submit buttons.
//
// Flow: any `change` in the address form is debounced ~400ms, then the whole
// form is POSTed to saveUrl. The server persists leniently and returns
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
// block in place and hand the summary fragment to the drawer's bbk-cart
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
connect() {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
this.applySameAsBilling()
}
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
// Checked: shipping *is* billing — copy every value across, then hide +
// disable so the browser doesn't submit them; the server reuses billing.
// Unchecked: reveal them pre-filled from billing wherever still empty.
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
const shippingField = this.element.querySelector(
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
)
if (shippingField && (on || !shippingField.value)) {
shippingField.value = billingField.value
}
})
this.shippingFieldsTarget.hidden = on
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = on
})
}
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
// The shipping-method and payment radios live inside this controller's
// element too, and this action is bound on .bbk-checkout-main to also
// catch the contact email/consent that sit outside the <form>. Only
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
// so the indicator isn't flickering "saving" on every keystroke.
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 700)
}
// Called by bbk-payment right before place-order — a debounced save (and
// the shipping-option auto-select that happens as part of it) might still
// be pending when the shopper clicks "place order"; this guarantees the
// server has processed the current form state first.
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
this.saveController?.abort()
this.saveController = new AbortController()
this.setStatus('saving')
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(this.formTarget),
signal: this.saveController.signal,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch (error) {
if (error.name !== 'AbortError') this.setStatus('error')
}
}
async selectShipping(event) {
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
applyResult(data) {
this.applyErrors(data.errors || {})
if (data.shippingOptionsHtml != null) {
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
}
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: data.summaryHtml },
}))
}
}
applyErrors(errors) {
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
const message = errors[el.dataset.bbkFieldError]
el.textContent = message || ''
el.hidden = !message
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
field?.classList.toggle('bbk-field-input--error', Boolean(message))
})
}
setStatus(state) {
if (!this.hasStatusTarget) return
const text = {
saving: this.statusSavingValue,
saved: this.statusSavedValue,
error: this.statusErrorValue,
}[state]
this.statusTarget.textContent = text
this.statusTarget.hidden = false
this.statusTarget.dataset.state = state
clearTimeout(this.statusTimer)
if (state === 'saved') {
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
}
}
}
@@ -0,0 +1,289 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
const STRIPE_JS = 'https://js.stripe.com/v3/'
const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 30000
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
// .bbk-checkout-main.
//
// - selectMethod: radio change -> persist via /payment-method, refresh the
// summary (COD fee), mount/unmount the Stripe Payment Element
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
// and POSTs it to /place-order, then routes on the JSON result:
// { redirect } -> order placed, go to confirmation
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static values = {
selectUrl: String,
placeOrderUrl: String,
orderStatusUrl: String,
stripeKey: String,
amount: Number,
currency: String,
termsRequired: String,
chooseMethod: String,
genericError: String,
processingSlow: String,
}
connect() {
this.stripe = null
this.elements = null
this.paymentElement = null
this.onSummaryUpdate = (event) => {
const total = event.detail?.total
if (typeof total === 'number' && this.elements) {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
this.unmountStripe()
}
// ── Method selection ──────────────────────────────────────────────
async selectMethod(event) {
const isStripe = event.target.dataset.paymentDriver === 'stripe'
try {
const response = await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({ payment_type: event.target.value }),
})
if (response.ok) {
const data = await response.json()
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
}
}
} catch {
// summary just won't refresh — non-fatal
}
isStripe ? this.mountStripe() : this.unmountStripe()
}
selectedRadio() {
return this.element.querySelector('input[name="payment_type"]:checked')
}
selectedIsStripe() {
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
}
// ── Stripe Payment Element ────────────────────────────────────────
async loadStripe() {
if (window.Stripe) return window.Stripe
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
if (existing) {
existing.addEventListener('load', resolve)
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = STRIPE_JS
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
return window.Stripe
}
async mountStripe() {
if (this.paymentElement || !this.stripeKeyValue) return
const Stripe = await this.loadStripe()
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
this.elements = this.stripe.elements({
mode: 'payment',
amount: Math.max(this.amountValue, 1),
currency: this.currencyValue,
paymentMethodCreation: 'manual',
// Card only — matches the server confirming with
// automatic_payment_methods.allow_redirects = 'never' (no
// return_url in our flow: 3-D Secure resolves in-page via
// handleNextAction, never a full-page redirect).
paymentMethodTypes: ['card'],
})
this.paymentElement = this.elements.create('payment')
this.paymentElement.mount(this.elementTarget)
this.elementTarget.hidden = false
}
unmountStripe() {
this.paymentElement?.unmount()
this.paymentElement = null
this.elements = null
if (this.hasElementTarget) {
this.elementTarget.innerHTML = ''
this.elementTarget.hidden = true
}
}
// ── Place order ──────────────────────────────────────────────────
// Sibling controller on the same element (.bbk-checkout-main) — used to
// flush a pending debounced address autosave before placing the order.
get checkoutForm() {
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
}
async placeOrder() {
this.clearError()
this.submitTarget.disabled = true
// A debounced address save (and the shipping-option auto-select that
// happens as part of it) might still be pending — make sure the
// server has the latest state before we ask it to place the order.
await this.checkoutForm?.flush()
if (!this.termsTarget.checked) {
this.submitTarget.disabled = false
this.showError(this.termsRequiredValue)
return
}
const radio = this.selectedRadio()
if (!radio) {
this.submitTarget.disabled = false
this.showError(this.chooseMethodValue)
return
}
let paymentMethodId = null
if (radio.dataset.paymentDriver === 'stripe') {
const { error: submitError } = await this.elements.submit()
if (submitError) return this.fail(submitError.message)
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
if (pmError) return this.fail(pmError.message)
paymentMethodId = paymentMethod.id
}
let data
try {
const response = await fetch(this.placeOrderUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({
payment_type: radio.value,
payment_method: paymentMethodId ?? '',
terms_accepted: '1',
}),
})
data = await response.json()
} catch {
return this.fail(this.genericErrorValue)
}
if (data.redirect) {
window.location.assign(data.redirect)
return
}
if (data.status === 'pending' && data.clientSecret) {
await this.resolvePending(data.clientSecret)
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
async resolvePending(clientSecret) {
this.processingTarget.hidden = false
const { error } = await this.stripe.handleNextAction({ clientSecret })
if (error) {
this.processingTarget.hidden = true
return this.fail(error.message)
}
// 3-D Secure cleared client-side — the webhook places the order. Poll.
const startedAt = Date.now()
const tick = async () => {
try {
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (data.placed && data.redirect) {
window.location.assign(data.redirect)
return
}
} catch {
// keep polling
}
if (Date.now() - startedAt > POLL_TIMEOUT) {
this.processingTextTarget.textContent = this.processingSlowValue
return
}
setTimeout(tick, POLL_INTERVAL)
}
tick()
}
// ── helpers ──────────────────────────────────────────────────────
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {
this.errorTarget.textContent = ''
this.errorTarget.hidden = true
}
}
+6
View File
@@ -0,0 +1,6 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
+19
View File
@@ -0,0 +1,19 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerCheckout } from './checkout'
// registerCheckout(application)
//
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}
@@ -0,0 +1,31 @@
import { Controller } from '@hotwired/stimulus'
// 3dealer-side glue for the checkout module. The module owns the cart and emits
// `bbk-cart:updated` {count, total} on window after every change; this reflects
// the line count on the header bag icon. How (or whether) that count is shown
// is the host's call — hence this lives here, not in the module.
export default class extends Controller {
static targets = ['badge']
connect() {
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
window.addEventListener('bbk-cart:updated', this.onUpdate)
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onUpdate)
}
// Header cart icon click — there's no separate cart page, the drawer IS
// the cart. `bbk-cart:open` is the module's own event, already listened
// for by bbk-cart-controller.
open() {
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
}
render(count) {
if (!this.hasBadgeTarget) return
this.badgeTarget.textContent = String(count)
this.badgeTarget.hidden = count < 1
}
}
@@ -0,0 +1,108 @@
import { Controller } from '@hotwired/stimulus'
// One product custom field of type `file` (see x-product-custom-fields).
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
// as soon as it's picked, then writes the returned opaque reference into the
// hidden input the add-to-cart form actually submits — the checkout module
// never receives the file itself.
//
// While uploading, the file input is marked invalid via setCustomValidity(),
// so the browser's own form validation blocks add-to-cart until the reference
// is in place. A failed upload clears the input, so `required` blocks it too.
export default class extends Controller {
static targets = ['file', 'reference', 'preview', 'error']
static values = {
url: String,
label: String,
uploadingMessage: String,
failedMessage: String,
}
disconnect() {
this.abortController?.abort()
this.revokePreview()
}
async upload() {
this.reset()
const file = this.fileTarget.files[0]
if (!file) return
const abortController = new AbortController()
this.abortController = abortController
this.fileTarget.setCustomValidity(this.uploadingMessageValue)
this.fileTarget.setAttribute('aria-busy', 'true')
const body = new FormData()
body.append('file', file)
body.append('label', this.labelValue)
try {
const response = await fetch(this.urlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
signal: abortController.signal,
})
const data = await response.json().catch(() => null)
if (!response.ok || !data?.reference) {
this.fail(data?.error)
return
}
this.referenceTarget.value = data.reference
this.showPreview(file)
} catch (error) {
// A newer pick superseded this upload — reset() already handled it.
if (error.name === 'AbortError') return
this.fail()
} finally {
if (!abortController.signal.aborted) this.markIdle()
}
}
reset() {
this.abortController?.abort()
this.referenceTarget.value = ''
this.errorTarget.hidden = true
this.markIdle()
this.revokePreview()
}
fail(message) {
this.fileTarget.value = ''
this.errorTarget.textContent = message || this.failedMessageValue
this.errorTarget.hidden = false
}
markIdle() {
this.fileTarget.setCustomValidity('')
this.fileTarget.removeAttribute('aria-busy')
}
showPreview(file) {
this.previewUrl = URL.createObjectURL(file)
this.previewTarget.src = this.previewUrl
this.previewTarget.hidden = false
}
// Formats the browser can't render (HEIC outside Safari) — the file
// input's own filename is enough there.
hidePreview() {
this.previewTarget.hidden = true
}
revokePreview() {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
this.previewUrl = null
this.previewTarget.removeAttribute('src')
this.previewTarget.hidden = true
}
}
+8
View File
@@ -6,7 +6,9 @@
import AppearController from './appear-controller'
import AutoSubmitController from './auto-submit-controller'
import BackToTopController from './back-to-top-controller'
import CartCountController from './cart-count-controller'
import CarouselController from './carousel-controller'
import CustomFieldUploadController from './custom-field-upload-controller'
import DropdownController from './dropdown-controller'
import FrameScrollController from './frame-scroll-controller'
import NavSearchController from './nav-search-controller'
@@ -15,13 +17,17 @@ import ProductGalleryController from './product-gallery-controller'
import QuantityController from './quantity-controller'
import RangeSliderController from './range-slider-controller'
import StarRatingController from './star-rating-controller'
import TabLinkController from './tab-link-controller'
import TabsController from './tabs-controller'
import WishlistController from './wishlist-controller'
export function registerControllers(application) {
application.register('appear', AppearController)
application.register('auto-submit', AutoSubmitController)
application.register('back-to-top', BackToTopController)
application.register('cart-count', CartCountController)
application.register('carousel', CarouselController)
application.register('custom-field-upload', CustomFieldUploadController)
application.register('dropdown', DropdownController)
application.register('frame-scroll', FrameScrollController)
application.register('nav-search', NavSearchController)
@@ -30,5 +36,7 @@ export function registerControllers(application) {
application.register('quantity', QuantityController)
application.register('range-slider', RangeSliderController)
application.register('star-rating', StarRatingController)
application.register('tab-link', TabLinkController)
application.register('tabs', TabsController)
application.register('wishlist', WishlistController)
}
+136 -14
View File
@@ -2,25 +2,134 @@ import { Controller } from '@hotwired/stimulus'
import { formatPrice } from '../utils/format-price'
export default class extends Controller {
static targets = ['price', 'image', 'swatch', 'colorName']
static values = { variants: Array, selected: Number }
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError']
static values = {
variants: Array,
selected: Number,
stockCheckUrl: String,
// Two pre-rendered translated templates (see product/show.blade.php)
// rather than one — this controller doesn't reimplement Laravel's
// pluralization rules, it just picks whichever of these two the
// count actually needs and fills in the number.
stockErrorOne: String,
stockErrorMany: String,
}
connect() {
const params = new URLSearchParams(window.location.search)
const urlId = parseInt(params.get('variant'))
const defaultId = this.variantsValue[0]?.id
const params = new URLSearchParams(window.location.search)
const urlId = parseInt(params.get('variant'))
const urlVariant = this.variantsValue.find(v => v.id === urlId)
const initial = urlVariant ?? this.variantsValue[0]
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
? urlId
: defaultId
// A product can have several independent options (e.g. size + style
// + person-count) — this tracks the currently-picked value id per
// option handle, and selectVariant() below resolves the full
// combination back to one exact variant on every change.
this.selections = { ...initial?.options }
this.selectedValue = initial?.id
// Capture phase, on this controller's own root element (an ancestor
// of the checkout module's add-to-cart <form>) — runs BEFORE that
// form's own bubble-phase submit handler (bbk-add-to-cart#add), so a
// failed check can stop it from ever reaching the module at all. The
// module itself is never touched or modified for this: it keeps
// validating server-side regardless, this is purely an up-front,
// storefront-owned check (see [[project_checkout_module]] for why
// that split matters — stock UX is a catalog concern, not something
// the portable checkout module should own) — and a REAL, live check
// against the backend (ProductController::checkStock(), reading the
// Eloquent model directly), not page-load data that can go stale.
this.onSubmitCapture = this.checkStock.bind(this)
this.element.addEventListener('submit', this.onSubmitCapture, true)
}
disconnect() {
this.element.removeEventListener('submit', this.onSubmitCapture, true)
}
checkStock(event) {
const form = event.target
if (!form.matches('.bbk-add-to-cart')) return
// The re-submit this itself triggers below, once the backend has
// confirmed the quantity is fine — let that one through to the
// module's own submit handler instead of checking a second time.
if (form.dataset.bbkStockChecked) {
delete form.dataset.bbkStockChecked
return
}
event.preventDefault()
event.stopPropagation()
this.verifyStock(form)
}
async verifyStock(form) {
this.clearStockError()
const submit = form.querySelector('[type="submit"]')
if (submit) submit.disabled = true
const purchasableId = form.querySelector('[data-bbk-purchasable-input]')?.value
const quantity = form.querySelector('[name="quantity"]')?.value || '1'
try {
const url = new URL(this.stockCheckUrlValue, window.location.origin)
url.searchParams.set('variant', purchasableId)
url.searchParams.set('quantity', quantity)
const response = await fetch(url, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (!data.ok) {
this.showStockError(data.stock)
return
}
} catch {
// Network hiccup — fall through and let the checkout module's
// own server-side check have the final word rather than
// silently blocking the shopper here.
} finally {
if (submit) submit.disabled = false
}
form.dataset.bbkStockChecked = 'true'
form.requestSubmit()
}
showStockError(available) {
if (!this.hasStockErrorTarget) return
this.stockErrorTarget.textContent = available === 1
? this.stockErrorOneValue
: this.stockErrorManyValue.replace(':count', String(available))
this.stockErrorTarget.hidden = false
}
clearStockError() {
if (!this.hasStockErrorTarget) return
this.stockErrorTarget.hidden = true
}
selectVariant(event) {
const id = parseInt(event.currentTarget.dataset.variantId)
this.selectedValue = id
const option = event.currentTarget.dataset.option
const valueId = parseInt(event.currentTarget.dataset.valueId)
this.selections = { ...this.selections, [option]: valueId }
const match = this.variantsValue.find(variant =>
Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key])
)
// No variant exists for this combination (e.g. an option value that
// isn't offered together with another currently-selected value) —
// leave the previous selection in place rather than pointing the
// add-to-cart form at nothing.
if (!match) return
this.selectedValue = match.id
const url = new URL(window.location)
url.searchParams.set('variant', id)
url.searchParams.set('variant', match.id)
window.history.pushState({}, '', url)
}
@@ -30,6 +139,8 @@ export default class extends Controller {
const variant = this.variantsValue.find(v => v.id === id)
if (!variant) return
this.clearStockError()
if (this.hasPriceTarget && variant.price !== null) {
this.priceTarget.textContent = formatPrice(variant.price)
}
@@ -38,13 +149,24 @@ export default class extends Controller {
this.imageTarget.src = variant.image
}
// Keep the checkout module's add-to-cart form pointed at the chosen
// variant. [data-bbk-purchasable-input] is that module's documented
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
// this is the one place the two touch.
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
if (purchasableInput) purchasableInput.value = id
this.swatchTargets.forEach(swatch => {
const isSelected = parseInt(swatch.dataset.variantId) === id
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
swatch.classList.toggle('is-selected', isSelected)
swatch.setAttribute('aria-pressed', String(isSelected))
if (isSelected && this.hasColorNameTarget) {
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
if (isSelected) {
// Each color-option group has its own colorName echo (see
// x-ui.color-swatch) — matched by option handle so a swatch
// in one group never overwrites another group's label.
const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option)
if (colorName) colorName.textContent = swatch.getAttribute('aria-label')
}
})
}
@@ -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))
@@ -0,0 +1,15 @@
import { Controller } from '@hotwired/stimulus'
// Jumps to a tab panel from an element outside the tabs' own markup — the
// review count and the "read more" link both sit elsewhere in the DOM, too
// far apart from the tabs for a plain data-action, hence the outlet.
export default class extends Controller {
static outlets = ['tabs']
static values = { panel: String }
activate(event) {
event?.preventDefault()
this.tabsOutlet.activate(this.panelValue)
this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
}
+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)
@@ -0,0 +1,42 @@
import { Controller } from '@hotwired/stimulus'
// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the
// server's answer on aria-pressed, which the CSS uses to swap the outline and
// filled heart. If the request fails, falls back to a normal form submit.
export default class extends Controller {
static targets = ['button', 'status']
static values = {
addLabel: String,
removeLabel: String,
addedMessage: String,
removedMessage: String,
}
async toggle(event) {
event.preventDefault()
if (this.busy) return
this.busy = true
try {
const response = await fetch(this.element.action, {
method: 'POST',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this.element),
})
if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`)
const { active } = await response.json()
this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false')
this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue)
this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue
} catch {
this.element.submit()
} finally {
this.busy = false
}
}
}
@@ -0,0 +1,65 @@
@extends('layouts.account')
@section('title', __('storefront.auth.enter_code'))
@section('account')
<div class="flex max-w-2xl flex-col gap-12">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.auth.enter_code') }}
</h1>
<p>
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
</p>
<x-ui.status />
</div>
<form method="POST" action="{{ route('account.email.verify') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.auth.code')"
for="account-email-code"
:required="true"
:error="$errors->first('code')"
>
<x-ui.input
id="account-email-code"
name="code"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
:required="true"
autofocus
class="text-2xl font-bold tracking-[0.5em]"
:aria-invalid="$errors->has('code') ? 'true' : null"
:aria-describedby="$errors->has('code') ? 'account-email-code-error' : null"
/>
</x-ui.field>
<div>
<x-ui.button type="submit">{{ __('storefront.account.email_confirm') }}</x-ui.button>
</div>
</form>
<div class="flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
<form method="POST" action="{{ route('account.email.resend') }}">
@csrf
<button type="submit" class="cursor-pointer underline hover:no-underline">
{{ __('storefront.auth.resend_code') }}
</button>
</form>
<a href="{{ route('account.email.edit') }}" class="underline hover:no-underline">
{{ __('storefront.auth.change_email') }}
</a>
</div>
</div>
@endsection
+53
View File
@@ -0,0 +1,53 @@
@extends('layouts.account')
@section('title', __('storefront.account.email_change_heading'))
@section('account')
<div class="flex max-w-2xl flex-col gap-12">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.email_change_heading') }}
</h1>
<p>
{{ __('storefront.account.email_current') }} <strong class="break-all">{{ $user->email }}</strong>
</p>
</div>
<form method="POST" action="{{ route('account.email.send') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.account.email_new')"
for="account-new-email"
:required="true"
:description="__('storefront.account.email_new_hint')"
:error="$errors->first('email')"
>
<x-ui.input
id="account-new-email"
name="email"
type="email"
autocomplete="email"
:value="old('email')"
:required="true"
autofocus
:aria-invalid="$errors->has('email') ? 'true' : null"
:aria-describedby="$errors->has('email') ? 'account-new-email-error' : 'account-new-email-description'"
/>
</x-ui.field>
<div class="flex flex-wrap items-center gap-8">
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
<a href="{{ route('account') }}" class="text-sm underline hover:no-underline">
{{ __('storefront.account.delete_cancel') }}
</a>
</div>
</form>
</div>
@endsection
@@ -0,0 +1,67 @@
@extends('layouts.account')
@section('title', __('storefront.account.nav_orders'))
@section('account')
<div class="flex max-w-4xl flex-col gap-12">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_orders') }}
</h1>
@if ($orders->isEmpty())
<div class="flex flex-col items-start gap-8">
<p>{{ __('storefront.orders.empty') }}</p>
<x-ui.button :href="route('products')" size="md">{{ __('storefront.orders.shop_now') }}</x-ui.button>
</div>
@else
{{-- A list, not a table, so each order can stack on mobile. The column
headings are visual only; each cell carries its own sr-only label. --}}
<div>
<div class="hidden grid-cols-[1fr_1fr_1.5fr_1fr_auto] gap-6 border-b border-black pb-3 text-sm uppercase font-display font-extrabold sm:grid" aria-hidden="true">
<span>{{ __('storefront.orders.date') }}</span>
<span>{{ __('storefront.orders.number') }}</span>
<span>{{ __('storefront.orders.status') }}</span>
<span>{{ __('storefront.orders.total') }}</span>
<span class="w-24"></span>
</div>
<ul>
@foreach ($orders as $order)
<li class="grid grid-cols-2 gap-x-6 gap-y-2 border-b border-black py-5 sm:grid-cols-[1fr_1fr_1.5fr_1fr_auto] sm:items-center">
<span>
<span class="sr-only">{{ __('storefront.orders.date') }}:</span>
{{ $order->placed_at->format('d/m/Y') }}
</span>
<span class="text-right font-bold sm:text-left">
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
#{{ $order->reference }}
</span>
<span>
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
<x-order-status :status="$order->status" />
</span>
<span class="text-right sm:text-left">
<span class="sr-only">{{ __('storefront.orders.total') }}:</span>
{{ $order->total?->formatted() }}
</span>
<a
href="{{ route('account.orders.show', ['orderId' => $order->id]) }}"
class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0"
aria-label="{{ __('storefront.orders.view_order', ['number' => $order->reference]) }}"
>
{{ __('storefront.orders.view') }}
<x-ui.icon name="arrow-right" :size="16" />
</a>
</li>
@endforeach
</ul>
</div>
<x-ui.pagination :paginator="$orders" />
@endif
</div>
@endsection
@@ -0,0 +1,158 @@
@extends('layouts.account')
@section('title', __('storefront.orders.order_title', ['number' => $order->reference]))
@php
$productLines = $order->lines->where('type', '!=', 'shipping');
$shippingLine = $order->lines->firstWhere('type', 'shipping');
@endphp
@section('account')
<div class="flex max-w-4xl flex-col gap-12">
<div class="flex flex-col gap-6">
<a href="{{ route('account.orders') }}" class="inline-flex items-center gap-2 self-start text-sm underline hover:no-underline">
<x-ui.icon name="arrow-left" :size="16" />
{{ __('storefront.orders.back') }}
</a>
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }}
</h1>
</div>
{{-- Summary --}}
<dl class="grid grid-cols-1 gap-6 border-y border-black py-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.date') }}</dt>
<dd>{{ $order->placed_at->format('d/m/Y') }}</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.status') }}</dt>
<dd class="font-bold"><x-order-status :status="$order->status" /></dd>
</div>
@if ($paymentMethodName)
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.payment') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine)
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.shipping_method') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
@foreach ($shipments as $shipment)
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.tracking') }}</dt>
<dd class="break-all">{{ $shipment->tracking_reference }}</dd>
</div>
@endforeach
</dl>
{{-- Items + totals --}}
<section class="flex flex-col gap-6" aria-labelledby="order-items-heading">
<h2 id="order-items-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.orders.items') }}</h2>
<ul>
@foreach ($productLines as $line)
<li class="flex gap-4 border-b border-black py-5 sm:gap-6">
<div class="size-18 shrink-0 bg-neutral-300 sm:size-24">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="" aria-hidden="true" width="96" height="96" loading="lazy" class="size-full object-cover">
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
{{-- Linked only while the product still exists and is published;
otherwise the name stays plain text (the order keeps it). --}}
@php($lineProduct = $line->purchasable?->product)
<p class="font-bold">
@if ($lineProduct?->status === 'published')
<a href="{{ route('product.show', ['id' => $lineProduct->id]) }}" class="underline hover:no-underline">{{ $line->description }}</a>
@else
{{ $line->description }}
@endif
<span class="font-normal">&times; {{ $line->quantity }}</span>
</p>
@if ($line->option)
<p class="text-sm text-neutral-500">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<p class="shrink-0 font-bold">{{ $line->sub_total?->formatted() }}</p>
</li>
@endforeach
</ul>
<dl class="ml-auto flex w-full max-w-sm flex-col gap-2">
<div class="flex justify-between gap-6">
<dt>{{ __('storefront.orders.subtotal') }}</dt>
<dd>{{ $order->sub_total?->formatted() }}</dd>
</div>
@if ($order->discount_total?->value > 0)
<div class="flex justify-between gap-6">
<dt>{{ __('storefront.orders.discount') }}</dt>
<dd>&minus;{{ $order->discount_total->formatted() }}</dd>
</div>
@endif
<div class="flex justify-between gap-6">
<dt>{{ __('storefront.orders.shipping') }}</dt>
<dd>{{ $order->shipping_total?->formatted() }}</dd>
</div>
@if ($order->tax_total?->value > 0)
<div class="flex justify-between gap-6 text-sm text-neutral-500">
<dt>{{ __('storefront.orders.tax') }}</dt>
<dd>{{ $order->tax_total->formatted() }}</dd>
</div>
@endif
<div class="flex justify-between gap-6 border-t border-black pt-3 font-display text-h4 font-extrabold">
<dt>{{ __('storefront.orders.total') }}</dt>
<dd>{{ $order->total?->formatted() }}</dd>
</div>
</dl>
</section>
{{-- Addresses --}}
<div class="grid grid-cols-1 gap-12 sm:grid-cols-2">
@foreach ([
'shipping_to' => $order->shippingAddress,
'billing' => $order->billingAddress,
] as $heading => $address)
@if ($address)
<section class="flex flex-col gap-4" aria-labelledby="order-{{ $heading }}-heading">
<h2 id="order-{{ $heading }}-heading" class="font-display text-h4 font-extrabold">{{ __("storefront.orders.{$heading}") }}</h2>
<address class="flex flex-col not-italic">
<span>{{ trim($address->first_name.' '.$address->last_name) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
@if ($address->tax_identifier)<span>{{ __('storefront.account.tax_identifier') }}: {{ $address->tax_identifier }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim($address->postcode.' '.$address->city) }}</span>
@if ($address->state)
<span>{{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}</span>
@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
</section>
@endif
@endforeach
</div>
</div>
@endsection
+204
View File
@@ -0,0 +1,204 @@
@extends('layouts.account')
@section('title', __('storefront.account.nav_profile'))
@php
$wantsInvoice = (bool) old('invoice', filled($customer?->company_name) || filled($customer?->tax_identifier));
$regionOptions = $regions->map(fn ($region) => [
'value' => $region->name,
'label' => Lang::has("core::states.{$region->name}") ? __("core::states.{$region->name}") : $region->name,
])->all();
@endphp
@section('account')
<div class="flex max-w-2xl flex-col gap-16">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_profile') }}
</h1>
<x-ui.status />
</div>
{{-- Email: the login itself, so it's changed through its own verified flow --}}
<section class="flex flex-col gap-6" aria-labelledby="account-email-heading">
<h2 id="account-email-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.email_heading') }}</h2>
<div class="flex flex-wrap items-center justify-between gap-x-8 gap-y-2 border-b border-black py-2">
<span class="break-all">{{ $user->email }}</span>
<a href="{{ route('account.email.edit') }}" class="text-sm underline hover:no-underline">
{{ __('storefront.account.email_change') }}
</a>
</div>
</section>
<form method="POST" action="{{ route('account.update') }}" class="flex flex-col gap-16">
@csrf
@method('PUT')
{{-- Details --}}
<section class="flex flex-col gap-8" aria-labelledby="account-details-heading">
<h2 id="account-details-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.details_heading') }}</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field :label="__('storefront.account.first_name')" for="account-first-name" :error="$errors->first('first_name')">
<x-ui.input id="account-first-name" name="first_name" autocomplete="given-name"
:value="old('first_name', $customer?->first_name)"
:aria-invalid="$errors->has('first_name') ? 'true' : null" :aria-describedby="$errors->has('first_name') ? 'account-first-name-error' : null" />
</x-ui.field>
<x-ui.field :label="__('storefront.account.last_name')" for="account-last-name" :error="$errors->first('last_name')">
<x-ui.input id="account-last-name" name="last_name" autocomplete="family-name"
:value="old('last_name', $customer?->last_name)"
:aria-invalid="$errors->has('last_name') ? 'true' : null" :aria-describedby="$errors->has('last_name') ? 'account-last-name-error' : null" />
</x-ui.field>
</div>
{{-- Invoice details, revealed by the checkbox (CSS :has(), no JS). Not
`required` in HTML: a hidden required field would block submit, so
the server requires them only when the box is ticked. --}}
<div class="group/invoice flex flex-col gap-8">
<input type="hidden" name="invoice" value="0">
<x-ui.checkbox
id="account-invoice"
name="invoice"
:checked="$wantsInvoice"
:label="__('storefront.account.invoice')"
aria-controls="account-invoice-fields"
/>
<div id="account-invoice-fields" class="hidden grid-cols-1 gap-8 sm:grid-cols-2 group-has-[#account-invoice:checked]/invoice:grid">
<x-ui.field :label="__('storefront.account.company_name')" for="account-company" :error="$errors->first('company_name')">
<x-ui.input id="account-company" name="company_name" autocomplete="organization"
:value="old('company_name', $customer?->company_name)"
:aria-invalid="$errors->has('company_name') ? 'true' : null" :aria-describedby="$errors->has('company_name') ? 'account-company-error' : null" />
</x-ui.field>
<x-ui.field :label="__('storefront.account.tax_identifier')" for="account-tax-id" :error="$errors->first('tax_identifier')">
<x-ui.input id="account-tax-id" name="tax_identifier" inputmode="numeric" maxlength="9" pattern="[0-9]{9}"
:value="old('tax_identifier', $customer?->tax_identifier)"
:aria-invalid="$errors->has('tax_identifier') ? 'true' : null" :aria-describedby="$errors->has('tax_identifier') ? 'account-tax-id-error' : null" />
</x-ui.field>
</div>
</div>
</section>
{{-- Address: one, used as both shipping and billing default --}}
<section class="flex flex-col gap-8" aria-labelledby="account-address-heading">
<h2 id="account-address-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.address_heading') }}</h2>
<x-ui.field :label="__('storefront.account.line_one')" for="account-line-one" :error="$errors->first('line_one')">
<x-ui.input id="account-line-one" name="line_one" autocomplete="address-line1"
:value="old('line_one', $address?->line_one)"
:aria-invalid="$errors->has('line_one') ? 'true' : null" :aria-describedby="$errors->has('line_one') ? 'account-line-one-error' : null" />
</x-ui.field>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field :label="__('storefront.account.city')" for="account-city" :error="$errors->first('city')">
<x-ui.input id="account-city" name="city" autocomplete="address-level2"
:value="old('city', $address?->city)"
:aria-invalid="$errors->has('city') ? 'true' : null" :aria-describedby="$errors->has('city') ? 'account-city-error' : null" />
</x-ui.field>
<x-ui.field :label="__('storefront.account.postcode')" for="account-postcode" :error="$errors->first('postcode')">
<x-ui.input id="account-postcode" name="postcode" autocomplete="postal-code" inputmode="numeric"
:value="old('postcode', $address?->postcode)"
:aria-invalid="$errors->has('postcode') ? 'true' : null" :aria-describedby="$errors->has('postcode') ? 'account-postcode-error' : null" />
</x-ui.field>
</div>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field :label="__('storefront.account.state')" for="account-state" :error="$errors->first('state')">
<x-ui.select
id="account-state"
name="state"
:block="true"
:options="$regionOptions"
:value="old('state', $address?->state)"
:placeholder="__('storefront.account.state_placeholder')"
:aria-invalid="$errors->has('state') ? 'true' : null"
:aria-describedby="$errors->has('state') ? 'account-state-error' : null"
/>
</x-ui.field>
<x-ui.field :label="__('storefront.account.phone')" for="account-phone" :error="$errors->first('contact_phone')">
<x-ui.input id="account-phone" name="contact_phone" type="tel" autocomplete="tel"
:value="old('contact_phone', $address?->contact_phone)"
:aria-invalid="$errors->has('contact_phone') ? 'true' : null" :aria-describedby="$errors->has('contact_phone') ? 'account-phone-error' : null" />
</x-ui.field>
</div>
</section>
{{-- Standing opt-in for abandoned-cart reminders (explicit, off by
default); checkout starts from it and can change it again. --}}
<section class="flex flex-col gap-8" aria-labelledby="account-emails-heading">
<h2 id="account-emails-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.emails_heading') }}</h2>
<div>
<input type="hidden" name="recovery_consent" value="0">
<x-ui.checkbox
id="account-recovery-consent"
name="recovery_consent"
:checked="(bool) old('recovery_consent', data_get($customer, 'meta.recovery_consent'))"
:label="__('storefront.account.recovery_consent')"
/>
</div>
</section>
<div>
<x-ui.button type="submit">{{ __('storefront.account.save') }}</x-ui.button>
</div>
</form>
{{-- Delete account --}}
<section class="flex flex-col gap-6 border-t border-black pt-12" aria-labelledby="account-delete-heading">
<h2 id="account-delete-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.delete_heading') }}</h2>
<p>{{ __('storefront.account.delete_text') }}</p>
<div>
<x-ui.button
variant="secondary"
size="md"
popovertarget="account-delete-dialog"
aria-haspopup="dialog"
>{{ __('storefront.account.delete') }}</x-ui.button>
</div>
<div
id="account-delete-dialog"
popover
role="dialog"
aria-modal="true"
aria-labelledby="account-delete-dialog-heading"
class="m-auto w-[calc(100%-2rem)] max-w-lg border border-black bg-neutral-200 p-8 backdrop:bg-black/50"
>
<h3 id="account-delete-dialog-heading" class="mb-4 font-display text-h4 font-extrabold">
{{ __('storefront.account.delete_confirm_heading') }}
</h3>
<p class="mb-8">{{ __('storefront.account.delete_confirm_text') }}</p>
<form method="POST" action="{{ route('account.destroy') }}" class="flex flex-wrap gap-4">
@csrf
@method('DELETE')
<x-ui.button type="submit" size="md">{{ __('storefront.account.delete_confirm') }}</x-ui.button>
<x-ui.button
variant="secondary"
size="md"
popovertarget="account-delete-dialog"
popovertargetaction="hide"
>{{ __('storefront.account.delete_cancel') }}</x-ui.button>
</form>
</div>
</section>
</div>
@endsection
@@ -0,0 +1,17 @@
@extends('layouts.account')
@section('title', __('storefront.account.nav_wishlist'))
@section('account')
<div class="flex flex-col gap-12">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_wishlist') }}
</h1>
@include('wishlist.list')
</div>
@endsection
+67
View File
@@ -0,0 +1,67 @@
@extends('layouts.app')
@section('title', __('storefront.auth.enter_code'))
@push('seo')
<meta name="robots" content="noindex, nofollow">
@endpush
@section('content')
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.auth.enter_code') }}
</h1>
<p class="mb-12">
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
</p>
<x-ui.status class="mb-8" />
<form method="POST" action="{{ route('login.verify') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.auth.code')"
for="login-code"
:required="true"
:error="$errors->first('code')"
>
<x-ui.input
id="login-code"
name="code"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
:required="true"
autofocus
class="text-2xl font-bold tracking-[0.5em]"
:aria-invalid="$errors->has('code') ? 'true' : null"
:aria-describedby="$errors->has('code') ? 'login-code-error' : null"
/>
</x-ui.field>
<div>
<x-ui.button type="submit">{{ __('storefront.auth.login') }}</x-ui.button>
</div>
</form>
<div class="mt-12 flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
<form method="POST" action="{{ route('login.resend') }}">
@csrf
<button type="submit" class="cursor-pointer underline hover:no-underline">
{{ __('storefront.auth.resend_code') }}
</button>
</form>
<a href="{{ route('login') }}" class="underline hover:no-underline">
{{ __('storefront.auth.change_email') }}
</a>
</div>
</section>
@endsection
+62
View File
@@ -0,0 +1,62 @@
@extends('layouts.app')
@section('title', __('storefront.auth.login'))
@push('seo')
<meta name="robots" content="noindex, follow">
@endpush
@section('content')
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.auth.login') }}
</h1>
{{-- Unescaped: the label holds a <br /> (edited in Filament › Language Lines, staff only). --}}
<p class="mb-12">{!! __('storefront.auth.login_intro') !!}</p>
{{-- e.g. "account deletion scheduled", after AccountController::destroy() --}}
<x-ui.status class="mb-12" />
<form method="POST" action="{{ route('login.send') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.auth.email')"
for="login-email"
:required="true"
:error="$errors->first('email')"
>
<x-ui.input
id="login-email"
name="email"
type="email"
autocomplete="email"
:value="old('email')"
:required="true"
autofocus
:aria-invalid="$errors->has('email') ? 'true' : null"
:aria-describedby="$errors->has('email') ? 'login-email-error' : null"
/>
</x-ui.field>
{{-- Shown to everyone. Recording acceptance for new accounts is a
boboko-core task (UserOtpService). Unescaped: the label holds the
two links (Language Lines, staff only). --}}
<p class="text-sm text-neutral-500 [&_a]:underline [&_a:hover]:no-underline">
{!! __('storefront.auth.terms_notice', [
'terms' => route('legal.terms'),
'privacy' => route('legal.privacy'),
]) !!}
</p>
<div>
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
</div>
</form>
</section>
@endsection
+2 -1
View File
@@ -14,7 +14,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
@@ -0,0 +1,44 @@
{{--
<x-checkout::add-to-cart :purchasable="$variantId" />
A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller
(fetch) and hands the rendered cart body to the drawer over the
`bbk-cart:changed` window event.
Props:
purchasable ProductVariant id. Omit to render no hidden id field — the host
must then supply [data-bbk-purchasable-input] itself (e.g. a
variant picker writing the selected id into it).
quantity Integer for the hidden quantity field, or false to omit it
(the host then puts its own name="quantity" control in the slot).
The button and any quantity control come from the slot, so the host owns all
appearance. Extra attributes (class, etc.) land on the <form>.
--}}
@props([
'purchasable' => null,
'quantity' => 1,
'action' => null,
])
<form
method="POST"
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
data-controller="bbk-add-to-cart"
data-action="bbk-add-to-cart#add"
{{ $attributes->class('bbk-add-to-cart') }}
>
@csrf
@if (! is_null($purchasable))
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
@endif
@if ($quantity !== false)
<input type="hidden" name="quantity" value="{{ $quantity }}">
@endif
{{ $slot }}
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
</form>
@@ -0,0 +1,15 @@
{{--
Read-only formatted address. $address is any Lunar address model
(OrderAddress / CartAddress) — same column names on both.
--}}
@props(['address'])
<address class="bbk-address-lines">
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
@if ($address->state)<span>{{ $address->state }}</span>@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
@@ -0,0 +1,29 @@
{{--
<x-checkout::field name="billing_first_name" label="First name" required />
Generic labelled text input with old-input repopulation and validation
error display — the module's own equivalent of a host x-ui.field, used
instead of it per the module's independence rule. All styling is .bbk-field*
(resources/css/checkout.css); no host classes.
--}}
@props([
'name',
'label',
'type' => 'text',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<input
type="{{ $type }}"
name="{{ $name }}"
id="bbk-{{ $name }}"
value="{{ old($name, $value) }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,52 @@
{{--
The state/region + country pair for one address (billing or shipping).
Single-country store ($storeCountry set): region is a <select> of that
country's Lunar states, submitting `->name` (table-rate-shipping resolves
zones with State::whereName()), and country is a fixed hidden field + label.
Otherwise: free-text region + full country <select>, as before.
--}}
@props([
'prefix',
'storeCountry' => null,
'regions' => [],
'countries' => [],
'address' => null,
])
<div class="bbk-field-row">
@if ($storeCountry)
<x-checkout::select
:name="$prefix . '_state'"
label="{{ __('checkout.page.state') }}"
:options="$regions"
value-field="name"
translation-group="states"
:value="$address?->state"
placeholder="{{ __('checkout.page.state_placeholder') }}"
required
/>
<div class="bbk-field">
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
<p class="bbk-field-static">
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
? __("core::countries.{$storeCountry->name}")
: $storeCountry->name }}
</p>
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
</div>
@else
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
<x-checkout::select
:name="$prefix . '_country_id'"
label="{{ __('checkout.page.country') }}"
:options="$countries"
translation-group="countries"
:value="$address?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
@endif
</div>
@@ -0,0 +1,62 @@
{{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
`options` is an iterable of models/objects; `label` is always read from
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
`name` for Lunar states — table-rate-shipping resolves those with
State::whereName(), so the address must carry the exact name string).
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
boboko-core's lang/el/countries.php, lang/el/states.php) for the
DISPLAYED label only — the submitted `value` is always the untranslated
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
off the original English name. Falls back to the raw name when no
translation exists for the current locale (e.g. English, or a country
outside the covered set).
--}}
@props([
'name',
'label',
'options' => [],
'value' => null,
'placeholder' => null,
'required' => false,
'valueField' => 'id',
'translationGroup' => null,
])
@php
$optionLabel = function ($option) use ($translationGroup) {
if (! $translationGroup) {
return $option->name;
}
$key = "core::{$translationGroup}.{$option->name}";
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
};
@endphp
@php($selected = old($name, $value))
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<select
name="{{ $name }}"
id="bbk-{{ $name }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
@if ($placeholder)
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif
@foreach ($options as $option)
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
{{ $optionLabel($option) }}
</option>
@endforeach
</select>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,18 @@
@props([
'name',
'label',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<textarea
name="{{ $name }}"
id="bbk-{{ $name }}"
rows="3"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,131 @@
{{--
Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
--}}
@extends('layouts.app')
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.email_label') }}</dt>
<dd>{{ $order->billingAddress->contact_email }}</dd>
</div>
@endif
@if ($paymentMethodName)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.payment_heading') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
</dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account
(App\Listeners\ClaimGuestOrdersOnLogin), so it shows in their history. --}}
@guest
@if ($loginRoute = config('checkout.login_route'))
<p class="bbk-checkout-note">
{{ __('checkout.page.confirmation_login_hint') }}
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
@endguest
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<div class="bbk-cart-item-media">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
@endif
</div>
</div>
</div>
@endsection
+33
View File
@@ -0,0 +1,33 @@
{{--
Slide-in cart drawer. Rendered once, globally, from the app layout
(@include('checkout::drawer')). Structure only — all styling lives in
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
.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="click->bbk-cart#close"></div>
<aside
class="bbk-cart-panel"
role="dialog"
aria-modal="true"
aria-labelledby="bbk-cart-heading"
data-bbk-cart-target="panel"
>
<header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
<button
type="button"
class="bbk-cart-dismiss"
data-action="bbk-cart#close"
aria-label="{{ __('checkout.cart.close') }}"
>&times;</button>
</header>
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</aside>
</div>
+279
View File
@@ -0,0 +1,279 @@
{{--
The checkout page. Two columns: left is contact + billing + shipping +
shipping method, right is the order summary (the same cart-body partial the
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
checkout.css). Stops short of payment for this slice — see
CheckoutController's class docblock.
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
$countries come from CheckoutController::show().
--}}
@extends('layouts.app')
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-checkout-page">
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<div class="bbk-checkout">
<div
class="bbk-checkout-main"
data-controller="bbk-checkout-form bbk-payment"
data-action="input->bbk-checkout-form#scheduleSave"
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
>
{{-- Contact. Logged in: the order email is the account's (forced
server-side in saveAddress()), so there's no field. Guests type
their email, plus a login link when config('checkout.login_route')
is set; the storefront's login page sends them back here and Lunar
merges the guest cart into the account. --}}
@php($loginRoute = config('checkout.login_route'))
<section class="bbk-checkout-section">
@auth
<p class="bbk-checkout-logged-in">
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
</p>
@else
@if ($loginRoute)
<p class="bbk-checkout-login-prompt">
{{ __('checkout.page.login_prompt') }}
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
<x-checkout::field
name="contact_email"
label="{{ __('checkout.page.email_label') }}"
type="email"
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
required
form="bbk-address-form"
/>
@endauth
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
required — direct marketing under ePrivacy (GR L. 3471/2006
art. 11), so it needs an explicit opt-in and checkout can't be
gated on it. Narrow scope by design (boboko-core's
setRecoveryConsent) — a general newsletter opt-in, if wanted,
is a separate checkbox. --}}
<label class="bbk-checkbox bbk-checkbox--stacked">
<input
type="checkbox"
name="recovery_consent"
value="1"
form="bbk-address-form"
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
>
{{ __('checkout.page.recovery_consent') }}
</label>
</section>
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
(this form, plus the contact email/consent which sit outside it but
link via form="bbk-address-form") is debounced and POSTed as the whole
form; the shipping-method radios are excluded in scheduleSave(). --}}
<form
id="bbk-address-form"
method="POST"
action="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-target="form"
>
@csrf
{{-- Billing --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
<div class="bbk-field-row">
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
</div>
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
(:has on the checkbox), saved/cleared by saveAddress(), and
required at place-order. --}}
<div class="bbk-invoice">
<label class="bbk-checkbox">
<input
type="checkbox"
name="wants_invoice"
value="1"
aria-controls="bbk-invoice-fields"
@checked($wantsInvoice)
>
{{ __('checkout.page.wants_invoice') }}
</label>
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
</div>
</div>
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="billing"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$billingAddress"
/>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section>
{{-- Shipping --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
<label class="bbk-checkbox">
<input
type="checkbox"
name="same_as_billing"
value="1"
data-bbk-checkout-form-target="sameAsBilling"
data-action="bbk-checkout-form#toggleSameAsBilling"
@checked($shipToBilling)
>
{{ __('checkout.page.same_as_billing') }}
</label>
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
<div class="bbk-field-row">
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
</div>
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="shipping"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$shippingAddress"
/>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div>
<x-checkout::textarea
name="shipping_delivery_instructions"
label="{{ __('checkout.page.delivery_instructions') }}"
:value="$shippingAddress?->delivery_instructions"
/>
</section>
</form>
<p
class="bbk-checkout-status"
data-bbk-checkout-form-target="status"
role="status"
aria-live="polite"
hidden
></p>
{{-- Shipping method — resolves from the saved shipping address;
re-rendered as a fragment by bbk-checkout-form after each
autosave / option change. --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
@include('checkout::partials.shipping-options', [
'shippingAddress' => $shippingAddress,
'shippingOptions' => $shippingOptions,
])
</div>
</section>
{{-- Payment --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods">
@include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods,
'cart' => $cart,
])
</div>
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
<label class="bbk-checkbox bbk-checkbox--stacked">
<input type="checkbox" data-bbk-payment-target="terms">
{!! __('checkout.page.terms_accept', [
'terms' => route('legal.terms', app()->getLocale()),
'privacy' => route('legal.privacy', app()->getLocale()),
]) !!}
</label>
<p class="bbk-checkout-withdrawal">
{!! __('checkout.page.withdrawal_notice', [
'link' => route('legal.shipping-returns', app()->getLocale()),
]) !!}
</p>
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
<button
type="button"
class="bbk-checkout-continue"
data-bbk-payment-target="submit"
data-action="bbk-payment#placeOrder"
@disabled($lines->isEmpty())
>
{{ __('checkout.page.place_order') }}
</button>
</section>
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
<span class="bbk-spinner" aria-hidden="true"></span>
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
</div>
</div>
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</div>
</aside>
</div>
</div>
@endsection
@@ -0,0 +1,121 @@
{{--
Server-rendered cart contents. Rendered inline on first page load inside
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
bbk-cart-controller after every mutation. $cart / $lines come from the view
composer in CheckoutModuleServiceProvider.
The data-bbk-cart-* attributes on the root are the module's read API for the
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
each swap and re-emits them on the `bbk-cart:updated` window event.
--}}
@php($count = $lines->sum('quantity'))
{{-- @dump($lines) --}}
<div
class="bbk-cart-content"
data-bbk-cart-count="{{ $count }}"
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
>
@if ($lines->isEmpty())
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
@else
<ul class="bbk-cart-items">
@each('checkout::partials.cart-line', $lines, 'line')
</ul>
<div class="bbk-cart-summary">
<div class="bbk-cart-coupon">
@if ($cart?->coupon_code)
<div class="bbk-cart-coupon-applied">
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
<form
method="POST"
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button type="submit" class="bbk-cart-coupon-remove">
{{ __('checkout.cart.coupon_remove') }}
</button>
</form>
</div>
@else
<form
class="bbk-cart-coupon-form"
method="POST"
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
<label class="bbk-visually-hidden" for="bbk-coupon-code">
{{ __('checkout.cart.coupon_label') }}
</label>
<input
type="text"
name="code"
id="bbk-coupon-code"
class="bbk-cart-coupon-input"
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
autocomplete="off"
required
>
<button type="submit" class="bbk-cart-coupon-submit">
{{ __('checkout.cart.coupon_apply') }}
</button>
</form>
@if ($couponError ?? false)
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
@endif
@endif
</div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $cart->discountTotal->formatted() }}</span>
</div>
@endif
{{-- Shipping + tax appear once the shopper has a shipping address
(i.e. they're on the checkout page). In the drawer, where no
address is set yet, only subtotal + total show. --}}
@if ($cart?->shippingAddress)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
@if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div>
@endif
@if ($cart?->taxTotal?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span>
</div>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</a>
</div>
@endif
</div>
@@ -0,0 +1,9 @@
{{--
Shared error slot for any host wrapping cart-body in a bbk-cart controller
instance (the drawer, and the checkout page's own order summary) —
bbk-cart-controller.js#showError() writes into whichever one is present.
Without this element in a given host, a rejected quantity update (e.g.
over stock) still gets rejected server-side, but the shopper never sees
why.
--}}
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
@@ -0,0 +1,103 @@
{{--
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
@each in cart-body). The two forms post through bbk-cart-controller
(fetch + method spoofing) and the response re-renders cart-body.
--}}
@php
$variant = $line->purchasable;
$product = $variant?->product;
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
// The variant's own image (falls back to the product's thumbnail
// internally — see ProductVariant::getThumbnail()) — the specific option
// 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)
@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">
@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
@include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form
class="bbk-cart-qty"
method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
>
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
</form>
</div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div>
</li>
@@ -0,0 +1,42 @@
{{--
@include('checkout::partials.line-custom-fields', ['line' => $line])
A cart or order line's custom-field answers (meta.custom_fields, written by
CartController::customFieldsMeta()) — label/value pairs. A file answer links
to the file through a temporary signed URL (CustomFieldFileController),
minted fresh on every render, with a thumbnail when the browser can display
the format (HEIC can't be shown outside Safari, so it gets the name only).
--}}
@php
$fields = $line->meta['custom_fields'] ?? [];
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
@endphp
@if (! empty($fields))
<dl class="bbk-line-fields">
@foreach ($fields as $field)
<div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt>
<dd>
@if ($field['type'] === 'file')
@php
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
'checkout.custom-field-file',
now()->addHours(2),
['locale' => app()->getLocale(), 'disk' => $field['disk'], 'path' => $field['path']],
);
@endphp
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
@if (in_array($field['mime'] ?? null, $previewable, true))
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
@endif
<span>{{ $field['value'] }}</span>
</a>
@else
{{ $field['value'] }}
@endif
</dd>
</div>
@endforeach
</dl>
@endif
@@ -0,0 +1,30 @@
{{--
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
filtered to enabled + driver-resolves + isConfigured()). Selecting one
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
controller whether to mount the Stripe Element.
$paymentMethods, $cart come from the page / controller.
--}}
@php($selected = $cart?->meta['payment_method'] ?? null)
@if ($paymentMethods->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
@else
<div class="bbk-checkout-payment-options">
@foreach ($paymentMethods as $method)
<label class="bbk-checkout-payment-option">
<input
type="radio"
name="payment_type"
value="{{ $method->type }}"
data-payment-driver="{{ $method->driver }}"
@checked($selected === $method->type)
data-action="change->bbk-payment#selectMethod"
>
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
</label>
@endforeach
</div>
@endif
@@ -0,0 +1,50 @@
{{--
Shipping methods for the checkout page. Rendered inline by page.blade.php on
load, and re-rendered as a fragment by CheckoutController after every
address save / option change (bbk-checkout-form swaps it in). Radios
autosave via bbk-checkout-form#selectShipping — no submit button. A single
resolved option is auto-selected server-side and shown as a fixed line.
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@if (! $shippingAddress?->postcode)
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@elseif ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
@elseif ($shippingOptions->count() === 1)
@php($only = $shippingOptions->first())
<div class="bbk-checkout-shipping-confirmed">
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
@if ($only->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
</div>
@else
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@endif
+34 -3
View File
@@ -41,11 +41,42 @@
</a>
@endforeach
{{-- Cart --}}
<a href="{{ url('/'.app()->getLocale().'/cart') }}" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
<x-ui.icon name="bag" :size="40" />
{{-- Wishlist — the account page, or the cookie-based guest page --}}
<a
href="{{ auth()->check() ? route('account.wishlist') : route('wishlist') }}"
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
aria-label="{{ __('storefront.account.nav_wishlist') }}"
@if (request()->routeIs('account.wishlist', 'wishlist')) aria-current="page" @endif
>
<x-ui.icon name="heart" :size="40" />
</a>
{{-- Account — the account page, or login for guests --}}
<a
href="{{ auth()->check() ? route('account') : route('login') }}"
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
aria-label="{{ auth()->check() ? __('storefront.nav.account') : __('storefront.auth.login') }}"
>
<x-ui.icon name="user" :size="40" />
</a>
{{-- Cart — opens the checkout module's drawer, no separate cart page --}}
<button
type="button"
class="relative flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
aria-label="Cart"
aria-haspopup="dialog"
data-controller="cart-count"
data-action="cart-count#open"
>
<x-ui.icon name="bag" :size="40" />
<span
data-cart-count-target="badge"
class="absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full bg-black text-neutral-200 text-xs font-bold flex items-center justify-center"
hidden
>0</span>
</button>
{{-- Search --}}
<button
type="button"
@@ -0,0 +1,13 @@
{{--
An order status as text, e.g. "Σε επεξεργασία". Translated through
storefront.order_status.{status}, falling back to Lunar's own (English)
label from config('lunar.orders.statuses') when that key doesn't exist.
--}}
@props(['status'])
@php
$key = "storefront.order_status.{$status}";
$label = Lang::has($key) ? __($key) : config("lunar.orders.statuses.{$status}.label", $status);
@endphp
<span {{ $attributes }}>{{ $label }}</span>
@@ -0,0 +1,76 @@
{{--
<x-product-custom-fields :fields="$product['custom_fields']" />
The product's custom fields (boboko-core's Product::$custom_fields), rendered
inside the add-to-cart <form> so their values travel with it as
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
and only the returned reference is submitted with the form — the file input
itself has no name. It still carries `required`, so the browser's own
validation blocks add-to-cart until a photo is picked, and the controller
marks it invalid (setCustomValidity) while the upload is in flight.
--}}
@props(['fields' => []])
@use('App\Http\Controllers\CustomFieldUploadController')
<div class="flex flex-col gap-6">
@foreach ($fields as $field)
@php
$id = 'custom-field-'.$field['key'];
$name = 'custom_fields['.$field['key'].']';
$required = (bool) ($field['required'] ?? false);
@endphp
@switch($field['type'])
@case('textarea')
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
</x-ui.field>
@break
@case('file')
<x-ui.field
:label="$field['label']"
:for="$id"
:required="$required"
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
data-controller="custom-field-upload"
data-custom-field-upload-url-value="{{ route('custom-field-upload.store') }}"
data-custom-field-upload-label-value="{{ $field['label'] }}"
data-custom-field-upload-uploading-message-value="{{ __('storefront.product.custom_field_uploading') }}"
data-custom-field-upload-failed-message-value="{{ __('storefront.product.custom_field_upload_failed') }}"
>
<input type="hidden" name="{{ $name }}" data-custom-field-upload-target="reference">
<x-ui.file-input
:id="$id"
:accept="CustomFieldUploadController::accept()"
:required="$required"
aria-describedby="{{ $id }}-description {{ $id }}-error"
data-custom-field-upload-target="file"
data-action="change->custom-field-upload#upload"
/>
<img
alt=""
width="96"
height="96"
hidden
class="w-24 h-24 object-cover border border-black"
data-custom-field-upload-target="preview"
data-action="error->custom-field-upload#hidePreview"
>
<p id="{{ $id }}-error" class="text-sm text-red-600" role="alert" hidden data-custom-field-upload-target="error"></p>
</x-ui.field>
@break
@default
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
</x-ui.field>
@endswitch
@endforeach
</div>
@@ -24,6 +24,8 @@
:price="$product['price'] ?? null"
:image="$product['image'] ?? null"
:href="$product['href'] ?? '#'"
:variant-id="$product['variantId'] ?? null"
:has-custom-fields="$product['hasCustomFields'] ?? false"
/>
@endforeach
</div>
@@ -1,26 +1,39 @@
@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>
@if (!empty($review['image']))
<div class="mt-1">
<img
src="{{ $review['image'] }}"
alt="Φωτογραφία από τον αγοραστή"
class="w-24 h-24 object-cover border border-black"
>
<a href="{{ $review['image'] }}" target="_blank" rel="noopener noreferrer">
<img
src="{{ $review['image'] }}"
alt="Φωτογραφία από τον αγοραστή"
class="w-24 h-24 object-cover border border-black"
>
</a>
</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
@@ -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->tab-link#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>
+12 -1
View File
@@ -3,6 +3,7 @@
'href' => null,
'type' => 'button',
'size' => 'lg',
'variant' => 'primary',
'position' => 'relative',
])
@@ -15,6 +16,16 @@
default => 'py-5 px-[46px] text-[19px]',
};
// 'primary' is the CTA look (offset-shadow via .btn-primary's ::before/
// ::after, italic, uppercase). 'secondary' is a plain bordered toggle —
// no shadow layers, fills solid on hover/aria-pressed=true instead, used
// for option pickers (see x-ui.option-buttons) and anywhere else a
// secondary/toggle action shouldn't compete visually with the CTA.
$variantClasses = match($variant) {
'secondary' => 'font-semibold hover:bg-black hover:text-neutral-200 aria-pressed:bg-black aria-pressed:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black',
default => 'btn-primary font-bold italic uppercase',
};
// $position defaults to 'relative' (needed so the ::before/::after layers
// in .btn-primary position against the button itself), but callers that
// need to place the button absolutely (e.g. a hover-reveal CTA over a
@@ -22,7 +33,7 @@
// "absolute" via the class prop — Tailwind's generated stylesheet always
// orders the "relative" utility after "absolute", so on a class clash
// "relative" silently wins and the button never actually gets positioned.
$class = 'btn-primary '.$position.' isolate inline-flex items-center justify-center border border-black font-display font-bold italic text-black cursor-pointer no-underline uppercase ' . $sizeClasses;
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline '.$variantClasses.' '.$sizeClasses);
$attrs = $href
? $attributes->merge(['href' => $href, 'class' => $class])
@@ -1,12 +1,11 @@
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
models, since this is fed from Modules\Core\Catalog\Services\ProductService. --}}
@props(['variants', 'option' => null])
{{-- $values: one product option's de-duplicated, ordered values, as built by
ProductController::buildOptionPicker() — [{id, label, hex}]. --}}
@props(['values', 'option' => null, 'optionHandle' => null])
<div {{ $attributes }}>
@if($option)
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
{{ $option }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
{{ $option }}: <span data-product-form-target="colorName" data-option="{{ $optionHandle }}" class="font-normal normal-case"></span>
</p>
@endif
@@ -15,22 +14,17 @@ class="flex flex-wrap gap-2"
role="group"
aria-label="{{ $option ?? 'Color' }}"
>
@foreach($variants as $variant)
@php
$value = $variant['options'][0] ?? null;
$label = $value['value'] ?? '';
$bg = $value['meta']['hex'] ?? '#cccccc';
@endphp
@foreach($values as $value)
<button
type="button"
class="color-swatch"
data-product-form-target="swatch"
data-action="click->product-form#selectVariant"
data-variant-id="{{ $variant['id'] }}"
style="background-color: {{ $bg }};"
aria-label="{{ $label }}"
data-option="{{ $optionHandle }}"
data-value-id="{{ $value['id'] }}"
style="background-color: {{ $value['hex'] ?? '#cccccc' }};"
aria-label="{{ $value['label'] }}"
aria-pressed="false"
title="{{ $label }}"
></button>
@endforeach
</div>

Some files were not shown because too many files have changed in this diff Show More