Feat: Removing Cart and Checkout from core

This commit is contained in:
2026-09-25 20:22:38 +03:00
parent e2d7bbb043
commit 081893ef57
26 changed files with 288 additions and 2257 deletions
@@ -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();
}
}
@@ -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());
},
);
}
}
-2
View File
@@ -1,11 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\CheckoutModuleServiceProvider;
use App\Providers\PanelServiceProvider; use App\Providers\PanelServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
PanelServiceProvider::class, PanelServiceProvider::class,
CheckoutModuleServiceProvider::class,
]; ];
+25
View File
@@ -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
View File
@@ -2,17 +2,43 @@
/* /*
* Per-site settings for the cart + checkout module (see * Per-site settings for the cart + checkout module (see
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's * Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
* publishable config when the module moves to boboko-core. * artisan vendor:publish --tag=core-config.
*/ */
return [ return [
/* /*
* Name of the storefront's login route. The checkout's login tab and the * 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 * confirmation page link to it with `?redirect=<checkout path>`, so the
* login page must send the shopper back there afterwards (3dealer's * login page must send the shopper back there afterwards. null: no login
* Auth\LoginController does). null: no login offered in checkout at all. * offered in checkout at all.
*/ */
'login_route' => 'login', '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
View File
@@ -16,4 +16,126 @@
'auto_create_customer_for_user' => true, '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,
],
],
]; ];
+50
View File
@@ -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),
];
+61
View File
@@ -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,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,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">&times; {{ $line->quantity }}</span>
</span>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
@endif
</div>
</div>
</div>
@endsection
-33
View File
@@ -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') }}"
>&times;</button>
</header>
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</aside>
</div>
-279
View File
@@ -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>&minus;{{ $cart->discountTotal->formatted() }}</span>
</div>
@endif
{{-- Shipping + tax appear once the shopper has a shipping address
(i.e. they're on the checkout page). In the drawer, where no
address is set yet, only subtotal + total show. --}}
@if ($cart?->shippingAddress)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
@if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div>
@endif
@if ($cart?->taxTotal?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span>
</div>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</a>
</div>
@endif
</div>
@@ -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') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
</form>
</div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div>
</li>
@@ -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
-60
View File
@@ -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');
});