generated from boboko/starter
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4634e14924 | ||
|
|
69486f65ee | ||
|
|
060b6c647e | ||
|
|
36806cac5b | ||
|
|
b1162761de | ||
|
|
8b362c9436 | ||
|
|
cd45d8f578 | ||
|
|
8e186fc231 | ||
|
|
a51ce78538 | ||
|
|
ab7e560a3d | ||
|
|
081893ef57 | ||
|
|
e2d7bbb043 | ||
|
|
3347febb3c | ||
|
|
78c8165c04 |
@@ -61,6 +61,9 @@ MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
CONTACT_EMAIL=
|
||||
|
||||
HCAPTCHA_SITEKEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
+11
-3
@@ -10,11 +10,19 @@ RUN composer install \
|
||||
--optimize-autoloader \
|
||||
--ignore-platform-reqs
|
||||
|
||||
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
|
||||
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
|
||||
FROM node:22-alpine AS vite-dev
|
||||
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
|
||||
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
|
||||
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
|
||||
# isn't visible in the build context, only once bind-mounted at container start,
|
||||
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
|
||||
# redundant even if it could work.
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||
RUN chmod +x /entrypoint-vite.sh
|
||||
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||
|
||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS node-build
|
||||
|
||||
@@ -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, variantId: ?int, hasCustomFields: bool}
|
||||
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool, soldOut: bool}
|
||||
*/
|
||||
public static function fromIndexed(array $product): array
|
||||
{
|
||||
@@ -38,6 +38,10 @@ public static function fromIndexed(array $product): array
|
||||
// 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']),
|
||||
// Index-time stock (see boboko-core's ProductIndexer `in_stock`),
|
||||
// so only as fresh as the last reindex. A document missing the
|
||||
// field is treated as in stock rather than hiding its cart button.
|
||||
'soldOut' => ! ($product['in_stock'] ?? true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Rules\HCaptcha;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -43,8 +44,13 @@ public function create(string $locale, Request $request): View
|
||||
|
||||
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
// Captcha only here: verify() and resend() need the email this step
|
||||
// puts in the session, so they can't be reached without passing it.
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
], [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
]);
|
||||
|
||||
$email = Str::lower(trim($validated['email']));
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\CustomFieldUploadController;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
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 Illuminate\Support\Facades\App;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\File\Models\File;
|
||||
use Modules\Core\File\Services\FileService;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* 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 id of a File row the host's own upload endpoint
|
||||
* (CustomFieldUploadController) already created via boboko-core's
|
||||
* FileService — never the file's bytes, disk, or path, all of which
|
||||
* FileService alone is the source of truth for. A shopper can't point
|
||||
* this at someone else's file: the id must resolve to a File that is
|
||||
* BOTH unowned (isFileAnswerValid()) and tagged with
|
||||
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
||||
* already-ordered file (owned, and/or a different purpose entirely) is
|
||||
* rejected. Attaching the File to the real CartLine it belongs to
|
||||
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
||||
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
||||
* CartLineAdded) — not here, since this method only builds the meta
|
||||
* $this->cart->addLine() is about to receive, before any CartLine
|
||||
* actually exists to own anything.
|
||||
*
|
||||
* 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')
|
||||
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validated = Validator::make(
|
||||
$input,
|
||||
$fields->map(fn (array $field) => [
|
||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||
...match ($field['type']) {
|
||||
'textarea' => ['string', 'max:2000'],
|
||||
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||
if (! $this->isFileAnswerValid($value)) {
|
||||
$fail('validation.uploaded')->translate();
|
||||
}
|
||||
}],
|
||||
default => ['string', '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'
|
||||
? ['file_id' => (int) $validated[$field['key']]]
|
||||
: ['value' => $validated[$field['key']]]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $answers === [] ? [] : ['custom_fields' => $answers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product::$custom_fields stores `label` as {locale: string} (see
|
||||
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
||||
* resolves it to the single current-locale string cart/order line meta
|
||||
* actually needs, the same filled()-over-?? fallback boboko-core's
|
||||
* ProductDocumentLocalizer uses for every other translated field (an
|
||||
* empty string for the current locale still falls through to the
|
||||
* store's default language, rather than showing blank). A product
|
||||
* saved before labels became translatable still has a plain string
|
||||
* here, returned as-is.
|
||||
*/
|
||||
private function resolveLabel(mixed $label): string
|
||||
{
|
||||
if (! is_array($label)) {
|
||||
return (string) $label;
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||
|
||||
return filled($label[$locale] ?? null)
|
||||
? $label[$locale]
|
||||
: ($label[$fallbackLocale] ?? '');
|
||||
}
|
||||
|
||||
private function isFileAnswerValid(mixed $fileId): bool
|
||||
{
|
||||
$file = File::find($fileId);
|
||||
|
||||
return $file !== null
|
||||
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||
&& $file->owner_id === null
|
||||
&& app(FileService::class)->exists($file);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
<?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->account->setRecoveryConsent(Auth::user(), $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;
|
||||
}
|
||||
|
||||
private function storeCountry(): ?Country
|
||||
{
|
||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,9 @@ private function buildOptionPicker(int $productId): array
|
||||
'id' => $variant->id,
|
||||
'price' => $variant->prices->first()?->price?->decimal(),
|
||||
'image' => $variant->images->first()?->getUrl(),
|
||||
// Live from the DB (not the index's in_stock), with Lunar's own
|
||||
// purchasability rule — drives the disabled add-to-cart button.
|
||||
'inStock' => $variant->canBeFulfilledAtQuantity(1),
|
||||
// handle => selected value id, for matching a combination of
|
||||
// selections back to this variant — see selectVariant() in
|
||||
// product-form-controller.js.
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
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;
|
||||
use Modules\Core\Wishlist\Services\WishlistService;
|
||||
|
||||
/**
|
||||
* Page rendering only — the toggle action itself lives in core
|
||||
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
|
||||
* `wishlist.toggle`), since it needs no app-specific presentation.
|
||||
*/
|
||||
class WishlistController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Wishlist $wishlist,
|
||||
private readonly WishlistService $wishlist,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -39,24 +41,6 @@ public function guest(string $locale, ProductService $products): View|RedirectRe
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\HCaptcha;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
@@ -17,6 +18,14 @@ public function rules(): array
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'max:5000'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?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'];
|
||||
}
|
||||
@@ -17,13 +17,6 @@
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Verifies the `h-captcha-response` token the hCaptcha widget adds to a form.
|
||||
* Use it as `'h-captcha-response' => ['required', new HCaptcha]`.
|
||||
*
|
||||
* Fails closed: if hCaptcha can't be reached the submission is rejected, since
|
||||
* letting it through would reopen the hole this exists to close (bots making
|
||||
* us send email to arbitrary addresses).
|
||||
*/
|
||||
class HCaptcha implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout(5)
|
||||
->post('https://api.hcaptcha.com/siteverify', [
|
||||
'secret' => config('services.hcaptcha.secret'),
|
||||
'response' => $value,
|
||||
// Rejects tokens solved against someone else's sitekey.
|
||||
'sitekey' => config('services.hcaptcha.sitekey'),
|
||||
'remoteip' => request()->ip(),
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]);
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $response->successful() || $response->json('success') !== true) {
|
||||
// A bad/missing secret or sitekey would otherwise look like every
|
||||
// shopper failing the captcha.
|
||||
$configErrors = array_intersect((array) $response->json('error-codes'), [
|
||||
'missing-input-secret',
|
||||
'invalid-input-secret',
|
||||
'sitekey-secret-mismatch',
|
||||
'invalid-sitekey',
|
||||
]);
|
||||
|
||||
if ($response->failed() || $configErrors) {
|
||||
Log::warning('hCaptcha siteverify error', [
|
||||
'status' => $response->status(),
|
||||
'error-codes' => $response->json('error-codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\CheckoutModuleServiceProvider;
|
||||
use App\Providers\PanelServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
PanelServiceProvider::class,
|
||||
CheckoutModuleServiceProvider::class,
|
||||
];
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"_repositories": [
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../boboko-core",
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"repositories": [
|
||||
"_repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://code.radical-elements.com/boboko/core.git"
|
||||
|
||||
Generated
+24
-19
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a89bc3e4e93372ee52664163d56f783b",
|
||||
"content-hash": "ba9c83a482613f3bd1f9a2b82ab91f66",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -515,11 +515,11 @@
|
||||
},
|
||||
{
|
||||
"name": "boboko/core",
|
||||
"version": "0.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://code.radical-elements.com/boboko/core.git",
|
||||
"reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d"
|
||||
"version": "0.22.0",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "../boboko-core",
|
||||
"reference": "547f07f01e043206d36b54290633f9f8fe6c1e14"
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": "^12.0",
|
||||
@@ -552,11 +552,13 @@
|
||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
||||
"Modules\\Core\\Providers\\CheckoutModuleServiceProvider",
|
||||
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
||||
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
||||
"Modules\\Core\\Providers\\FileServiceProvider",
|
||||
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
||||
"Modules\\Core\\Providers\\OrderServiceProvider",
|
||||
"Modules\\Core\\Providers\\PrivacyServiceProvider"
|
||||
@@ -569,7 +571,10 @@
|
||||
}
|
||||
},
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"time": "2026-09-23T06:47:28+00:00"
|
||||
"transport-options": {
|
||||
"symlink": true,
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -2937,16 +2942,16 @@
|
||||
},
|
||||
{
|
||||
"name": "kirschbaum-development/eloquent-power-joins",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kirschbaum-development/eloquent-power-joins.git",
|
||||
"reference": "c609dbbe4ad2051b667e937f1ab554067519d64b"
|
||||
"reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/c609dbbe4ad2051b667e937f1ab554067519d64b",
|
||||
"reference": "c609dbbe4ad2051b667e937f1ab554067519d64b",
|
||||
"url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/a0e6c0420c381861e1830693c4b74bdac7f7fb5f",
|
||||
"reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2994,9 +2999,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues",
|
||||
"source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.3"
|
||||
"source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.4"
|
||||
},
|
||||
"time": "2026-07-23T11:41:37+00:00"
|
||||
"time": "2026-09-25T11:10:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
@@ -12600,16 +12605,16 @@
|
||||
},
|
||||
{
|
||||
"name": "technikermathe/blade-lucide-icons",
|
||||
"version": "v3.181.0",
|
||||
"version": "v3.182.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PascaleBeier/blade-lucide-icons.git",
|
||||
"reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98"
|
||||
"reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98",
|
||||
"reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98",
|
||||
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/11253aa7d9aa7430da61d3534c62dc69a95b703b",
|
||||
"reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12659,9 +12664,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues",
|
||||
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.181.0"
|
||||
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.182.0"
|
||||
},
|
||||
"time": "2026-09-18T02:09:04+00:00"
|
||||
"time": "2026-09-25T02:29:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Product recommendation rules
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||
| the first rule that returns at least one product wins. The order here IS
|
||||
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||
| last-resort so a product page is never left with zero recommendations
|
||||
| (as long as the store has more than one product). A consuming app can
|
||||
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||
| hardcoded in the service itself.
|
||||
|
|
||||
*/
|
||||
'recommendation_rules' => [
|
||||
SameCategoryRule::class,
|
||||
RandomRule::class,
|
||||
],
|
||||
];
|
||||
+30
-4
@@ -2,17 +2,43 @@
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
|
||||
* artisan vendor:publish --tag=core-config.
|
||||
*/
|
||||
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 page must send the shopper back there afterwards. null: no login
|
||||
* offered in checkout at all.
|
||||
*/
|
||||
'login_route' => 'login',
|
||||
|
||||
/*
|
||||
* Name of the storefront's product-listing route — where confirmation()
|
||||
* redirects a visit with no placed order to look at (session expired,
|
||||
* direct navigation, a bookmark). route($this, $locale) must resolve.
|
||||
*/
|
||||
'products_route' => 'products',
|
||||
|
||||
/*
|
||||
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
|
||||
* field, forced server-side — no country picker shown at all). null (the
|
||||
* default) gives the full country/region picker, for a multi-country
|
||||
* store. 3dealer is Greece-only for now.
|
||||
*/
|
||||
'store_country_iso3' => 'GRC',
|
||||
|
||||
/*
|
||||
* The `purpose` tag CartController expects a product custom field's
|
||||
* `file` answer to already carry (see Modules\Core\File\Models\File) —
|
||||
* matches whatever purpose string the host's own upload endpoint
|
||||
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
|
||||
* tags its stored files with. This module never reaches into that
|
||||
* host controller directly; this config value is the one shared
|
||||
* source of truth between the two.
|
||||
*/
|
||||
'custom_field_upload_purpose' => 'custom-field-upload',
|
||||
|
||||
];
|
||||
|
||||
+122
@@ -16,4 +16,126 @@
|
||||
|
||||
'auto_create_customer_for_user' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Privacy / GDPR data-subject requests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
|
||||
| that should be consulted for right-of-access/right-of-erasure requests. A
|
||||
| module never needs to be known to core in advance — it just adds its own
|
||||
| provider class here, the same way config('lunar.search.indexers') maps a
|
||||
| model to its indexer. See docs/privacy.md.
|
||||
|
|
||||
| 'grace_period_days' is how long an erasure request stays cancellable
|
||||
| (account deactivated, not yet erased) before it's actually processed by
|
||||
| the privacy:process-erasure-requests scheduled command.
|
||||
|
|
||||
*/
|
||||
|
||||
'privacy' => [
|
||||
'providers' => [
|
||||
// ActivityLogDataProvider MUST run before AddressDataProvider —
|
||||
// it resolves which activity_log rows belong to this customer
|
||||
// (including ones keyed by an Address id) before
|
||||
// AddressDataProvider hard-deletes those Address rows. See that
|
||||
// provider's own class docblock.
|
||||
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
|
||||
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
|
||||
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
|
||||
\Modules\Core\Order\Privacy\OrderDataProvider::class,
|
||||
\Modules\Core\Cart\Privacy\CartDataProvider::class,
|
||||
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
|
||||
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
|
||||
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
|
||||
],
|
||||
|
||||
'grace_period_days' => 30,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cart Abandonment Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How long a cart (that hasn't converted to a placed order) can go without
|
||||
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
|
||||
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
|
||||
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
||||
|
|
||||
*/
|
||||
|
||||
'cart' => [
|
||||
'abandoned_after' => '1 hour',
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Unrecoverable Cap
|
||||
|----------------------------------------------------------------------
|
||||
|
|
||||
| Beyond this age, a stale cart stops being treated as an active
|
||||
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
|
||||
| CartLifecycleService) — too old to be a realistic recovery target
|
||||
| (pricing/stock/tax likely stale by then). This is about the
|
||||
| abandoned-cart pipeline only, not data retention — no rows are
|
||||
| deleted or pruned based on this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'unrecoverable_after' => '90 days',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Order Return Window
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How many days after a carrier order is delivered (Order::fulfillment_status
|
||||
| becomes 'return_window_open') before Modules\Core\Order\Commands\
|
||||
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
|
||||
| Store-pickup orders have no return-window step and are unaffected by
|
||||
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
||||
|
|
||||
*/
|
||||
|
||||
'order' => [
|
||||
'return_window_days' => 14,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Storefront OTP Login
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
|
||||
| max_attempts caps how many wrong codes a shopper can guess against ONE
|
||||
| generated code before it's invalidated outright. generation_limit/
|
||||
| generation_decay_minutes cap how often a NEW code can be requested for
|
||||
| the same email — independent of max_attempts, since generating a fresh
|
||||
| code also resets the guess count, so an attempt cap alone doesn't stop
|
||||
| an attacker from just requesting a new code every few tries. This same
|
||||
| limit is also what stands between a malicious/careless caller and
|
||||
| mail-bombing one inbox.
|
||||
|
|
||||
*/
|
||||
|
||||
'auth' => [
|
||||
'otp' => [
|
||||
'max_attempts' => 5,
|
||||
'generation_limit' => 3,
|
||||
'generation_decay_minutes' => 10,
|
||||
],
|
||||
|
||||
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
|
||||
// shape/reasoning as auth.otp above, independent limits since this
|
||||
// is a separate flow (changing an existing account's login email,
|
||||
// not logging in).
|
||||
'email_change' => [
|
||||
'max_attempts' => 5,
|
||||
'generation_limit' => 3,
|
||||
'generation_decay_minutes' => 10,
|
||||
'expiry_minutes' => 10,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -40,6 +40,15 @@
|
||||
'email' => env('CONTACT_EMAIL'),
|
||||
],
|
||||
|
||||
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||
// secret 0x0000000000000000000000000000000000000000.
|
||||
'hcaptcha' => [
|
||||
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||
'secret' => env('HCAPTCHA_SECRET'),
|
||||
],
|
||||
|
||||
'stoic' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'host' => env('STOIC_HOST'),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ACS Courier credentials
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||
| HTTP header (gates the REST gateway itself) and four account fields
|
||||
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||
| request body. Both are supplied by ACS when your account is set up.
|
||||
|
|
||||
| Set these via environment variables — never commit real values.
|
||||
|
|
||||
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||
| every ACSAlias call).
|
||||
| ACS_API_KEY The AcsApiKey header value.
|
||||
| ACS_COMPANY_ID Company_ID body field.
|
||||
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||
| ACS_USER_ID User_ID body field.
|
||||
| ACS_USER_PASSWORD User_Password body field.
|
||||
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||
| calculation and voucher creation.
|
||||
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||
|
||||
'api_key' => env('ACS_API_KEY'),
|
||||
|
||||
'company_id' => env('ACS_COMPANY_ID'),
|
||||
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||
'user_id' => env('ACS_USER_ID'),
|
||||
'user_password' => env('ACS_USER_PASSWORD'),
|
||||
|
||||
'billing_code' => env('ACS_BILLING_CODE'),
|
||||
|
||||
'sender' => [
|
||||
'name' => env('ACS_SENDER_NAME'),
|
||||
'address' => env('ACS_SENDER_ADDRESS'),
|
||||
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||
'phone' => env('ACS_SENDER_PHONE'),
|
||||
],
|
||||
|
||||
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Box Now credentials
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||
| Unlike ACS, there is no separate per-request credential body — the
|
||||
| token alone authorizes all calls once obtained.
|
||||
|
|
||||
| Set these via environment variables — never commit real values.
|
||||
|
|
||||
| Box Now has two environments (see their Partner API manual, section 2):
|
||||
| Stage/Sandbox for testing, Production once live. Each has its own
|
||||
| client_id/client_secret pair and its own base_url/location_api_url —
|
||||
| there is no shared "switch an env var" flag, since stage credentials
|
||||
| don't work against the production host or vice versa.
|
||||
|
|
||||
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||
| lookups (Box Now recommends this over the main
|
||||
| base URL for those two calls specifically).
|
||||
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
|
||||
| credentials. NOT used for REST API authentication
|
||||
| (BoxNowClient authenticates with client_id/
|
||||
| client_secret alone) — this is only consumed by
|
||||
| the client-side Destination Map widget config
|
||||
| (_bn_map_widget_config.partnerId), confirmed
|
||||
| against Box Now's own WooCommerce plugin source.
|
||||
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||
| the pickup origin on every delivery request.
|
||||
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||
| delivery request.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||
|
||||
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||
'partner_id' => env('BOXNOW_PARTNER_ID'),
|
||||
|
||||
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||
|
||||
'sender' => [
|
||||
'name' => env('BOXNOW_SENDER_NAME'),
|
||||
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||
],
|
||||
|
||||
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||
|
||||
];
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
<?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', 'Συνέχεια αγορών'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,7 @@ services:
|
||||
scheduler:
|
||||
volumes:
|
||||
- ../boboko-core:/var/www/boboko-core
|
||||
|
||||
vite:
|
||||
volumes:
|
||||
- ../boboko-core:/boboko-core
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Mirrors entrypoint.sh's Composer block: node_modules is a named/anonymous
|
||||
# volume, not the bind-mounted host directory, so this container needs its
|
||||
# own live install on every boot rather than relying on whatever was baked in
|
||||
# at image-build time.
|
||||
echo "[entrypoint-vite] Installing npm dependencies..."
|
||||
npm install
|
||||
|
||||
# Re-resolve @boboko/core specifically on every boot, whether it's a local
|
||||
# path-repo checkout (file:../boboko-core, needs docker-compose.core-dev.yml's
|
||||
# ../boboko-core:/boboko-core mount) or a tagged VCS install
|
||||
# (git+https://...#vX.Y.Z) — the same manual toggle composer.json's
|
||||
# repositories/_repositories block uses for the PHP side of this same
|
||||
# package. A plain `npm install` above only installs what package-lock.json
|
||||
# already pins; it won't notice a new commit on the path-repo checkout or a
|
||||
# re-pushed tag. `npm update` re-resolves that one package against whatever
|
||||
# package.json currently says and rewrites package-lock.json to pin it —
|
||||
# exactly like `composer update boboko/* --with-all-dependencies` rewrites
|
||||
# composer.lock, which is committed so prod picks up the resolved version via
|
||||
# `npm install` alone (no `npm update` in prod's build).
|
||||
echo "[entrypoint-vite] Re-resolving @boboko/core..."
|
||||
npm update @boboko/core
|
||||
|
||||
exec "$@"
|
||||
@@ -79,6 +79,16 @@ echo "[entrypoint] Touched migrated file"
|
||||
echo "[entrypoint] Trying Lunar install"
|
||||
php artisan lunar:install --quiet || true
|
||||
|
||||
# Same idempotent per-key upsert as lunar:install's own seeding above, just kept
|
||||
# as separate seeder classes rather than folded into InstallLunarCommand — these
|
||||
# are Spatie translation-loader `validation`/`checkout` groups, not Lunar store
|
||||
# data, and each is safe to re-run on every boot.
|
||||
echo "[entrypoint] Seeding validation translations..."
|
||||
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder" --force --quiet || true
|
||||
|
||||
echo "[entrypoint] Seeding checkout translations..."
|
||||
php artisan db:seed --class="Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder" --force --quiet || true
|
||||
|
||||
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
|
||||
# every boot rather than flushing and rebuilding the whole index each time. Runs
|
||||
# in production too — a deploy that changed an indexer's field list needs this
|
||||
|
||||
Generated
+8
-15
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "boboko-starter",
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,9 +7,8 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"axios": "^1.15.2",
|
||||
"flatpickr": "^4.6.13"
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -147,12 +146,6 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/web": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
@@ -1015,12 +1008,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flatpickr": {
|
||||
"version": "4.6.13",
|
||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -1256,6 +1243,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
|
||||
+7
-1
@@ -14,8 +14,14 @@
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"axios": "^1.15.2"
|
||||
"axios": "^1.15.2",
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
|
||||
"_dependencies": {
|
||||
"@boboko/core": "file:../boboko-core"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ @layer components {
|
||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
||||
}
|
||||
|
||||
.btn-primary:hover::after {
|
||||
.btn-primary:not(:disabled):hover::after {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,856 +0,0 @@
|
||||
/*
|
||||
* 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
-3
@@ -10,13 +10,15 @@ window.Turbo.session.drive = false;
|
||||
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { registerControllers } from "./stimulus/index";
|
||||
import { registerCheckout } from "./checkout";
|
||||
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||
|
||||
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.
|
||||
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||
// these are the only wiring lines they need here.
|
||||
registerCheckout(application);
|
||||
registerWishlist(application);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// 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') || ''
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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)
|
||||
@@ -38,5 +37,4 @@ export function registerControllers(application) {
|
||||
application.register('star-rating', StarRatingController)
|
||||
application.register('tab-link', TabLinkController)
|
||||
application.register('tabs', TabsController)
|
||||
application.register('wishlist', WishlistController)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
||||
import { formatPrice } from '../utils/format-price'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError']
|
||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
|
||||
static values = {
|
||||
variants: Array,
|
||||
selected: Number,
|
||||
@@ -90,7 +90,7 @@ export default class extends Controller {
|
||||
// own server-side check have the final word rather than
|
||||
// silently blocking the shopper here.
|
||||
} finally {
|
||||
if (submit) submit.disabled = false
|
||||
if (submit) submit.disabled = !this.selectedVariant?.inStock
|
||||
}
|
||||
|
||||
form.dataset.bbkStockChecked = 'true'
|
||||
@@ -111,6 +111,10 @@ export default class extends Controller {
|
||||
this.stockErrorTarget.hidden = true
|
||||
}
|
||||
|
||||
get selectedVariant() {
|
||||
return this.variantsValue.find(v => v.id === this.selectedValue)
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const option = event.currentTarget.dataset.option
|
||||
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||
@@ -156,6 +160,9 @@ export default class extends Controller {
|
||||
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||
if (purchasableInput) purchasableInput.value = id
|
||||
|
||||
// Out-of-stock variant (as of page load) can't be added at all.
|
||||
if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@
|
||||
|
||||
<ul>
|
||||
@foreach ($orders as $order)
|
||||
@php
|
||||
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
|
||||
@endphp
|
||||
<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>
|
||||
@@ -36,7 +39,7 @@
|
||||
</span>
|
||||
<span class="text-right font-bold sm:text-left">
|
||||
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
|
||||
#{{ $order->reference }}
|
||||
#{{ $orderReference }}
|
||||
</span>
|
||||
<span>
|
||||
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
|
||||
@@ -49,7 +52,7 @@
|
||||
<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]) }}"
|
||||
aria-label="{{ __('storefront.orders.view_order', ['number' => $orderReference]) }}"
|
||||
>
|
||||
{{ __('storefront.orders.view') }}
|
||||
<x-ui.icon name="arrow-right" :size="16" />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@extends('layouts.account')
|
||||
|
||||
@section('title', __('storefront.orders.order_title', ['number' => $order->reference]))
|
||||
@php
|
||||
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
|
||||
@endphp
|
||||
|
||||
@section('title', __('storefront.orders.order_title', ['number' => $orderReference]))
|
||||
|
||||
@php
|
||||
$productLines = $order->lines->where('type', '!=', 'shipping');
|
||||
@@ -18,7 +22,7 @@
|
||||
</a>
|
||||
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }}
|
||||
{{ __('storefront.orders.order_title', ['number' => $orderReference]) }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
<meta name="robots" content="noindex, follow">
|
||||
@endpush
|
||||
|
||||
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||
@push('scripts')
|
||||
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||
@@ -52,6 +57,18 @@
|
||||
]) !!}
|
||||
</p>
|
||||
|
||||
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds
|
||||
the `h-captcha-response` field itself. Fixed height reserves the
|
||||
iframe's space so the button doesn't jump when it loads. --}}
|
||||
<x-ui.field for="login-captcha" :error="$errors->first('h-captcha-response')">
|
||||
<div
|
||||
id="login-captcha"
|
||||
class="h-captcha min-h-[78px]"
|
||||
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||
@if ($errors->has('h-captcha-response')) aria-describedby="login-captcha-error" @endif
|
||||
></div>
|
||||
</x-ui.field>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{{--
|
||||
<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>
|
||||
@@ -1,15 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -1,29 +0,0 @@
|
||||
{{--
|
||||
<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>
|
||||
@@ -1,52 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -1,62 +0,0 @@
|
||||
{{--
|
||||
<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>
|
||||
@@ -1,18 +0,0 @@
|
||||
@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>
|
||||
@@ -1,132 +0,0 @@
|
||||
{{--
|
||||
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
|
||||
(boboko-core's Modules\Core\Customer\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">× {{ $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>−{{ $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
|
||||
@@ -1,33 +0,0 @@
|
||||
{{--
|
||||
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') }}"
|
||||
>×</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>
|
||||
@@ -1,279 +0,0 @@
|
||||
{{--
|
||||
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
|
||||
@@ -1,121 +0,0 @@
|
||||
{{--
|
||||
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>−{{ $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>
|
||||
@@ -1,9 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -1,103 +0,0 @@
|
||||
{{--
|
||||
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') }}"
|
||||
>−</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') }}"
|
||||
>×</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,49 +0,0 @@
|
||||
{{--
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
|
||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
||||
CartController::customFieldsMeta()). A file answer only carries a File id
|
||||
(Modules\Core\File\Models\File is the source of truth for name/mime/disk/
|
||||
path — never duplicated into meta), resolved here and linked through
|
||||
boboko-core's own signed download route (files.download), 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
|
||||
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
|
||||
@endphp
|
||||
@if ($file)
|
||||
@php
|
||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
||||
'files.download',
|
||||
now()->addHours(2),
|
||||
['file' => $file->id],
|
||||
);
|
||||
@endphp
|
||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
||||
@if (in_array($file->mime, $previewable, true))
|
||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
||||
@endif
|
||||
<span>{{ $file->original_name }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
{{ $field['value'] }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
@@ -1,30 +0,0 @@
|
||||
{{--
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
{{--
|
||||
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
|
||||
@@ -3,10 +3,10 @@
|
||||
])
|
||||
|
||||
<section {{ $attributes }}>
|
||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16 items-center">
|
||||
<h2
|
||||
class="text-h2 leading-tight"
|
||||
class="text-h1 leading-[1.3] max-w-xl text-center"
|
||||
aria-label="Γράψου στο newsletter μας"
|
||||
data-controller="appear"
|
||||
data-appear-visible-class="is-visible"
|
||||
@@ -17,13 +17,13 @@ class="text-h2 leading-tight"
|
||||
μας
|
||||
</h2>
|
||||
|
||||
<p class="max-w-md text-neutral-600">
|
||||
<p class="max-w-xl text-neutral-600 text-center">
|
||||
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
|
||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-xl items-center">
|
||||
@csrf
|
||||
<div class="relative">
|
||||
<div class="relative max-w-xs w-full">
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
@@ -44,7 +44,7 @@ class="absolute right-0 bottom-2 cursor-pointer"
|
||||
</div>
|
||||
|
||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
<span class="text-sm">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
</x-ui.checkbox>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
inside the add-to-cart <form> so their values travel with it as
|
||||
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
||||
|
||||
The label is styled like the product option labels (option-buttons /
|
||||
color-swatch); the optional help_text shows under it. Help text is
|
||||
product-page only; the cart/checkout line meta only snapshots the label.
|
||||
|
||||
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
||||
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
||||
Models\File) is submitted with the form — the file input itself has no
|
||||
@@ -22,18 +26,25 @@
|
||||
$id = 'custom-field-'.$field['key'];
|
||||
$name = 'custom_fields['.$field['key'].']';
|
||||
$required = (bool) ($field['required'] ?? false);
|
||||
$hint = filled($field['help_text'] ?? null) ? $field['help_text'] : null;
|
||||
// mb-1 on top of the field's gap-2 matches the option labels' mb-3;
|
||||
// with a hint, the label + hint group carries that spacing instead.
|
||||
$labelClass = 'font-bold text-sm uppercase tracking-wide'.($hint ? '' : ' mb-1');
|
||||
$hintId = $hint ? $id.'-hint' : null;
|
||||
@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 :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
|
||||
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" :aria-describedby="$hintId" />
|
||||
</x-ui.field>
|
||||
@break
|
||||
|
||||
@case('file')
|
||||
<x-ui.field
|
||||
:label="$field['label']"
|
||||
:label-class="$labelClass"
|
||||
:hint="$hint"
|
||||
:for="$id"
|
||||
:required="$required"
|
||||
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
||||
@@ -49,7 +60,7 @@
|
||||
:id="$id"
|
||||
:accept="CustomFieldUploadController::accept()"
|
||||
:required="$required"
|
||||
aria-describedby="{{ $id }}-description {{ $id }}-error"
|
||||
aria-describedby="{{ $hintId ? $hintId.' ' : '' }}{{ $id }}-description {{ $id }}-error"
|
||||
data-custom-field-upload-target="file"
|
||||
data-action="change->custom-field-upload#upload"
|
||||
/>
|
||||
@@ -69,8 +80,8 @@ class="w-24 h-24 object-cover border border-black"
|
||||
@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 :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
|
||||
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" :aria-describedby="$hintId" />
|
||||
</x-ui.field>
|
||||
@endswitch
|
||||
@endforeach
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
:href="$product['href'] ?? '#'"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
:sold-out="$product['soldOut'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -33,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 = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline '.$variantClasses.' '.$sizeClasses);
|
||||
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline disabled:cursor-not-allowed disabled:opacity-50 '.$variantClasses.' '.$sizeClasses);
|
||||
|
||||
$attrs = $href
|
||||
? $attributes->merge(['href' => $href, 'class' => $class])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@props([
|
||||
'label' => null,
|
||||
'labelDescription' => null,
|
||||
'labelClass' => null,
|
||||
'hint' => null,
|
||||
'for' => null,
|
||||
'description' => null,
|
||||
'error' => null,
|
||||
@@ -10,9 +12,23 @@
|
||||
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
|
||||
|
||||
@if ($label)
|
||||
{{-- With a hint, label + hint are grouped so the hint sits tight
|
||||
under the label, with the usual gap before the input. The input
|
||||
should reference {for}-hint in its aria-describedby. --}}
|
||||
@if ($hint) <div class="flex flex-col gap-1"> @endif
|
||||
|
||||
<label
|
||||
@if ($for) for="{{ $for }}" @endif
|
||||
@if ($labelClass) class="{{ $labelClass }}" @endif
|
||||
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
|
||||
|
||||
@if ($hint)
|
||||
<p
|
||||
@if ($for) id="{{ $for }}-hint" @endif
|
||||
class="text-sm text-neutral-500"
|
||||
>{{ $hint }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
'href' => '#',
|
||||
'variantId' => null,
|
||||
'hasCustomFields' => false,
|
||||
// Shows the "sold" star and drops the quick add-to-cart/personalize button.
|
||||
'soldOut' => false,
|
||||
// Heart toggle at the top right of the image; needs productId.
|
||||
'productId' => null,
|
||||
'wishlist' => false,
|
||||
@@ -36,7 +38,9 @@ class="w-full h-auto block"
|
||||
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
|
||||
@endif
|
||||
|
||||
@if ($hasCustomFields)
|
||||
@if ($soldOut)
|
||||
<x-ui.sold-badge class="absolute top-2 right-2 {{ $wishlist && $productId ? 'mt-12' : '' }}" />
|
||||
@elseif ($hasCustomFields)
|
||||
{{-- Custom fields have to be filled in on the product page. --}}
|
||||
<x-ui.button
|
||||
:href="$href"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{-- Black "sold" star for out-of-stock products. Position it from the caller
|
||||
(e.g. class="absolute top-2 right-2"). --}}
|
||||
<div {{ $attributes->class('z-10 flex h-20 w-22 items-center justify-center text-black') }}>
|
||||
<svg viewBox="0 0 87 80" class="absolute inset-0 size-full" fill="currentColor" aria-hidden="true">
|
||||
<path d="M56.942,0l2.607,18.642,19.143-3.363L69.468,31.842,87,40,69.468,48.158l9.224,16.563L59.549,61.358,56.942,80,43.5,66.4,30.058,80,27.451,61.358,8.308,64.721l9.224-16.563L0,40l17.532-8.158L8.308,15.279l19.143,3.363L30.058,0,43.5,13.6Z"/>
|
||||
</svg>
|
||||
<span class="relative text-base italic font-black text-white uppercase">{{ __('storefront.product.sold') }}</span>
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@
|
||||
@props(['productId'])
|
||||
|
||||
@php
|
||||
$active = app(\App\Services\Wishlist::class)->has((int) $productId);
|
||||
$active = app(\Modules\Core\Wishlist\Services\WishlistService::class)->has((int) $productId);
|
||||
$addLabel = __('storefront.wishlist.add');
|
||||
$removeLabel = __('storefront.wishlist.remove');
|
||||
@endphp
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@section('title', __('storefront.nav.contact'))
|
||||
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
|
||||
|
||||
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||
@push('scripts')
|
||||
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
|
||||
@@ -30,25 +35,35 @@
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<x-ui.field label="Το όνομά σου" for="contact-name" :error="$errors->first('name')">
|
||||
<x-ui.field label="Το όνομά σου *" for="contact-name" :error="$errors->first('name')">
|
||||
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
|
||||
:aria-invalid="$errors->has('name') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field label="Το email σου" for="contact-email" :error="$errors->first('email')">
|
||||
<x-ui.field label="Το email σου *" for="contact-email" :error="$errors->first('email')">
|
||||
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
|
||||
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
|
||||
</x-ui.field>
|
||||
</div>
|
||||
|
||||
<x-ui.field label="Το μήνυμά σου" for="contact-message" :error="$errors->first('message')">
|
||||
<x-ui.field label="Το μήνυμά σου *" for="contact-message" :error="$errors->first('message')">
|
||||
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
|
||||
:aria-invalid="$errors->has('message') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
|
||||
</x-ui.field>
|
||||
|
||||
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha (see auth/login). --}}
|
||||
<x-ui.field for="contact-captcha" :error="$errors->first('h-captcha-response')">
|
||||
<div
|
||||
id="contact-captcha"
|
||||
class="h-captcha min-h-[78px]"
|
||||
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||
@if ($errors->has('h-captcha-response')) aria-describedby="contact-captcha-error" @endif
|
||||
></div>
|
||||
</x-ui.field>
|
||||
|
||||
@if (session('contact_error'))
|
||||
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
||||
@endif
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
@media only screen and (max-width: 480px) {
|
||||
.email-container { width: 100% !important; max-width: 100% !important; }
|
||||
.email-header { padding: 24px 20px 12px !important; }
|
||||
.email-gutter { padding: 0 !important; }
|
||||
.email-card { padding: 28px 20px !important; }
|
||||
.email-footer { padding: 0 20px 32px !important; }
|
||||
}
|
||||
@@ -69,7 +70,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 24px;">
|
||||
<td class="email-gutter" style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="background-color:#ffffff;border-radius:0;">
|
||||
<tr>
|
||||
<td class="email-card" style="padding:40px 32px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#000000;text-align:left;">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{--
|
||||
@include('emails.partials.address', ['address' => $order->shippingAddress])
|
||||
|
||||
An order address, read-only, same fields as the account order page.
|
||||
--}}
|
||||
<p style="margin:0;font-size:15px;line-height:1.6;">
|
||||
{{ trim($address->first_name.' '.$address->last_name) }}<br>
|
||||
@if ($address->company_name){{ $address->company_name }}<br>@endif
|
||||
@if ($address->tax_identifier)ΑΦΜ: {{ $address->tax_identifier }}<br>@endif
|
||||
{{ $address->line_one }}<br>
|
||||
@if ($address->line_two){{ $address->line_two }}<br>@endif
|
||||
{{ trim($address->postcode.' '.$address->city) }}<br>
|
||||
@if ($address->state){{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}<br>@endif
|
||||
@if ($address->contact_phone){{ $address->contact_phone }}@endif
|
||||
</p>
|
||||
@@ -0,0 +1,43 @@
|
||||
{{--
|
||||
@include('emails.partials.button', ['url' => ..., 'label' => ...])
|
||||
|
||||
The site's primary button (x-ui.button, .btn-primary): neutral-200 fill,
|
||||
black border, bold italic uppercase, black offset shadow. The shadow is
|
||||
built from table cells, not box-shadow (Gmail and Outlook drop it): a
|
||||
black column on the right starting $offset down, and a black row at the
|
||||
bottom starting $offset in. The button cell spans the first two rows, so
|
||||
the black column always matches its height.
|
||||
|
||||
Uppercased here with the tonos removed, since emails can't run the site's
|
||||
strip-accents script and CSS uppercase would keep the accents.
|
||||
--}}
|
||||
@php
|
||||
$offset = 8;
|
||||
$label = strtr(mb_strtoupper($label), [
|
||||
'Ά' => 'Α', 'Έ' => 'Ε', 'Ή' => 'Η', 'Ί' => 'Ι', 'Ό' => 'Ο', 'Ύ' => 'Υ', 'Ώ' => 'Ω',
|
||||
'ΐ' => 'Ϊ', 'ΰ' => 'Ϋ',
|
||||
]);
|
||||
$spacer = "font-size:1px;line-height:1px;mso-line-height-rule:exactly;";
|
||||
@endphp
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0;border-collapse:separate;">
|
||||
<tr>
|
||||
<td rowspan="2" bgcolor="#e5e5e5" style="background-color:#e5e5e5;border:1px solid #000000;border-radius:0;mso-padding-alt:14px 28px;">
|
||||
<a href="{{ $url }}" target="_blank" style="display:inline-block;padding:14px 28px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:20px;mso-line-height-rule:exactly;font-weight:700;font-style:italic;color:#000000;text-decoration:none;">{{ $label }}</a>
|
||||
</td>
|
||||
<td width="{{ $offset }}" height="{{ $offset }}" style="width:{{ $offset }}px;height:{{ $offset }}px;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="{{ $offset }}" style="height:{{ $offset }}px;padding:0 0 0 {{ $offset }}px;{{ $spacer }}">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td height="{{ $offset }}" bgcolor="#000000" style="height:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td width="{{ $offset }}" height="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;height:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,2 @@
|
||||
{{-- @include('emails.partials.section-heading', ['text' => ...]) --}}
|
||||
<h2 style="margin:0 0 12px;padding:0 0 8px;border-bottom:1px solid #000000;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:18px;line-height:1.3;font-weight:800;color:#000000;">{{ $text }}</h2>
|
||||
@@ -43,7 +43,7 @@ class="absolute left-4 lg:left-8 top-1/2 z-10 -translate-y-1/2 hover:-translate-
|
||||
class="{{ $loop->first ? '' : 'hidden' }} group h-full flex flex-col justify-center pb-8 relative"
|
||||
>
|
||||
<div class="relative flex items-center justify-center">
|
||||
<a href="{{ $product['href'] }}" class="block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
|
||||
<a href="{{ $product['href'] }}" class="relative block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
|
||||
@if($product['image'])
|
||||
<img
|
||||
src="{{ $product['image'] }}"
|
||||
@@ -58,9 +58,16 @@ class="w-full h-full object-cover"
|
||||
@else
|
||||
<span class="text-neutral-500 text-sm">{{ __('storefront.product.no_image') }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Inside the link so it sits on the image's corner, not the full-width slide's. --}}
|
||||
@if ($product['soldOut'] ?? false)
|
||||
<x-ui.sold-badge class="absolute top-2 right-2" />
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@if ($product['hasCustomFields'] ?? false)
|
||||
@if ($product['soldOut'] ?? false)
|
||||
{{-- Sold: no quick add-to-cart/personalize, same as x-ui.product-card. --}}
|
||||
@elseif ($product['hasCustomFields'] ?? false)
|
||||
<x-ui.button
|
||||
:href="$product['href']"
|
||||
size="md"
|
||||
@@ -173,6 +180,7 @@ class="max-w-xs"
|
||||
:href="$product['href']"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
:sold-out="$product['soldOut'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -187,7 +195,5 @@ class="max-w-xs"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-20">
|
||||
<x-newsletter-split />
|
||||
</div>
|
||||
<x-newsletter-split />
|
||||
@endsection
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
|
||||
|
||||
@vite(['resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
|
||||
@vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
@auth('staff')
|
||||
@php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email))
|
||||
|
||||
@@ -196,8 +196,13 @@ class="underline-slide font-semibold whitespace-nowrap"
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@php
|
||||
// Same initial pick as product-form-controller.js's connect()
|
||||
// (?variant= or the first), so the button doesn't flip on load.
|
||||
$initialVariant = collect($variantsData)->firstWhere('id', (int) request('variant')) ?? ($variantsData[0] ?? null);
|
||||
@endphp
|
||||
<x-checkout::add-to-cart
|
||||
:purchasable="$variantsData[0]['id'] ?? null"
|
||||
:purchasable="$initialVariant['id'] ?? null"
|
||||
:quantity="false"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
@@ -207,7 +212,7 @@ class="flex flex-col gap-6"
|
||||
|
||||
<div class="flex items-stretch gap-10">
|
||||
<x-ui.quantity name="quantity" />
|
||||
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||
<x-ui.button type="submit" class="flex-1" data-product-form-target="submit" :disabled="! ($initialVariant['inStock'] ?? true)">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||
</div>
|
||||
</x-checkout::add-to-cart>
|
||||
|
||||
@@ -270,7 +275,7 @@ class="flex flex-col gap-6"
|
||||
@if(!empty($product['recommendations']))
|
||||
<x-product-grid
|
||||
class="mt-20"
|
||||
title="Σχετικά προϊόντα"
|
||||
:title="__('storefront.product.related')"
|
||||
:products="collect($product['recommendations'])->map(fn (array $recommendation) => [
|
||||
'name' => $recommendation['name'],
|
||||
'price' => $recommendation['price'],
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
|
||||
@section('title', "Λάβαμε την πληρωμή σου")
|
||||
|
||||
@section('preheader', "Λάβαμε την πληρωμή {$amount} για την παραγγελία #{$reference}.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Λάβαμε την πληρωμή σου
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Λάβαμε την πληρωμή σου <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
|
||||
@section('title', "Η παραγγελία σου ολοκληρώθηκε")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} ολοκληρώθηκε.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου ολοκληρώθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> ολοκληρώθηκε. Ευχαριστούμε που ψώνισες από εμάς!</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
|
||||
@section('title', "Η παραγγελία σου παραδόθηκε")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} παραδόθηκε.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου παραδόθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> παραδόθηκε. Ελπίζουμε να σου αρέσει!</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
|
||||
@section('title', "Η παραγγελία σου στάλθηκε")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} είναι καθ’ οδόν.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου στάλθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> είναι καθ’ οδόν. Θα τη λάβεις σύντομα.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
|
||||
@section('title', "Η παραγγελία σου είναι έτοιμη για παραλαβή")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} σε περιμένει στο κατάστημα.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου είναι έτοιμη για παραλαβή
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> είναι έτοιμη και σε περιμένει στο κατάστημα.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
+172
-16
@@ -1,19 +1,175 @@
|
||||
<p>Hi,</p>
|
||||
{{--
|
||||
Order confirmation (boboko-core's OrderPlacedNotification).
|
||||
|
||||
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
|
||||
Core only passes $reference, $total and $lines, so the order itself is
|
||||
reached through a line (every OrderLine belongs to it). Interim until core
|
||||
passes the Order to this view.
|
||||
|
||||
<ul>
|
||||
@foreach ($lines as $line)
|
||||
<li>
|
||||
{{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }}
|
||||
{{-- Text answers only — photo answers are deliberately left out of emails. --}}
|
||||
@foreach ($line->meta['custom_fields'] ?? [] as $field)
|
||||
@if ($field['type'] !== 'file')
|
||||
<br>{{ $field['label'] }}: {!! nl2br(e($field['value'])) !!}
|
||||
@endif
|
||||
@endforeach
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
Sent from the queue with no locale of its own, so it always renders in the
|
||||
app default (Greek). Copy is Greek on purpose, like the other core emails.
|
||||
--}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Total: <strong>{{ $total }}</strong></p>
|
||||
@php
|
||||
$order = $lines->first()->order;
|
||||
$productLines = $lines->where('type', '!=', 'shipping');
|
||||
$shippingLine = $lines->firstWhere('type', 'shipping');
|
||||
|
||||
// By type, like the account order page: the method may since have been
|
||||
// disabled, but the order still shows what was used.
|
||||
$paymentMethodName = \Modules\Core\Payment\Models\PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
// Lunar doesn't copy the coupon onto the order; the cart row keeps it.
|
||||
$couponCode = $order->cart?->coupon_code;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
$comments = $order->shippingAddress?->delivery_instructions;
|
||||
|
||||
$muted = 'color:#5c5c5c;font-size:14px;';
|
||||
$font = "font-family:'Manrope',Arial,Helvetica,sans-serif;";
|
||||
@endphp
|
||||
|
||||
@section('title', "Η παραγγελία σου #{$reference} επιβεβαιώθηκε")
|
||||
|
||||
@section('preheader', "Ευχαριστούμε! Λάβαμε την παραγγελία σου #{$reference}, σύνολο {$total}.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;{{ $font }}font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Ευχαριστούμε για την παραγγελία σου!
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 24px;">
|
||||
Λάβαμε την παραγγελία σου <strong>#{{ $reference }}</strong>
|
||||
στις {{ $order->placed_at?->format('d/m/Y') }}.
|
||||
Θα σε ενημερώσουμε με email μόλις σταλεί.
|
||||
</p>
|
||||
|
||||
<div style="margin:0 0 40px;">
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders.show', ['locale' => 'el', 'orderId' => $order->id]),
|
||||
'label' => 'Δες την παραγγελία σου',
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Items --}}
|
||||
@include('emails.partials.section-heading', ['text' => 'Προϊόντα'])
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 8px;">
|
||||
@foreach ($productLines as $line)
|
||||
<tr>
|
||||
<td width="64" valign="top" style="width:64px;padding:12px 12px 12px 0;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
|
||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||
<img src="{{ $thumb }}" width="64" height="64" alt="{{ $line->description }}" style="display:block;width:64px;height:64px;object-fit:cover;background-color:#e5e5e5;border:0;{{ $font }}font-size:11px;color:#5c5c5c;">
|
||||
@else
|
||||
<div style="width:64px;height:64px;background-color:#e5e5e5;"></div>
|
||||
@endif
|
||||
</td>
|
||||
<td valign="top" style="padding:12px 12px 12px 0;{{ $font }}font-size:15px;line-height:1.4;color:#000000;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
|
||||
<strong>{{ $line->description }}</strong>
|
||||
|
||||
@if ($line->option)
|
||||
<br><span style="{{ $muted }}">{{ $line->option }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Text answers only: photo answers are deliberately left out of emails. --}}
|
||||
@foreach ($line->meta['custom_fields'] ?? [] as $field)
|
||||
@if ($field['type'] !== 'file' && filled($field['value'] ?? null))
|
||||
<br><span style="{{ $muted }}">{{ $field['label'] }}: {!! nl2br(e($field['value'])) !!}</span>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<br><span style="{{ $muted }}">
|
||||
Ποσότητα: {{ $line->quantity }}@if ($line->quantity > 1) × {{ $line->unit_price->formatted() }}@endif
|
||||
</span>
|
||||
</td>
|
||||
<td valign="top" align="right" style="padding:12px 0;white-space:nowrap;{{ $font }}font-size:15px;line-height:1.4;font-weight:700;color:#000000;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
|
||||
{{ $line->sub_total?->formatted() }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
{{-- Totals --}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 40px;border-top:1px solid #000000;">
|
||||
<tr>
|
||||
<td style="padding:12px 0 4px;{{ $font }}font-size:15px;color:#000000;">Υποσύνολο</td>
|
||||
<td align="right" style="padding:12px 0 4px;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">{{ $order->sub_total?->formatted() }}</td>
|
||||
</tr>
|
||||
|
||||
@if ($order->discount_total?->value > 0)
|
||||
<tr>
|
||||
<td style="padding:4px 0;{{ $font }}font-size:15px;color:#000000;">
|
||||
Έκπτωση@if ($couponCode) <span style="{{ $muted }}">({{ $couponCode }})</span>@endif
|
||||
</td>
|
||||
<td align="right" style="padding:4px 0;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">−{{ $order->discount_total->formatted() }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
|
||||
<tr>
|
||||
<td style="padding:4px 0;{{ $font }}font-size:15px;color:#000000;">
|
||||
Μεταφορικά@if ($shippingLine) <span style="{{ $muted }}">({{ $shippingLine->description }})</span>@endif
|
||||
</td>
|
||||
<td align="right" style="padding:4px 0;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">
|
||||
{{ $order->shipping_total?->value > 0 ? $order->shipping_total->formatted() : 'Δωρεάν' }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:12px 0 0;border-top:1px solid #000000;{{ $font }}font-size:20px;line-height:1.3;font-weight:800;color:#000000;">Σύνολο</td>
|
||||
<td align="right" style="padding:12px 0 0;border-top:1px solid #000000;white-space:nowrap;{{ $font }}font-size:20px;line-height:1.3;font-weight:800;color:#000000;">{{ $total }}</td>
|
||||
</tr>
|
||||
|
||||
{{-- Prices include VAT (lunar.pricing.stored_inclusive_of_tax), so this is informational. --}}
|
||||
@if ($order->tax_total?->value > 0)
|
||||
<tr>
|
||||
<td colspan="2" align="right" style="padding:4px 0 0;{{ $font }}{{ $muted }}">Περιλαμβάνεται Φ.Π.Α. {{ $order->tax_total->formatted() }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
|
||||
{{-- Payment + shipping --}}
|
||||
@include('emails.partials.section-heading', ['text' => 'Πληρωμή και αποστολή'])
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 40px;">
|
||||
@foreach (array_filter([
|
||||
'Τρόπος πληρωμής' => $paymentMethodName,
|
||||
'Τρόπος αποστολής' => $shippingLine?->description,
|
||||
'Παραστατικό' => ($order->meta['wants_invoice'] ?? false) ? 'Τιμολόγιο' : 'Απόδειξη',
|
||||
'Email' => $email,
|
||||
]) as $label => $value)
|
||||
<tr>
|
||||
<td valign="top" style="padding:4px 12px 4px 0;{{ $font }}{{ $muted }}line-height:1.5;">{{ $label }}</td>
|
||||
<td valign="top" align="right" style="padding:4px 0;{{ $font }}font-size:15px;line-height:1.5;color:#000000;word-break:break-word;">{{ $value }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
{{-- Addresses: stacked, never side by side, so they read well on a phone. --}}
|
||||
@if ($order->shippingAddress)
|
||||
@include('emails.partials.section-heading', ['text' => 'Διεύθυνση αποστολής'])
|
||||
<div style="margin:0 0 {{ $comments ? '16px' : '40px' }};">
|
||||
@include('emails.partials.address', ['address' => $order->shippingAddress])
|
||||
</div>
|
||||
|
||||
@if ($comments)
|
||||
<p style="margin:0 0 40px;font-size:15px;line-height:1.6;">
|
||||
<span style="{{ $muted }}">Σχόλια</span><br>
|
||||
{!! nl2br(e($comments)) !!}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($order->billingAddress)
|
||||
@include('emails.partials.section-heading', ['text' => 'Στοιχεία χρέωσης'])
|
||||
@include('emails.partials.address', ['address' => $order->billingAddress])
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
|
||||
@section('title', "Έγινε επιστροφή χρημάτων")
|
||||
|
||||
@section('preheader', "Επιστρέψαμε {$amount} για την παραγγελία #{$reference}.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Έγινε επιστροφή χρημάτων
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Σου επιστρέψαμε <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
|
||||
@php
|
||||
// Core passes Lunar's English label; map it back to the status key so the
|
||||
// shared <x-order-status> can show the translated one. Interim, until core
|
||||
// passes the order (or its status) to this view.
|
||||
$status = collect(config('lunar.orders.statuses'))
|
||||
->search(fn (array $config) => $config['label'] === $statusLabel) ?: $statusLabel;
|
||||
@endphp
|
||||
|
||||
@section('title', "Ενημέρωση παραγγελίας")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} ενημερώθηκε.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου ενημερώθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η κατάσταση της παραγγελίας σου <strong>#{{ $reference }}</strong> είναι τώρα: <strong><x-order-status :status="$status" /></strong></p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
:href="$product['href']"
|
||||
:variant-id="$product['variantId']"
|
||||
:has-custom-fields="$product['hasCustomFields']"
|
||||
:sold-out="$product['soldOut']"
|
||||
:product-id="$product['id']"
|
||||
:wishlist="true"
|
||||
/>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Checkout\CartController;
|
||||
use App\Http\Controllers\Checkout\CheckoutController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
* Cart + checkout module routes.
|
||||
*
|
||||
* The {locale} prefix and `locale` middleware mirror the 3dealer host's
|
||||
* localization convention (CLAUDE.md » Localization). When this module moves to
|
||||
* boboko-core the route definitions travel with it, but each host wraps them in
|
||||
* whatever prefix/middleware it uses. Every action still declares $locale as its
|
||||
* first parameter regardless, per the ControllerDispatcher positional-args
|
||||
* gotcha documented in CLAUDE.md.
|
||||
*
|
||||
* Loaded from CheckoutModuleServiceProvider inside the `web` middleware group.
|
||||
*/
|
||||
Route::prefix('{locale}')
|
||||
->middleware('locale')
|
||||
->group(function () {
|
||||
// No standalone cart page — the drawer (checkout::drawer) is the cart.
|
||||
Route::get('checkout', [CheckoutController::class, 'show'])
|
||||
->name('checkout.show');
|
||||
|
||||
Route::post('checkout/address', [CheckoutController::class, 'saveAddress'])
|
||||
->name('checkout.address.save');
|
||||
|
||||
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
|
||||
->name('checkout.shipping-option.select');
|
||||
|
||||
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
|
||||
->name('checkout.payment-method.select');
|
||||
|
||||
Route::post('checkout/place-order', [CheckoutController::class, 'placeOrder'])
|
||||
->name('checkout.place-order');
|
||||
|
||||
Route::get('checkout/order-status', [CheckoutController::class, 'orderStatus'])
|
||||
->name('checkout.order-status');
|
||||
|
||||
Route::get('checkout/confirmation', [CheckoutController::class, 'confirmation'])
|
||||
->name('checkout.confirmation');
|
||||
|
||||
Route::post('cart/lines', [CartController::class, 'add'])
|
||||
->name('checkout.cart.add');
|
||||
|
||||
Route::patch('cart/lines/{line}', [CartController::class, 'updateLine'])
|
||||
->whereNumber('line')
|
||||
->name('checkout.cart.update');
|
||||
|
||||
Route::delete('cart/lines/{line}', [CartController::class, 'remove'])
|
||||
->whereNumber('line')
|
||||
->name('checkout.cart.remove');
|
||||
|
||||
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
|
||||
->name('checkout.cart.coupon.apply');
|
||||
|
||||
Route::delete('cart/coupon', [CartController::class, 'removeCoupon'])
|
||||
->name('checkout.cart.coupon.remove');
|
||||
});
|
||||
+3
-5
@@ -98,12 +98,10 @@
|
||||
->name('logout');
|
||||
|
||||
// Heart button: works for guests too (cookie), merged into the account
|
||||
// on login — see App\Services\Wishlist.
|
||||
// on login — see Modules\Core\Wishlist\Services\WishlistService. The
|
||||
// toggle action itself (route `wishlist.toggle`) is registered by
|
||||
// core's own WishlistServiceProvider, not here.
|
||||
Route::get('/wishlist', [WishlistController::class, 'guest'])->name('wishlist');
|
||||
Route::post('/wishlist/{productId}', [WishlistController::class, 'toggle'])
|
||||
->whereNumber('productId')
|
||||
->middleware('throttle:60,1')
|
||||
->name('wishlist.toggle');
|
||||
|
||||
Route::middleware('auth')->prefix('/account')->group(function () {
|
||||
Route::get('/', [AccountController::class, 'show'])->name('account');
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import laravel from "laravel-vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { boboko } from "@boboko/core/vite-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -14,13 +15,18 @@ export default defineConfig({
|
||||
// checkout.css first: it's the portable cart/checkout module's
|
||||
// structural base (@layer bbk-checkout) and must load before
|
||||
// app.css so the host's own .bbk-* theming always wins.
|
||||
"resources/css/checkout.css",
|
||||
// Sourced from the @boboko/core npm package (a local
|
||||
// `file:../boboko-core` dependency — see package.json and
|
||||
// boboko()'s own comments in @boboko/core/vite-plugin for why
|
||||
// this needs no further config here).
|
||||
"node_modules/@boboko/core/resources/css/checkout.css",
|
||||
"resources/css/app.css",
|
||||
"resources/js/app.js",
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
boboko(),
|
||||
],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
|
||||
Reference in New Issue
Block a user