generated from boboko/starter
Feat: Removing Cart and Checkout from core
This commit is contained in:
@@ -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());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||||
|
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Product recommendation rules
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||||
|
| the first rule that returns at least one product wins. The order here IS
|
||||||
|
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||||
|
| last-resort so a product page is never left with zero recommendations
|
||||||
|
| (as long as the store has more than one product). A consuming app can
|
||||||
|
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||||
|
| hardcoded in the service itself.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'recommendation_rules' => [
|
||||||
|
SameCategoryRule::class,
|
||||||
|
RandomRule::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
+30
-4
@@ -2,17 +2,43 @@
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Per-site settings for the cart + checkout module (see
|
* 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
@@ -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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ACS Courier credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||||
|
| HTTP header (gates the REST gateway itself) and four account fields
|
||||||
|
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||||
|
| request body. Both are supplied by ACS when your account is set up.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||||
|
| every ACSAlias call).
|
||||||
|
| ACS_API_KEY The AcsApiKey header value.
|
||||||
|
| ACS_COMPANY_ID Company_ID body field.
|
||||||
|
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||||
|
| ACS_USER_ID User_ID body field.
|
||||||
|
| ACS_USER_PASSWORD User_Password body field.
|
||||||
|
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||||
|
| calculation and voucher creation.
|
||||||
|
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||||
|
|
||||||
|
'api_key' => env('ACS_API_KEY'),
|
||||||
|
|
||||||
|
'company_id' => env('ACS_COMPANY_ID'),
|
||||||
|
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||||
|
'user_id' => env('ACS_USER_ID'),
|
||||||
|
'user_password' => env('ACS_USER_PASSWORD'),
|
||||||
|
|
||||||
|
'billing_code' => env('ACS_BILLING_CODE'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('ACS_SENDER_NAME'),
|
||||||
|
'address' => env('ACS_SENDER_ADDRESS'),
|
||||||
|
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||||
|
'phone' => env('ACS_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Box Now credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||||
|
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||||
|
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||||
|
| Unlike ACS, there is no separate per-request credential body — the
|
||||||
|
| token alone authorizes all calls once obtained.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| Box Now has two environments (see their Partner API manual, section 2):
|
||||||
|
| Stage/Sandbox for testing, Production once live. Each has its own
|
||||||
|
| client_id/client_secret pair and its own base_url/location_api_url —
|
||||||
|
| there is no shared "switch an env var" flag, since stage credentials
|
||||||
|
| don't work against the production host or vice versa.
|
||||||
|
|
|
||||||
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||||
|
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||||
|
| lookups (Box Now recommends this over the main
|
||||||
|
| base URL for those two calls specifically).
|
||||||
|
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||||
|
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||||
|
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
|
||||||
|
| credentials. NOT used for REST API authentication
|
||||||
|
| (BoxNowClient authenticates with client_id/
|
||||||
|
| client_secret alone) — this is only consumed by
|
||||||
|
| the client-side Destination Map widget config
|
||||||
|
| (_bn_map_widget_config.partnerId), confirmed
|
||||||
|
| against Box Now's own WooCommerce plugin source.
|
||||||
|
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||||
|
| the pickup origin on every delivery request.
|
||||||
|
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||||
|
| delivery request.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||||
|
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||||
|
|
||||||
|
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||||
|
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||||
|
'partner_id' => env('BOXNOW_PARTNER_ID'),
|
||||||
|
|
||||||
|
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('BOXNOW_SENDER_NAME'),
|
||||||
|
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||||
|
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -1,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">× {{ $line->quantity }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if ($line->option)
|
|
||||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary">
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
|
||||||
<span>{{ $order->sub_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($order->discount_total?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
|
||||||
<span>{{ __('checkout.cart.discount') }}</span>
|
|
||||||
<span>−{{ $order->discount_total->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
|
||||||
<span>{{ $order->shipping_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($order->tax_total?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.tax') }}</span>
|
|
||||||
<span>{{ $order->tax_total->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
|
||||||
<span>{{ __('checkout.cart.total') }}</span>
|
|
||||||
<span>{{ $order->total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-confirmation-addresses">
|
|
||||||
@if ($order->shippingAddress)
|
|
||||||
<div class="bbk-confirmation-address">
|
|
||||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
|
|
||||||
<x-checkout::address-lines :address="$order->shippingAddress" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($order->billingAddress)
|
|
||||||
<div class="bbk-confirmation-address">
|
|
||||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
|
|
||||||
<x-checkout::address-lines :address="$order->billingAddress" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{--
|
|
||||||
Slide-in cart drawer. Rendered once, globally, from the app layout
|
|
||||||
(@include('checkout::drawer')). Structure only — all styling lives in
|
|
||||||
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
|
|
||||||
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
|
|
||||||
--}}
|
|
||||||
<div class="bbk-cart" data-controller="bbk-cart" hidden>
|
|
||||||
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
class="bbk-cart-panel"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="bbk-cart-heading"
|
|
||||||
data-bbk-cart-target="panel"
|
|
||||||
>
|
|
||||||
<header class="bbk-cart-panel-header">
|
|
||||||
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-cart-dismiss"
|
|
||||||
data-action="bbk-cart#close"
|
|
||||||
aria-label="{{ __('checkout.cart.close') }}"
|
|
||||||
>×</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@include('checkout::partials.cart-error')
|
|
||||||
|
|
||||||
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
|
|
||||||
@include('checkout::partials.cart-body')
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
{{--
|
|
||||||
The checkout page. Two columns: left is contact + billing + shipping +
|
|
||||||
shipping method, right is the order summary (the same cart-body partial the
|
|
||||||
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
|
|
||||||
checkout.css). Stops short of payment for this slice — see
|
|
||||||
CheckoutController's class docblock.
|
|
||||||
|
|
||||||
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
|
|
||||||
$countries come from CheckoutController::show().
|
|
||||||
--}}
|
|
||||||
@extends('layouts.app')
|
|
||||||
|
|
||||||
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="bbk-checkout-page">
|
|
||||||
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
|
|
||||||
|
|
||||||
<div class="bbk-checkout">
|
|
||||||
<div
|
|
||||||
class="bbk-checkout-main"
|
|
||||||
data-controller="bbk-checkout-form bbk-payment"
|
|
||||||
data-action="input->bbk-checkout-form#scheduleSave"
|
|
||||||
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
|
|
||||||
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
|
|
||||||
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
|
|
||||||
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
|
|
||||||
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
|
|
||||||
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
|
|
||||||
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
|
|
||||||
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
|
|
||||||
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
|
|
||||||
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
|
|
||||||
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
|
|
||||||
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
|
|
||||||
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
|
|
||||||
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
|
|
||||||
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
{{-- Contact. Logged in: the order email is the account's (forced
|
|
||||||
server-side in saveAddress()), so there's no field. Guests type
|
|
||||||
their email, plus a login link when config('checkout.login_route')
|
|
||||||
is set; the storefront's login page sends them back here and Lunar
|
|
||||||
merges the guest cart into the account. --}}
|
|
||||||
@php($loginRoute = config('checkout.login_route'))
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
@auth
|
|
||||||
<p class="bbk-checkout-logged-in">
|
|
||||||
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
|
|
||||||
</p>
|
|
||||||
@else
|
|
||||||
@if ($loginRoute)
|
|
||||||
<p class="bbk-checkout-login-prompt">
|
|
||||||
{{ __('checkout.page.login_prompt') }}
|
|
||||||
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-checkout::field
|
|
||||||
name="contact_email"
|
|
||||||
label="{{ __('checkout.page.email_label') }}"
|
|
||||||
type="email"
|
|
||||||
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
|
|
||||||
required
|
|
||||||
form="bbk-address-form"
|
|
||||||
/>
|
|
||||||
@endauth
|
|
||||||
|
|
||||||
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
|
|
||||||
required — direct marketing under ePrivacy (GR L. 3471/2006
|
|
||||||
art. 11), so it needs an explicit opt-in and checkout can't be
|
|
||||||
gated on it. Narrow scope by design (boboko-core's
|
|
||||||
setRecoveryConsent) — a general newsletter opt-in, if wanted,
|
|
||||||
is a separate checkbox. --}}
|
|
||||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="recovery_consent"
|
|
||||||
value="1"
|
|
||||||
form="bbk-address-form"
|
|
||||||
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.recovery_consent') }}
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
|
|
||||||
(this form, plus the contact email/consent which sit outside it but
|
|
||||||
link via form="bbk-address-form") is debounced and POSTed as the whole
|
|
||||||
form; the shipping-method radios are excluded in scheduleSave(). --}}
|
|
||||||
<form
|
|
||||||
id="bbk-address-form"
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.address.save', app()->getLocale()) }}"
|
|
||||||
data-bbk-checkout-form-target="form"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
|
|
||||||
{{-- Billing --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
|
|
||||||
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
|
|
||||||
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
|
|
||||||
(:has on the checkbox), saved/cleared by saveAddress(), and
|
|
||||||
required at place-order. --}}
|
|
||||||
<div class="bbk-invoice">
|
|
||||||
<label class="bbk-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="wants_invoice"
|
|
||||||
value="1"
|
|
||||||
aria-controls="bbk-invoice-fields"
|
|
||||||
@checked($wantsInvoice)
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.wants_invoice') }}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
|
|
||||||
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
|
|
||||||
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
|
|
||||||
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
|
|
||||||
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::region-country
|
|
||||||
prefix="billing"
|
|
||||||
:store-country="$storeCountry"
|
|
||||||
:regions="$regions"
|
|
||||||
:countries="$countries"
|
|
||||||
:address="$billingAddress"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Shipping --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
|
|
||||||
|
|
||||||
<label class="bbk-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="same_as_billing"
|
|
||||||
value="1"
|
|
||||||
data-bbk-checkout-form-target="sameAsBilling"
|
|
||||||
data-action="bbk-checkout-form#toggleSameAsBilling"
|
|
||||||
@checked($shipToBilling)
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.same_as_billing') }}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
|
|
||||||
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
|
|
||||||
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
|
|
||||||
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::region-country
|
|
||||||
prefix="shipping"
|
|
||||||
:store-country="$storeCountry"
|
|
||||||
:regions="$regions"
|
|
||||||
:countries="$countries"
|
|
||||||
:address="$shippingAddress"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::textarea
|
|
||||||
name="shipping_delivery_instructions"
|
|
||||||
label="{{ __('checkout.page.delivery_instructions') }}"
|
|
||||||
:value="$shippingAddress?->delivery_instructions"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p
|
|
||||||
class="bbk-checkout-status"
|
|
||||||
data-bbk-checkout-form-target="status"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
hidden
|
|
||||||
></p>
|
|
||||||
|
|
||||||
{{-- Shipping method — resolves from the saved shipping address;
|
|
||||||
re-rendered as a fragment by bbk-checkout-form after each
|
|
||||||
autosave / option change. --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
|
|
||||||
|
|
||||||
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
|
|
||||||
@include('checkout::partials.shipping-options', [
|
|
||||||
'shippingAddress' => $shippingAddress,
|
|
||||||
'shippingOptions' => $shippingOptions,
|
|
||||||
])
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Payment --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
|
|
||||||
|
|
||||||
<div id="bbk-payment-methods">
|
|
||||||
@include('checkout::partials.payment-methods', [
|
|
||||||
'paymentMethods' => $paymentMethods,
|
|
||||||
'cart' => $cart,
|
|
||||||
])
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
|
|
||||||
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
|
|
||||||
|
|
||||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
|
||||||
<input type="checkbox" data-bbk-payment-target="terms">
|
|
||||||
{!! __('checkout.page.terms_accept', [
|
|
||||||
'terms' => route('legal.terms', app()->getLocale()),
|
|
||||||
'privacy' => route('legal.privacy', app()->getLocale()),
|
|
||||||
]) !!}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p class="bbk-checkout-withdrawal">
|
|
||||||
{!! __('checkout.page.withdrawal_notice', [
|
|
||||||
'link' => route('legal.shipping-returns', app()->getLocale()),
|
|
||||||
]) !!}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-checkout-continue"
|
|
||||||
data-bbk-payment-target="submit"
|
|
||||||
data-action="bbk-payment#placeOrder"
|
|
||||||
@disabled($lines->isEmpty())
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.place_order') }}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
|
|
||||||
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
|
|
||||||
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
|
|
||||||
<span class="bbk-spinner" aria-hidden="true"></span>
|
|
||||||
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="bbk-checkout-aside">
|
|
||||||
<div class="bbk-checkout-summary" data-controller="bbk-cart">
|
|
||||||
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
|
|
||||||
@include('checkout::partials.cart-error')
|
|
||||||
<div data-bbk-cart-target="body" aria-live="polite">
|
|
||||||
@include('checkout::partials.cart-body')
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{{--
|
|
||||||
Server-rendered cart contents. Rendered inline on first page load inside
|
|
||||||
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
|
|
||||||
bbk-cart-controller after every mutation. $cart / $lines come from the view
|
|
||||||
composer in CheckoutModuleServiceProvider.
|
|
||||||
|
|
||||||
The data-bbk-cart-* attributes on the root are the module's read API for the
|
|
||||||
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
|
|
||||||
each swap and re-emits them on the `bbk-cart:updated` window event.
|
|
||||||
--}}
|
|
||||||
@php($count = $lines->sum('quantity'))
|
|
||||||
|
|
||||||
{{-- @dump($lines) --}}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bbk-cart-content"
|
|
||||||
data-bbk-cart-count="{{ $count }}"
|
|
||||||
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
|
|
||||||
>
|
|
||||||
@if ($lines->isEmpty())
|
|
||||||
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
|
|
||||||
@else
|
|
||||||
<ul class="bbk-cart-items">
|
|
||||||
@each('checkout::partials.cart-line', $lines, 'line')
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary">
|
|
||||||
<div class="bbk-cart-coupon">
|
|
||||||
@if ($cart?->coupon_code)
|
|
||||||
<div class="bbk-cart-coupon-applied">
|
|
||||||
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
|
|
||||||
data-action="submit->bbk-cart#submit"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="submit" class="bbk-cart-coupon-remove">
|
|
||||||
{{ __('checkout.cart.coupon_remove') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<form
|
|
||||||
class="bbk-cart-coupon-form"
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
|
|
||||||
data-action="submit->bbk-cart#submit"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
<label class="bbk-visually-hidden" for="bbk-coupon-code">
|
|
||||||
{{ __('checkout.cart.coupon_label') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="code"
|
|
||||||
id="bbk-coupon-code"
|
|
||||||
class="bbk-cart-coupon-input"
|
|
||||||
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<button type="submit" class="bbk-cart-coupon-submit">
|
|
||||||
{{ __('checkout.cart.coupon_apply') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if ($couponError ?? false)
|
|
||||||
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
|
||||||
<span>{{ $cart?->subTotal?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($cart?->discountTotal?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
|
||||||
<span>{{ __('checkout.cart.discount') }}</span>
|
|
||||||
<span>−{{ $cart->discountTotal->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Shipping + tax appear once the shopper has a shipping address
|
|
||||||
(i.e. they're on the checkout page). In the drawer, where no
|
|
||||||
address is set yet, only subtotal + total show. --}}
|
|
||||||
@if ($cart?->shippingAddress)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
|
||||||
@if ($cart->shippingAddress->shipping_option)
|
|
||||||
<span>{{ $cart->shippingTotal?->formatted() }}</span>
|
|
||||||
@else
|
|
||||||
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($cart?->taxTotal?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.tax') }}</span>
|
|
||||||
<span>{{ $cart->taxTotal->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Always shown — equals subtotal with nothing else applied,
|
|
||||||
diverges as discount / shipping / tax come in. --}}
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
|
||||||
<span>{{ __('checkout.cart.total') }}</span>
|
|
||||||
<span>{{ $cart?->total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
|
|
||||||
{{ __('checkout.cart.checkout') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{{--
|
|
||||||
Shared error slot for any host wrapping cart-body in a bbk-cart controller
|
|
||||||
instance (the drawer, and the checkout page's own order summary) —
|
|
||||||
bbk-cart-controller.js#showError() writes into whichever one is present.
|
|
||||||
Without this element in a given host, a rejected quantity update (e.g.
|
|
||||||
over stock) still gets rejected server-side, but the shopper never sees
|
|
||||||
why.
|
|
||||||
--}}
|
|
||||||
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
{{--
|
|
||||||
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
|
|
||||||
@each in cart-body). The two forms post through bbk-cart-controller
|
|
||||||
(fetch + method spoofing) and the response re-renders cart-body.
|
|
||||||
--}}
|
|
||||||
@php
|
|
||||||
$variant = $line->purchasable;
|
|
||||||
$product = $variant?->product;
|
|
||||||
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
|
|
||||||
// The variant's own image (falls back to the product's thumbnail
|
|
||||||
// internally — see ProductVariant::getThumbnail()) — the specific option
|
|
||||||
// the shopper picked, not just the product in general.
|
|
||||||
$thumb = $variant?->getThumbnailImage() ?: null;
|
|
||||||
$variantLabel = $variant?->getOption();
|
|
||||||
// Not routed through checkout::'s own locale-explicit convention — this
|
|
||||||
// is a storefront route, so it follows the storefront's own (implicit
|
|
||||||
// locale) call shape, same as App\Catalog\ProductCard. Carries the
|
|
||||||
// variant id along so the product page can restore the same option the
|
|
||||||
// shopper actually has in their cart, not just default to the first one
|
|
||||||
// (see product-form-controller.js reading ?variant= on connect()).
|
|
||||||
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
|
||||||
<div class="bbk-cart-item-media">
|
|
||||||
@if ($thumb)
|
|
||||||
@if ($productUrl)
|
|
||||||
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
|
|
||||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-cart-item-detail">
|
|
||||||
@if ($productUrl)
|
|
||||||
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
|
|
||||||
@else
|
|
||||||
<p class="bbk-cart-item-title">{{ $name }}</p>
|
|
||||||
@endif
|
|
||||||
@if ($variantLabel)
|
|
||||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
|
||||||
@endif
|
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
|
||||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="bbk-cart-qty"
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
@method('PATCH')
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-cart-qty-btn"
|
|
||||||
data-action="bbk-cart#step"
|
|
||||||
data-bbk-cart-dir-param="-1"
|
|
||||||
aria-label="{{ __('checkout.cart.decrease') }}"
|
|
||||||
>−</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="quantity"
|
|
||||||
value="{{ $line->quantity }}"
|
|
||||||
min="0"
|
|
||||||
inputmode="numeric"
|
|
||||||
class="bbk-cart-qty-input"
|
|
||||||
data-action="change->bbk-cart#submit"
|
|
||||||
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
|
|
||||||
aria-label="{{ __('checkout.cart.quantity') }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-cart-qty-btn"
|
|
||||||
data-action="bbk-cart#step"
|
|
||||||
data-bbk-cart-dir-param="1"
|
|
||||||
aria-label="{{ __('checkout.cart.increase') }}"
|
|
||||||
>+</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-cart-item-aside">
|
|
||||||
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
|
||||||
data-action="submit->bbk-cart#submit"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="bbk-cart-item-remove"
|
|
||||||
aria-label="{{ __('checkout.cart.remove') }}"
|
|
||||||
>×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{{--
|
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
|
||||||
|
|
||||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
|
||||||
CartController::customFieldsMeta()). A file answer only carries a File id
|
|
||||||
(Modules\Core\File\Models\File is the source of truth for name/mime/disk/
|
|
||||||
path — never duplicated into meta), resolved here and linked through
|
|
||||||
boboko-core's own signed download route (files.download), minted fresh on
|
|
||||||
every render, with a thumbnail when the browser can display the format
|
|
||||||
(HEIC can't be shown outside Safari, so it gets the name only).
|
|
||||||
--}}
|
|
||||||
@php
|
|
||||||
$fields = $line->meta['custom_fields'] ?? [];
|
|
||||||
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if (! empty($fields))
|
|
||||||
<dl class="bbk-line-fields">
|
|
||||||
@foreach ($fields as $field)
|
|
||||||
<div class="bbk-line-field">
|
|
||||||
<dt>{{ $field['label'] }}</dt>
|
|
||||||
<dd>
|
|
||||||
@if ($field['type'] === 'file')
|
|
||||||
@php
|
|
||||||
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
|
|
||||||
@endphp
|
|
||||||
@if ($file)
|
|
||||||
@php
|
|
||||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
|
||||||
'files.download',
|
|
||||||
now()->addHours(2),
|
|
||||||
['file' => $file->id],
|
|
||||||
);
|
|
||||||
@endphp
|
|
||||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
|
||||||
@if (in_array($file->mime, $previewable, true))
|
|
||||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
|
||||||
@endif
|
|
||||||
<span>{{ $file->original_name }}</span>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
{{ $field['value'] }}
|
|
||||||
@endif
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</dl>
|
|
||||||
@endif
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{{--
|
|
||||||
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
|
|
||||||
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
|
|
||||||
filtered to enabled + driver-resolves + isConfigured()). Selecting one
|
|
||||||
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
|
|
||||||
controller whether to mount the Stripe Element.
|
|
||||||
|
|
||||||
$paymentMethods, $cart come from the page / controller.
|
|
||||||
--}}
|
|
||||||
@php($selected = $cart?->meta['payment_method'] ?? null)
|
|
||||||
|
|
||||||
@if ($paymentMethods->isEmpty())
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
|
|
||||||
@else
|
|
||||||
<div class="bbk-checkout-payment-options">
|
|
||||||
@foreach ($paymentMethods as $method)
|
|
||||||
<label class="bbk-checkout-payment-option">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="payment_type"
|
|
||||||
value="{{ $method->type }}"
|
|
||||||
data-payment-driver="{{ $method->driver }}"
|
|
||||||
@checked($selected === $method->type)
|
|
||||||
data-action="change->bbk-payment#selectMethod"
|
|
||||||
>
|
|
||||||
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{{--
|
|
||||||
Shipping methods for the checkout page. Rendered inline by page.blade.php on
|
|
||||||
load, and re-rendered as a fragment by CheckoutController after every
|
|
||||||
address save / option change (bbk-checkout-form swaps it in). Radios
|
|
||||||
autosave via bbk-checkout-form#selectShipping — no submit button. A single
|
|
||||||
resolved option is auto-selected server-side and shown as a fixed line.
|
|
||||||
|
|
||||||
$shippingAddress, $shippingOptions come from the controller / page scope.
|
|
||||||
--}}
|
|
||||||
@php($selected = $shippingAddress?->shipping_option)
|
|
||||||
|
|
||||||
{{-- Rate resolution needs country (always Greece here) + postcode; until a
|
|
||||||
postcode is saved there's nothing to quote against yet. --}}
|
|
||||||
@if (! $shippingAddress?->postcode)
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
|
|
||||||
@elseif ($shippingOptions->isEmpty())
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
|
|
||||||
@elseif ($shippingOptions->count() === 1)
|
|
||||||
@php($only = $shippingOptions->first())
|
|
||||||
<div class="bbk-checkout-shipping-confirmed">
|
|
||||||
<span class="bbk-checkout-shipping-option-detail">
|
|
||||||
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
|
|
||||||
@if ($only->description)
|
|
||||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="bbk-checkout-shipping-options">
|
|
||||||
@foreach ($shippingOptions as $option)
|
|
||||||
<label class="bbk-checkout-shipping-option">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="shipping_option"
|
|
||||||
value="{{ $option->identifier }}"
|
|
||||||
@checked($selected === $option->identifier)
|
|
||||||
data-action="change->bbk-checkout-form#selectShipping"
|
|
||||||
>
|
|
||||||
<span class="bbk-checkout-shipping-option-detail">
|
|
||||||
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
|
|
||||||
@if ($option->description)
|
|
||||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user