generated from boboko/starter
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a51ce78538 | ||
|
|
ab7e560a3d | ||
|
|
081893ef57 | ||
|
|
e2d7bbb043 | ||
|
|
3347febb3c | ||
|
|
78c8165c04 |
@@ -61,6 +61,9 @@ MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
CONTACT_EMAIL=
|
||||
|
||||
HCAPTCHA_SITEKEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
@@ -19,7 +19,7 @@ final class ProductCard
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
||||
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool}
|
||||
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool, soldOut: bool}
|
||||
*/
|
||||
public static function fromIndexed(array $product): array
|
||||
{
|
||||
@@ -38,6 +38,10 @@ public static function fromIndexed(array $product): array
|
||||
// can't be quick-added from a card — the card links to the
|
||||
// product page instead, even when every field is optional.
|
||||
'hasCustomFields' => ! empty($product['custom_fields']),
|
||||
// Index-time stock (see boboko-core's ProductIndexer `in_stock`),
|
||||
// so only as fresh as the last reindex. A document missing the
|
||||
// field is treated as in stock rather than hiding its cart button.
|
||||
'soldOut' => ! ($product['in_stock'] ?? true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Rules\HCaptcha;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -43,8 +44,13 @@ public function create(string $locale, Request $request): View
|
||||
|
||||
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
// Captcha only here: verify() and resend() need the email this step
|
||||
// puts in the session, so they can't be reached without passing it.
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
], [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
]);
|
||||
|
||||
$email = Str::lower(trim($validated['email']));
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\CustomFieldUploadController;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\File\Models\File;
|
||||
use Modules\Core\File\Services\FileService;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* Thin storefront cart endpoints for the checkout module. Every action mutates
|
||||
* the session cart via boboko-core's CartService and returns the same
|
||||
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
|
||||
* that fragment in place (no JSON, no client-side templating). $cart / $lines
|
||||
* for the partial come from the view composer in CheckoutModuleServiceProvider.
|
||||
*/
|
||||
class CartController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CartService $cart,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* CartException here is Lunar's own add_to_cart validation pipeline
|
||||
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
||||
* "not enough stock at this quantity" for a tracked (purchasable =
|
||||
* in_stock) variant. Its own message is an untranslated, hardcoded
|
||||
* English string not meant for storefront display, so this returns our
|
||||
* own translated one instead rather than passing it through — a
|
||||
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
||||
* concern the storefront owns, not something specific to the portable
|
||||
* checkout module.
|
||||
*/
|
||||
public function add(string $locale, Request $request): View|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchasable_id' => ['required', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||
'custom_fields' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||
|
||||
try {
|
||||
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
|
||||
} catch (CartException) {
|
||||
return $this->stockError($variant);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* The shopper's answers to the product's custom fields (boboko-core's
|
||||
* Product::$custom_fields — {key, type: text|textarea|file, label,
|
||||
* required}), as cart line meta. Lunar copies CartLine.meta onto the
|
||||
* OrderLine at order creation, so this is also what the order keeps.
|
||||
*
|
||||
* Only keys the product actually defines are kept, nested under
|
||||
* `custom_fields` — line meta also carries behavior flags (core's
|
||||
* `saved_for_later` zeroes the line's price), so shopper input must never
|
||||
* be merged into it directly. Label and type are snapshotted alongside
|
||||
* each value so the cart/order still reads correctly if the product's
|
||||
* fields are edited later.
|
||||
*
|
||||
* A `file` answer is the id of a File row the host's own upload endpoint
|
||||
* (CustomFieldUploadController) already created via boboko-core's
|
||||
* FileService — never the file's bytes, disk, or path, all of which
|
||||
* FileService alone is the source of truth for. A shopper can't point
|
||||
* this at someone else's file: the id must resolve to a File that is
|
||||
* BOTH unowned (isFileAnswerValid()) and tagged with
|
||||
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
||||
* already-ordered file (owned, and/or a different purpose entirely) is
|
||||
* rejected. Attaching the File to the real CartLine it belongs to
|
||||
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
||||
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
||||
* CartLineAdded) — not here, since this method only builds the meta
|
||||
* $this->cart->addLine() is about to receive, before any CartLine
|
||||
* actually exists to own anything.
|
||||
*
|
||||
* Two adds with identical answers merge into one line (Lunar matches
|
||||
* existing lines on meta); different answers stay separate lines.
|
||||
*/
|
||||
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
{
|
||||
$fields = collect($variant->product?->custom_fields ?? [])
|
||||
->keyBy('key')
|
||||
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validated = Validator::make(
|
||||
$input,
|
||||
$fields->map(fn (array $field) => [
|
||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||
...match ($field['type']) {
|
||||
'textarea' => ['string', 'max:2000'],
|
||||
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||
if (! $this->isFileAnswerValid($value)) {
|
||||
$fail('validation.uploaded')->translate();
|
||||
}
|
||||
}],
|
||||
default => ['string', 'max:255'],
|
||||
},
|
||||
])->all(),
|
||||
[],
|
||||
$fields->map(fn (array $field) => $field['label'])->all(),
|
||||
)->validate();
|
||||
|
||||
$answers = $fields
|
||||
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
|
||||
->map(fn (array $field) => [
|
||||
'key' => $field['key'],
|
||||
'label' => $field['label'],
|
||||
'type' => $field['type'],
|
||||
...($field['type'] === 'file'
|
||||
? ['file_id' => (int) $validated[$field['key']]]
|
||||
: ['value' => $validated[$field['key']]]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $answers === [] ? [] : ['custom_fields' => $answers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product::$custom_fields stores `label` as {locale: string} (see
|
||||
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
||||
* resolves it to the single current-locale string cart/order line meta
|
||||
* actually needs, the same filled()-over-?? fallback boboko-core's
|
||||
* ProductDocumentLocalizer uses for every other translated field (an
|
||||
* empty string for the current locale still falls through to the
|
||||
* store's default language, rather than showing blank). A product
|
||||
* saved before labels became translatable still has a plain string
|
||||
* here, returned as-is.
|
||||
*/
|
||||
private function resolveLabel(mixed $label): string
|
||||
{
|
||||
if (! is_array($label)) {
|
||||
return (string) $label;
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||
|
||||
return filled($label[$locale] ?? null)
|
||||
? $label[$locale]
|
||||
: ($label[$fallbackLocale] ?? '');
|
||||
}
|
||||
|
||||
private function isFileAnswerValid(mixed $fileId): bool
|
||||
{
|
||||
$file = File::find($fileId);
|
||||
|
||||
return $file !== null
|
||||
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||
&& $file->owner_id === null
|
||||
&& app(FileService::class)->exists($file);
|
||||
}
|
||||
|
||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||
{
|
||||
$quantity = (int) $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:0'],
|
||||
])['quantity'];
|
||||
|
||||
try {
|
||||
$quantity === 0
|
||||
? $this->cart->removeLine($line)
|
||||
: $this->cart->updateLine($line, $quantity);
|
||||
} catch (CartException) {
|
||||
$variant = CartLine::find($line)?->purchasable;
|
||||
|
||||
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
|
||||
* checked against (stock, for a tracked in_stock variant) — telling the
|
||||
* shopper how many are actually left beats a generic "not enough stock"
|
||||
* they'd otherwise have to guess around by trial and error.
|
||||
*/
|
||||
private function stockError(?ProductVariant $variant): JsonResponse
|
||||
{
|
||||
$available = $variant?->getTotalInventory() ?? 0;
|
||||
|
||||
return response()->json([
|
||||
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
public function remove(string $locale, int $line): View
|
||||
{
|
||||
$this->cart->removeLine($line);
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* A bad code is a normal, expected outcome here (typo, expired code), not
|
||||
* an error state for the request — it re-renders the same cart-body
|
||||
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
|
||||
* + swap in bbk-cart-controller stays the one code path for every cart
|
||||
* mutation.
|
||||
*/
|
||||
public function applyCoupon(string $locale, Request $request): View
|
||||
{
|
||||
$code = $request->validate([
|
||||
'code' => ['required', 'string'],
|
||||
])['code'];
|
||||
|
||||
$couponError = false;
|
||||
|
||||
try {
|
||||
$this->cart->applyCoupon($code);
|
||||
} catch (InvalidCouponException) {
|
||||
$couponError = true;
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
|
||||
}
|
||||
|
||||
public function removeCoupon(string $locale): View
|
||||
{
|
||||
$this->cart->removeCoupon();
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Exceptions\FingerprintMismatchException;
|
||||
use Lunar\Facades\CartSession;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Country;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\State;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* The checkout page — one page, sections (contact / billing / shipping /
|
||||
* shipping method), reviewed against boboko-core's CheckoutService. Stops
|
||||
* short of payment for this slice (see project memory).
|
||||
*
|
||||
* The address form and the shipping-method radios **autosave** — no submit
|
||||
* buttons. `saveAddress()` / `selectShippingOption()` are called by
|
||||
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
|
||||
* server-rendered fragments (shipping options + order summary) plus any
|
||||
* field errors, rather than redirecting. Address validation is deliberately
|
||||
* lenient — nothing is rejected mid-typing; required-field enforcement is the
|
||||
* job of the (not-yet-built) "Continue to payment" gate.
|
||||
*
|
||||
* Guests type their email; the login tab links to the storefront's own
|
||||
* `login` route and back. Logged in: the email is the account's (forced in
|
||||
* saveAddress()), the first visit prefills addresses from the account
|
||||
* (prefillFromAccount()), and Lunar's Login listener has already attached the
|
||||
* cart, so the placed order lands in the account's history.
|
||||
*
|
||||
* Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden
|
||||
* field, forced server-side). Set it to null for the full country picker (the
|
||||
* portable path for a multi-country boboko store).
|
||||
*/
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||
|
||||
public function __construct(
|
||||
private readonly CartService $cart,
|
||||
private readonly CheckoutService $checkout,
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale): View
|
||||
{
|
||||
$cart = $this->cart->current();
|
||||
$lines = $cart ? $this->cart->activeLines($cart) : collect();
|
||||
$storeCountry = $this->storeCountry();
|
||||
|
||||
$shippingOptions = collect();
|
||||
|
||||
// Captured before prefillFromAccount(), which may recreate the address
|
||||
// row (dropping its shipping_option) — same reason as in saveAddress().
|
||||
$previousOption = $cart?->shippingAddress?->shipping_option;
|
||||
|
||||
if ($cart && Auth::check()) {
|
||||
$cart = $this->prefillFromAccount($cart);
|
||||
|
||||
// Nothing chosen on this cart yet: carry over the account's standing
|
||||
// opt-in (an explicit earlier choice, recorded with its own
|
||||
// timestamp/policy version). Never opts anyone in by default.
|
||||
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
|
||||
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
|
||||
$cart = $this->checkout->setRecoveryConsent(true);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cart?->shippingAddress) {
|
||||
$shippingOptions = $this->syncShipping($cart, $previousOption);
|
||||
|
||||
// Cart's CachesProperties::refresh() explicitly nulls total/
|
||||
// subTotal/shippingTotal/etc. back to their defaults — every
|
||||
// Lunar call site pairs it with recalculate() for exactly that
|
||||
// reason. Bare refresh() here was leaving $cart->total null on
|
||||
// reload, which fed a 0 amount straight into the Stripe Element.
|
||||
$cart->refresh()->recalculate();
|
||||
}
|
||||
|
||||
$paymentMethods = $this->checkout->getPaymentMethods();
|
||||
|
||||
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
|
||||
// no longer offered (method disabled/removed since) — auto-select
|
||||
// the first one, same as a manual click would, so the payment
|
||||
// section (and the Stripe Element mounting under it) isn't sitting
|
||||
// inert behind an unchecked radio. A still-valid previous choice is
|
||||
// left alone.
|
||||
$firstMethod = $paymentMethods->first();
|
||||
|
||||
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
|
||||
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
|
||||
}
|
||||
|
||||
return view('checkout::page', [
|
||||
'cart' => $cart,
|
||||
'lines' => $lines,
|
||||
'billingAddress' => $cart?->billingAddress,
|
||||
'shippingAddress' => $cart?->shippingAddress,
|
||||
'shippingOptions' => $shippingOptions,
|
||||
'paymentMethods' => $paymentMethods,
|
||||
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
|
||||
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
|
||||
'storeCountry' => $storeCountry,
|
||||
'countries' => $storeCountry
|
||||
? collect()
|
||||
: Country::orderBy('name')->get(['id', 'name']),
|
||||
'regions' => $storeCountry
|
||||
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
|
||||
: collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveAddress(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$storeCountry = $this->storeCountry();
|
||||
$sameAsBilling = $request->boolean('same_as_billing');
|
||||
|
||||
// Only the fields shipping rates resolve against — if none of these
|
||||
// changed (shopper edited their name, phone, email, …) there's no point
|
||||
// re-quoting shipping or re-rendering the summary.
|
||||
$addressBefore = $this->cart->current()?->shippingAddress;
|
||||
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
|
||||
|
||||
// setShippingAddress() below always deletes + recreates this row (see
|
||||
// syncShipping()'s docblock) — capture what was selected NOW, before
|
||||
// it's gone, so it can be carried forward onto the fresh row.
|
||||
$previousOption = $addressBefore?->shipping_option;
|
||||
|
||||
$stateRule = $storeCountry
|
||||
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
|
||||
: ['nullable', 'string', 'max:255'];
|
||||
$countryRule = $storeCountry
|
||||
? ['nullable']
|
||||
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
|
||||
|
||||
// Lenient — only format checks. Anything that fails is simply left out
|
||||
// of what gets persisted, and reported back for inline display.
|
||||
$validator = Validator::make($request->all(), [
|
||||
'contact_email' => ['nullable', 'email'],
|
||||
|
||||
'billing_first_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_last_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_company_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
|
||||
'billing_line_one' => ['nullable', 'string', 'max:255'],
|
||||
'billing_city' => ['nullable', 'string', 'max:255'],
|
||||
'billing_state' => $stateRule,
|
||||
'billing_postcode' => ['nullable', 'string', 'max:20'],
|
||||
'billing_country_id' => $countryRule,
|
||||
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
'shipping_first_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_last_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_line_one' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_city' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_state' => $stateRule,
|
||||
'shipping_postcode' => ['nullable', 'string', 'max:20'],
|
||||
'shipping_country_id' => $countryRule,
|
||||
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$errors = $validator->errors()->toArray();
|
||||
$data = $validator->valid();
|
||||
|
||||
// Logged in: the order email is always the account's. It isn't a field
|
||||
// on the page then, and a submitted value isn't trusted.
|
||||
if ($user = Auth::user()) {
|
||||
$data['contact_email'] = $user->email;
|
||||
}
|
||||
|
||||
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
|
||||
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
|
||||
|
||||
// Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields
|
||||
// stay in the DOM (just hidden) when it isn't, so ignore what they send.
|
||||
$wantsInvoice = $request->boolean('wants_invoice');
|
||||
|
||||
$billing = [
|
||||
'first_name' => $data['billing_first_name'] ?? null,
|
||||
'last_name' => $data['billing_last_name'] ?? null,
|
||||
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
|
||||
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
|
||||
'line_one' => $data['billing_line_one'] ?? null,
|
||||
'city' => $data['billing_city'] ?? null,
|
||||
'state' => $data['billing_state'] ?? null,
|
||||
'postcode' => $data['billing_postcode'] ?? null,
|
||||
'country_id' => $billingCountryId,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'contact_phone' => $data['billing_contact_phone'] ?? null,
|
||||
];
|
||||
|
||||
$shipping = $sameAsBilling
|
||||
? [
|
||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||
]
|
||||
: [
|
||||
'first_name' => $data['shipping_first_name'] ?? null,
|
||||
'last_name' => $data['shipping_last_name'] ?? null,
|
||||
'line_one' => $data['shipping_line_one'] ?? null,
|
||||
'city' => $data['shipping_city'] ?? null,
|
||||
'state' => $data['shipping_state'] ?? null,
|
||||
'postcode' => $data['shipping_postcode'] ?? null,
|
||||
'country_id' => $shippingCountryId,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'contact_phone' => $data['shipping_contact_phone'] ?? null,
|
||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||
];
|
||||
|
||||
$this->checkout->setBillingAddress($billing);
|
||||
$cart = $this->checkout->setShippingAddress($shipping);
|
||||
|
||||
$cart->meta = [
|
||||
...($cart->meta?->toArray() ?? []),
|
||||
'ship_to_billing' => $sameAsBilling,
|
||||
'wants_invoice' => $wantsInvoice,
|
||||
];
|
||||
$cart->save();
|
||||
|
||||
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
|
||||
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
|
||||
// Deliberately its own scope, not merged with any future newsletter opt-in.
|
||||
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
|
||||
|
||||
if (Auth::check()) {
|
||||
$this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent'));
|
||||
}
|
||||
|
||||
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
|
||||
$rateChanged = $rateKeyAfter != $rateKeyBefore;
|
||||
|
||||
// setShippingAddress() above always deletes and recreates the
|
||||
// CartAddress row (Lunar's AddAddress action), which drops whatever
|
||||
// shipping_option was previously selected — regardless of whether the
|
||||
// rate-determining fields actually changed. So this always has to run
|
||||
// to restore/re-validate it, even on a save that only touched e.g. the
|
||||
// phone number. Only the fragment RE-RENDER is skippable when nothing
|
||||
// rate-relevant moved — the re-select itself is not optional.
|
||||
$options = $this->syncShipping($cart, $previousOption);
|
||||
|
||||
if (! $rateChanged) {
|
||||
return $this->fragments($cart, null, $errors);
|
||||
}
|
||||
|
||||
return $this->fragments($cart, $options, $errors);
|
||||
}
|
||||
|
||||
public function selectShippingOption(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$identifier = (string) $request->input('shipping_option');
|
||||
|
||||
try {
|
||||
$this->checkout->selectShippingOption($identifier);
|
||||
} catch (InvalidShippingOptionException) {
|
||||
// Re-render with whatever is currently valid; no hard error surfaced.
|
||||
}
|
||||
|
||||
$cart = $this->cart->current();
|
||||
$options = $cart?->shippingAddress
|
||||
? $this->checkout->getShippingOptions()
|
||||
: collect();
|
||||
|
||||
return $this->fragments($cart, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autosave-select a payment method (radio change). Persists it via
|
||||
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
||||
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
|
||||
*/
|
||||
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$type = (string) $request->input('payment_type');
|
||||
|
||||
try {
|
||||
$this->checkout->selectPaymentMethod($type);
|
||||
} catch (UnknownPaymentTypeException) {
|
||||
// Radio value out of sync with what's offered — ignore, the summary
|
||||
// just won't reflect a method fee. place-order re-checks properly.
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'summaryHtml' => view('checkout::partials.cart-body')->render(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The real submit — the hard gate. Re-selects the payment method (fresh
|
||||
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
|
||||
* creates the draft order, records terms acceptance, and charges the driver.
|
||||
* Returns JSON the bbk-payment controller routes on:
|
||||
* { redirect } — placed, go to confirmation
|
||||
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
|
||||
* { status: 'failed', message } — declined
|
||||
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
|
||||
*/
|
||||
public function placeOrder(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
if (! $request->boolean('terms_accepted')) {
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
|
||||
} catch (UnknownPaymentTypeException) {
|
||||
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
|
||||
}
|
||||
|
||||
$cart = $this->cart->current();
|
||||
|
||||
// Captured now, before initiatePayment() can place the order — Lunar's
|
||||
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
|
||||
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
|
||||
// (i.e. has an order with placed_at set), which happens synchronously
|
||||
// for an immediately-captured payment. Any later $this->cart->current()
|
||||
// call in this same flow (here, or in a subsequent orderStatus() poll
|
||||
// once the 3-D Secure webhook sets placed_at) would then resolve to
|
||||
// that fresh, order-less cart instead of the one that was just placed.
|
||||
// Storing the real cart id ourselves, under our own session key,
|
||||
// sidesteps CartSession entirely for the rest of the placement flow.
|
||||
session(['checkout.cart_id' => $cart?->id]);
|
||||
|
||||
// Lunar's own ValidateCartForOrderCreation (order_create validator)
|
||||
// never checks for this — an empty cart with a valid billing address
|
||||
// sails straight through it and would place a real, zero-line order.
|
||||
// The disabled "place order" button is only the client-side half of
|
||||
// this fix; this is the half that actually matters.
|
||||
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_empty'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Lenient autosave never requires these; this is the gate.
|
||||
if (data_get($cart, 'meta.wants_invoice')
|
||||
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.invoice_required'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Same check Lunar's own ValidateCartForOrderCreation runs inside
|
||||
// initiatePayment() (a product unpublished/deleted after it was
|
||||
// added to the cart) — checked here first so the shopper is told
|
||||
// which product is the problem, rather than falling into the
|
||||
// catch-all "complete your billing/shipping details" message below,
|
||||
// which is what actually happened and is generic to every
|
||||
// CartException reason, misleading when the real cause is a line,
|
||||
// not an address.
|
||||
$unavailableLines = $this->cart->activeLines($cart)->filter(
|
||||
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
|
||||
);
|
||||
|
||||
if ($unavailableLines->isNotEmpty()) {
|
||||
$names = $unavailableLines
|
||||
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// The one incomplete-cart case worth a specific message + pointing the
|
||||
// shopper at the right section: a region resolving 2+ methods needs an
|
||||
// explicit pick (no auto-select), easy to miss since nothing else on
|
||||
// the page demands it. Everything else CartException catches below.
|
||||
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.shipping_method_required'),
|
||||
'field' => 'shipping_option',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
|
||||
|
||||
$data = $request->filled('payment_method')
|
||||
? ['payment_method' => (string) $request->input('payment_method')]
|
||||
: [];
|
||||
|
||||
try {
|
||||
$result = $this->checkout->initiatePayment(
|
||||
$fingerprint,
|
||||
termsAccepted: true,
|
||||
policyVersion: (string) config('legal.terms_version'),
|
||||
data: $data,
|
||||
);
|
||||
} catch (FingerprintMismatchException) {
|
||||
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
|
||||
} catch (CartException $e) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.payment_incomplete_details'),
|
||||
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
|
||||
], 422);
|
||||
} catch (TermsNotAcceptedException) {
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
// Pending with no continuation (cash-on-delivery, or any other
|
||||
// deferred/offline method) means CheckoutService::initiatePayment()
|
||||
// already created the placed order — money just hasn't changed
|
||||
// hands yet. Only a Pending WITH a continuation (Stripe's client
|
||||
// secret) means the shopper still has something to do before the
|
||||
// order exists as far as the storefront is concerned.
|
||||
return match (true) {
|
||||
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending => response()->json([
|
||||
'status' => 'pending',
|
||||
'clientSecret' => $result->continuation?->value,
|
||||
]),
|
||||
default => response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
|
||||
'retriable' => $result->retriable,
|
||||
], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
|
||||
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
|
||||
* -> ApplyResolvedPaymentStatus sets placed_at.
|
||||
*/
|
||||
public function orderStatus(string $locale): JsonResponse
|
||||
{
|
||||
$order = $this->placedOrder();
|
||||
|
||||
if (! $order) {
|
||||
return response()->json(['placed' => false]);
|
||||
}
|
||||
|
||||
session(['checkout.order_id' => $order->id]);
|
||||
CartSession::forget();
|
||||
|
||||
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
|
||||
}
|
||||
|
||||
public function confirmation(string $locale): View|RedirectResponse
|
||||
{
|
||||
$orderId = session('checkout.order_id');
|
||||
|
||||
$order = $orderId
|
||||
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||
: null;
|
||||
|
||||
if (! $order) {
|
||||
return redirect()->route('products', $locale);
|
||||
}
|
||||
|
||||
// Looked up by type rather than a stored relation — the method may since
|
||||
// have been disabled/deleted, but the order still needs to show what was
|
||||
// actually used at the time.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('checkout::confirmation', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
]);
|
||||
}
|
||||
|
||||
private function orderPlacedResponse(string $locale): JsonResponse
|
||||
{
|
||||
if ($order = $this->placedOrder()) {
|
||||
session(['checkout.order_id' => $order->id]);
|
||||
}
|
||||
|
||||
CartSession::forget();
|
||||
|
||||
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
|
||||
}
|
||||
|
||||
private function placedOrder(): ?Order
|
||||
{
|
||||
$cartId = session('checkout.cart_id');
|
||||
|
||||
if ($cartId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Order::where('cart_id', $cartId)
|
||||
->whereNotNull('placed_at')
|
||||
->latest('placed_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve shipping options for the cart's current address and keep the
|
||||
* selection sane: auto-select when exactly one resolves, or carry a
|
||||
* previous pick forward when it's still among the resolved options.
|
||||
*
|
||||
* $previousOption must be captured by the CALLER before setShippingAddress()
|
||||
* runs — Lunar's AddAddress action always deletes and recreates the
|
||||
* CartAddress row on every save (see saveAddress()), so by the time this
|
||||
* runs, $address->shipping_option is unconditionally null regardless of
|
||||
* what was selected a moment ago. There is nothing meaningful left to read
|
||||
* off $address itself; $previousOption is the only source of truth for
|
||||
* "what was chosen before this save wiped the row." show() passes the
|
||||
* address's own (not-just-wiped) current value, since nothing recreated
|
||||
* anything in that path.
|
||||
*
|
||||
* Always (re-)applies the resolved target via selectShippingOption() rather
|
||||
* than comparing against the (always-blank, post-recreation) current value
|
||||
* — the fresh row needs the write regardless of whether the decision
|
||||
* "which option" actually changed.
|
||||
*
|
||||
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
|
||||
*/
|
||||
private function syncShipping(Cart $cart, ?string $previousOption): Collection
|
||||
{
|
||||
if (! $cart->shippingAddress) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$options = $this->checkout->getShippingOptions();
|
||||
|
||||
$target = match (true) {
|
||||
$options->count() === 1 => $options->first()->identifier,
|
||||
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($target !== null) {
|
||||
try {
|
||||
$this->checkout->selectShippingOption($target);
|
||||
} catch (InvalidShippingOptionException) {
|
||||
// $target came from $options itself — shouldn't happen, stay defensive
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* $options === null means "nothing money-relevant changed" — acknowledge the
|
||||
* save (and any field errors) without re-rendering the shipping options or
|
||||
* the order summary, so a plain name/phone edit is a cheap round-trip.
|
||||
*/
|
||||
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'errors' => collect($errors)
|
||||
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
|
||||
->all(),
|
||||
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
|
||||
'shippingAddress' => $cart?->shippingAddress,
|
||||
'shippingOptions' => $options,
|
||||
])->render(),
|
||||
// Composer (CheckoutModuleServiceProvider) fills $cart / $lines.
|
||||
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged-in shopper: fills any BLANK cart address field from the account
|
||||
* (name, saved default address, phone, email), on every checkout load, so
|
||||
* an account filled in after checkout started still shows up. Never
|
||||
* overwrites anything already in the cart.
|
||||
*
|
||||
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
|
||||
* (meta.account_prefilled): someone who then clears them or unticks the
|
||||
* box for this order shouldn't get them back on the next reload.
|
||||
*
|
||||
* Writes only when something actually changes, so a normal reload costs
|
||||
* nothing extra.
|
||||
*/
|
||||
private function prefillFromAccount(Cart $cart): Cart
|
||||
{
|
||||
$user = Auth::user();
|
||||
$customer = $this->account->customer($user);
|
||||
$addresses = collect($this->account->addresses($user));
|
||||
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||
$firstPass = ! data_get($cart, 'meta.account_prefilled');
|
||||
|
||||
$fromAccount = array_filter([
|
||||
'first_name' => $customer?->first_name ?: $saved?->first_name,
|
||||
'last_name' => $customer?->last_name ?: $saved?->last_name,
|
||||
'line_one' => $saved?->line_one,
|
||||
'city' => $saved?->city,
|
||||
'state' => $saved?->state,
|
||||
'postcode' => $saved?->postcode,
|
||||
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
|
||||
'contact_email' => $user->email,
|
||||
'contact_phone' => $saved?->contact_phone,
|
||||
], 'filled');
|
||||
|
||||
$invoice = $firstPass
|
||||
? array_filter([
|
||||
'company_name' => $customer?->company_name,
|
||||
'tax_identifier' => $customer?->tax_identifier,
|
||||
], 'filled')
|
||||
: [];
|
||||
|
||||
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
|
||||
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
|
||||
|
||||
$fillBlanks = function (?array $current, array $values) {
|
||||
$current ??= [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
if (blank($current[$key] ?? null)) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
};
|
||||
|
||||
$billingBefore = $cart->billingAddress?->only($fields);
|
||||
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
|
||||
|
||||
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
|
||||
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
|
||||
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
|
||||
|
||||
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
|
||||
$shipping = $shipToBilling
|
||||
? [
|
||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
|
||||
]
|
||||
: $fillBlanks($shippingBefore, $fromAccount);
|
||||
|
||||
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
|
||||
$this->checkout->setBillingAddress($billing);
|
||||
$cart = $this->checkout->setShippingAddress($shipping);
|
||||
}
|
||||
|
||||
if ($firstPass) {
|
||||
$cart->meta = [
|
||||
...($cart->meta?->toArray() ?? []),
|
||||
'account_prefilled' => true,
|
||||
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
|
||||
];
|
||||
$cart->save();
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
private function storeCountry(): ?Country
|
||||
{
|
||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,9 @@ private function buildOptionPicker(int $productId): array
|
||||
'id' => $variant->id,
|
||||
'price' => $variant->prices->first()?->price?->decimal(),
|
||||
'image' => $variant->images->first()?->getUrl(),
|
||||
// Live from the DB (not the index's in_stock), with Lunar's own
|
||||
// purchasability rule — drives the disabled add-to-cart button.
|
||||
'inStock' => $variant->canBeFulfilledAtQuantity(1),
|
||||
// handle => selected value id, for matching a combination of
|
||||
// selections back to this variant — see selectVariant() in
|
||||
// product-form-controller.js.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\HCaptcha;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
@@ -17,6 +18,14 @@ public function rules(): array
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'max:5000'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\View as ViewInstance;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
|
||||
/**
|
||||
* The seam for the (currently in-repo) cart + checkout module.
|
||||
*
|
||||
* Everything the module needs to boot inside a host app lives here: its view /
|
||||
* component namespaces, its routes, and the composer that feeds the
|
||||
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
|
||||
* here — same as storefront.* elsewhere in this app, they resolve through
|
||||
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
|
||||
* manually there rather than shipped as lang/ files. The module's own files
|
||||
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
|
||||
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
|
||||
* 3dealer-specific.
|
||||
*
|
||||
* When the module is extracted to boboko-core, this class is deleted and its
|
||||
* body becomes the package's own ServiceProvider. The only other host wiring
|
||||
* is the one registerCheckout() call in resources/js/app.js and the two lines
|
||||
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
|
||||
*/
|
||||
class CheckoutModuleServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
|
||||
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
|
||||
|
||||
Route::middleware('web')->group(base_path('routes/checkout.php'));
|
||||
|
||||
// The drawer is rendered on every page (from the layout) and its body
|
||||
// partial is re-rendered on every cart mutation — both need the current
|
||||
// cart without a controller in the loop.
|
||||
View::composer(
|
||||
['checkout::drawer', 'checkout::partials.cart-body'],
|
||||
function (ViewInstance $view) {
|
||||
$service = app(CartService::class);
|
||||
$cart = $service->current();
|
||||
|
||||
$view->with('cart', $cart);
|
||||
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Verifies the `h-captcha-response` token the hCaptcha widget adds to a form.
|
||||
* Use it as `'h-captcha-response' => ['required', new HCaptcha]`.
|
||||
*
|
||||
* Fails closed: if hCaptcha can't be reached the submission is rejected, since
|
||||
* letting it through would reopen the hole this exists to close (bots making
|
||||
* us send email to arbitrary addresses).
|
||||
*/
|
||||
class HCaptcha implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout(5)
|
||||
->post('https://api.hcaptcha.com/siteverify', [
|
||||
'secret' => config('services.hcaptcha.secret'),
|
||||
'response' => $value,
|
||||
// Rejects tokens solved against someone else's sitekey.
|
||||
'sitekey' => config('services.hcaptcha.sitekey'),
|
||||
'remoteip' => request()->ip(),
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]);
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $response->successful() || $response->json('success') !== true) {
|
||||
// A bad/missing secret or sitekey would otherwise look like every
|
||||
// shopper failing the captcha.
|
||||
$configErrors = array_intersect((array) $response->json('error-codes'), [
|
||||
'missing-input-secret',
|
||||
'invalid-input-secret',
|
||||
'sitekey-secret-mismatch',
|
||||
'invalid-sitekey',
|
||||
]);
|
||||
|
||||
if ($response->failed() || $configErrors) {
|
||||
Log::warning('hCaptcha siteverify error', [
|
||||
'status' => $response->status(),
|
||||
'error-codes' => $response->json('error-codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\CheckoutModuleServiceProvider;
|
||||
use App\Providers\PanelServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
PanelServiceProvider::class,
|
||||
CheckoutModuleServiceProvider::class,
|
||||
];
|
||||
|
||||
Generated
+16
-15
@@ -515,11 +515,11 @@
|
||||
},
|
||||
{
|
||||
"name": "boboko/core",
|
||||
"version": "0.20.0",
|
||||
"version": "0.22.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://code.radical-elements.com/boboko/core.git",
|
||||
"reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d"
|
||||
"reference": "985f53efa2d34849f35c8e0f4129f861be762e76"
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": "^12.0",
|
||||
@@ -557,6 +557,7 @@
|
||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
||||
"Modules\\Core\\Providers\\FileServiceProvider",
|
||||
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
||||
"Modules\\Core\\Providers\\OrderServiceProvider",
|
||||
"Modules\\Core\\Providers\\PrivacyServiceProvider"
|
||||
@@ -569,7 +570,7 @@
|
||||
}
|
||||
},
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"time": "2026-09-23T06:47:28+00:00"
|
||||
"time": "2026-09-25T12:58:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -2937,16 +2938,16 @@
|
||||
},
|
||||
{
|
||||
"name": "kirschbaum-development/eloquent-power-joins",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kirschbaum-development/eloquent-power-joins.git",
|
||||
"reference": "c609dbbe4ad2051b667e937f1ab554067519d64b"
|
||||
"reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/c609dbbe4ad2051b667e937f1ab554067519d64b",
|
||||
"reference": "c609dbbe4ad2051b667e937f1ab554067519d64b",
|
||||
"url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/a0e6c0420c381861e1830693c4b74bdac7f7fb5f",
|
||||
"reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2994,9 +2995,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues",
|
||||
"source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.3"
|
||||
"source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.4"
|
||||
},
|
||||
"time": "2026-07-23T11:41:37+00:00"
|
||||
"time": "2026-09-25T11:10:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
@@ -12600,16 +12601,16 @@
|
||||
},
|
||||
{
|
||||
"name": "technikermathe/blade-lucide-icons",
|
||||
"version": "v3.181.0",
|
||||
"version": "v3.182.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PascaleBeier/blade-lucide-icons.git",
|
||||
"reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98"
|
||||
"reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98",
|
||||
"reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98",
|
||||
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/11253aa7d9aa7430da61d3534c62dc69a95b703b",
|
||||
"reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12659,9 +12660,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues",
|
||||
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.181.0"
|
||||
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.182.0"
|
||||
},
|
||||
"time": "2026-09-18T02:09:04+00:00"
|
||||
"time": "2026-09-25T02:29:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Product recommendation rules
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||
| the first rule that returns at least one product wins. The order here IS
|
||||
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||
| last-resort so a product page is never left with zero recommendations
|
||||
| (as long as the store has more than one product). A consuming app can
|
||||
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||
| hardcoded in the service itself.
|
||||
|
|
||||
*/
|
||||
'recommendation_rules' => [
|
||||
SameCategoryRule::class,
|
||||
RandomRule::class,
|
||||
],
|
||||
];
|
||||
+30
-4
@@ -2,17 +2,43 @@
|
||||
|
||||
/*
|
||||
* Per-site settings for the cart + checkout module (see
|
||||
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
|
||||
* publishable config when the module moves to boboko-core.
|
||||
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
|
||||
* artisan vendor:publish --tag=core-config.
|
||||
*/
|
||||
return [
|
||||
|
||||
/*
|
||||
* Name of the storefront's login route. The checkout's login tab and the
|
||||
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||
* login page must send the shopper back there afterwards (3dealer's
|
||||
* Auth\LoginController does). null: no login offered in checkout at all.
|
||||
* login page must send the shopper back there afterwards. null: no login
|
||||
* offered in checkout at all.
|
||||
*/
|
||||
'login_route' => 'login',
|
||||
|
||||
/*
|
||||
* Name of the storefront's product-listing route — where confirmation()
|
||||
* redirects a visit with no placed order to look at (session expired,
|
||||
* direct navigation, a bookmark). route($this, $locale) must resolve.
|
||||
*/
|
||||
'products_route' => 'products',
|
||||
|
||||
/*
|
||||
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
|
||||
* field, forced server-side — no country picker shown at all). null (the
|
||||
* default) gives the full country/region picker, for a multi-country
|
||||
* store. 3dealer is Greece-only for now.
|
||||
*/
|
||||
'store_country_iso3' => 'GRC',
|
||||
|
||||
/*
|
||||
* The `purpose` tag CartController expects a product custom field's
|
||||
* `file` answer to already carry (see Modules\Core\File\Models\File) —
|
||||
* matches whatever purpose string the host's own upload endpoint
|
||||
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
|
||||
* tags its stored files with. This module never reaches into that
|
||||
* host controller directly; this config value is the one shared
|
||||
* source of truth between the two.
|
||||
*/
|
||||
'custom_field_upload_purpose' => 'custom-field-upload',
|
||||
|
||||
];
|
||||
|
||||
+122
@@ -16,4 +16,126 @@
|
||||
|
||||
'auto_create_customer_for_user' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Privacy / GDPR data-subject requests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
|
||||
| that should be consulted for right-of-access/right-of-erasure requests. A
|
||||
| module never needs to be known to core in advance — it just adds its own
|
||||
| provider class here, the same way config('lunar.search.indexers') maps a
|
||||
| model to its indexer. See docs/privacy.md.
|
||||
|
|
||||
| 'grace_period_days' is how long an erasure request stays cancellable
|
||||
| (account deactivated, not yet erased) before it's actually processed by
|
||||
| the privacy:process-erasure-requests scheduled command.
|
||||
|
|
||||
*/
|
||||
|
||||
'privacy' => [
|
||||
'providers' => [
|
||||
// ActivityLogDataProvider MUST run before AddressDataProvider —
|
||||
// it resolves which activity_log rows belong to this customer
|
||||
// (including ones keyed by an Address id) before
|
||||
// AddressDataProvider hard-deletes those Address rows. See that
|
||||
// provider's own class docblock.
|
||||
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
|
||||
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
|
||||
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
|
||||
\Modules\Core\Order\Privacy\OrderDataProvider::class,
|
||||
\Modules\Core\Cart\Privacy\CartDataProvider::class,
|
||||
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
|
||||
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
|
||||
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
|
||||
],
|
||||
|
||||
'grace_period_days' => 30,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cart Abandonment Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How long a cart (that hasn't converted to a placed order) can go without
|
||||
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
|
||||
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
|
||||
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
||||
|
|
||||
*/
|
||||
|
||||
'cart' => [
|
||||
'abandoned_after' => '1 hour',
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Unrecoverable Cap
|
||||
|----------------------------------------------------------------------
|
||||
|
|
||||
| Beyond this age, a stale cart stops being treated as an active
|
||||
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
|
||||
| CartLifecycleService) — too old to be a realistic recovery target
|
||||
| (pricing/stock/tax likely stale by then). This is about the
|
||||
| abandoned-cart pipeline only, not data retention — no rows are
|
||||
| deleted or pruned based on this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'unrecoverable_after' => '90 days',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Order Return Window
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How many days after a carrier order is delivered (Order::fulfillment_status
|
||||
| becomes 'return_window_open') before Modules\Core\Order\Commands\
|
||||
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
|
||||
| Store-pickup orders have no return-window step and are unaffected by
|
||||
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
||||
|
|
||||
*/
|
||||
|
||||
'order' => [
|
||||
'return_window_days' => 14,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Storefront OTP Login
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
|
||||
| max_attempts caps how many wrong codes a shopper can guess against ONE
|
||||
| generated code before it's invalidated outright. generation_limit/
|
||||
| generation_decay_minutes cap how often a NEW code can be requested for
|
||||
| the same email — independent of max_attempts, since generating a fresh
|
||||
| code also resets the guess count, so an attempt cap alone doesn't stop
|
||||
| an attacker from just requesting a new code every few tries. This same
|
||||
| limit is also what stands between a malicious/careless caller and
|
||||
| mail-bombing one inbox.
|
||||
|
|
||||
*/
|
||||
|
||||
'auth' => [
|
||||
'otp' => [
|
||||
'max_attempts' => 5,
|
||||
'generation_limit' => 3,
|
||||
'generation_decay_minutes' => 10,
|
||||
],
|
||||
|
||||
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
|
||||
// shape/reasoning as auth.otp above, independent limits since this
|
||||
// is a separate flow (changing an existing account's login email,
|
||||
// not logging in).
|
||||
'email_change' => [
|
||||
'max_attempts' => 5,
|
||||
'generation_limit' => 3,
|
||||
'generation_decay_minutes' => 10,
|
||||
'expiry_minutes' => 10,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -40,6 +40,15 @@
|
||||
'email' => env('CONTACT_EMAIL'),
|
||||
],
|
||||
|
||||
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||
// secret 0x0000000000000000000000000000000000000000.
|
||||
'hcaptcha' => [
|
||||
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||
'secret' => env('HCAPTCHA_SECRET'),
|
||||
],
|
||||
|
||||
'stoic' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'host' => env('STOIC_HOST'),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ACS Courier credentials
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||
| HTTP header (gates the REST gateway itself) and four account fields
|
||||
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||
| request body. Both are supplied by ACS when your account is set up.
|
||||
|
|
||||
| Set these via environment variables — never commit real values.
|
||||
|
|
||||
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||
| every ACSAlias call).
|
||||
| ACS_API_KEY The AcsApiKey header value.
|
||||
| ACS_COMPANY_ID Company_ID body field.
|
||||
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||
| ACS_USER_ID User_ID body field.
|
||||
| ACS_USER_PASSWORD User_Password body field.
|
||||
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||
| calculation and voucher creation.
|
||||
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||
|
||||
'api_key' => env('ACS_API_KEY'),
|
||||
|
||||
'company_id' => env('ACS_COMPANY_ID'),
|
||||
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||
'user_id' => env('ACS_USER_ID'),
|
||||
'user_password' => env('ACS_USER_PASSWORD'),
|
||||
|
||||
'billing_code' => env('ACS_BILLING_CODE'),
|
||||
|
||||
'sender' => [
|
||||
'name' => env('ACS_SENDER_NAME'),
|
||||
'address' => env('ACS_SENDER_ADDRESS'),
|
||||
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||
'phone' => env('ACS_SENDER_PHONE'),
|
||||
],
|
||||
|
||||
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Box Now credentials
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||
| Unlike ACS, there is no separate per-request credential body — the
|
||||
| token alone authorizes all calls once obtained.
|
||||
|
|
||||
| Set these via environment variables — never commit real values.
|
||||
|
|
||||
| Box Now has two environments (see their Partner API manual, section 2):
|
||||
| Stage/Sandbox for testing, Production once live. Each has its own
|
||||
| client_id/client_secret pair and its own base_url/location_api_url —
|
||||
| there is no shared "switch an env var" flag, since stage credentials
|
||||
| don't work against the production host or vice versa.
|
||||
|
|
||||
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||
| lookups (Box Now recommends this over the main
|
||||
| base URL for those two calls specifically).
|
||||
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
|
||||
| credentials. NOT used for REST API authentication
|
||||
| (BoxNowClient authenticates with client_id/
|
||||
| client_secret alone) — this is only consumed by
|
||||
| the client-side Destination Map widget config
|
||||
| (_bn_map_widget_config.partnerId), confirmed
|
||||
| against Box Now's own WooCommerce plugin source.
|
||||
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||
| the pickup origin on every delivery request.
|
||||
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||
| delivery request.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||
|
||||
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||
'partner_id' => env('BOXNOW_PARTNER_ID'),
|
||||
|
||||
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||
|
||||
'sender' => [
|
||||
'name' => env('BOXNOW_SENDER_NAME'),
|
||||
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||
],
|
||||
|
||||
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||
|
||||
];
|
||||
@@ -1,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', 'Συνέχεια αγορών'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Generated
+8
-15
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "boboko-starter",
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,9 +7,8 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"axios": "^1.15.2",
|
||||
"flatpickr": "^4.6.13"
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -147,12 +146,6 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/web": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
@@ -1015,12 +1008,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flatpickr": {
|
||||
"version": "4.6.13",
|
||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -1256,6 +1243,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"axios": "^1.15.2"
|
||||
"axios": "^1.15.2",
|
||||
"leaflet": "^1.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ @layer components {
|
||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
||||
}
|
||||
|
||||
.btn-primary:hover::after {
|
||||
.btn-primary:not(:disabled):hover::after {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -656,6 +656,141 @@ .bbk-checkout-status {
|
||||
|
||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
||||
|
||||
/* Dummy Box Now locker picker — see shipping-options.blade.php. */
|
||||
.bbk-checkout-box-now-locker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-map {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-search {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--bbk-color-accent);
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-chosen {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bbk-color-accent);
|
||||
}
|
||||
|
||||
/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no
|
||||
default Leaflet drop-shadow image, so a CSS shadow stands in for it. */
|
||||
.bbk-box-now-pin svg {
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
|
||||
/* Leaflet's own popup chrome, restyled to match the checkout's cards
|
||||
instead of the library's square-cornered default. */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-image {
|
||||
width: calc(100% + 1.75rem);
|
||||
margin: -0.875rem -0.875rem 0.125rem;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-name::before {
|
||||
content: '';
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #00c389;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-address {
|
||||
margin: 0;
|
||||
padding-left: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-note {
|
||||
margin: 0 0 0 0.875rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: color-mix(in srgb, #00c389 8%, transparent);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-select {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: #00c389;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); }
|
||||
.bbk-box-now-popup-select:active { transform: translateY(0); }
|
||||
|
||||
.bbk-box-now-popup-select--selected,
|
||||
.bbk-box-now-popup-select--selected:hover {
|
||||
background: var(--bbk-color-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||
|
||||
.bbk-checkout-continue {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Box Now's own brand green, used for the pin instead of Leaflet's default
|
||||
// blue teardrop — a small SVG data URI rather than another bundled asset.
|
||||
const PIN_COLOR = '#00c389'
|
||||
const PIN_COLOR_SELECTED = '#0a7a52'
|
||||
|
||||
function pinIcon(color) {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
|
||||
<path
|
||||
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
|
||||
fill="${color}"
|
||||
stroke="#ffffff"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="17" cy="17" r="7" fill="#ffffff" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
return L.divIcon({
|
||||
className: 'bbk-box-now-pin',
|
||||
html: svg,
|
||||
iconSize: [34, 46],
|
||||
iconAnchor: [17, 46],
|
||||
popupAnchor: [0, -40],
|
||||
})
|
||||
}
|
||||
|
||||
const ICON = pinIcon(PIN_COLOR)
|
||||
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
|
||||
|
||||
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
|
||||
// JS widget — that widget only talks to Box Now's Production API (see
|
||||
// their Partner API manual §4.1), so it can't be used while developing
|
||||
// against Stage credentials. Same underlying /destinations data, rendered
|
||||
// with OpenStreetMap tiles instead of Box Now's map.
|
||||
//
|
||||
// Visibility is toggled by bbk-checkout-form (see its own
|
||||
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
|
||||
// selected/deselected — this controller only owns loading the locker list
|
||||
// once visible, rendering pins, and autosaving the chosen one.
|
||||
export default class extends Controller {
|
||||
static targets = ['map', 'search', 'status', 'chosen']
|
||||
|
||||
static values = {
|
||||
lockersUrl: String,
|
||||
selectUrl: String,
|
||||
loading: String,
|
||||
selectLabel: String,
|
||||
selectedLabel: String,
|
||||
noResults: String,
|
||||
}
|
||||
|
||||
// Athens — a reasonable default center before any locker is loaded.
|
||||
static DEFAULT_CENTER = [37.9838, 23.7275]
|
||||
|
||||
connect() {
|
||||
this.map = null
|
||||
this.markers = new Map()
|
||||
this.selectedId = null
|
||||
this.loaded = false
|
||||
|
||||
if (!this.element.hidden) this.show()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.map?.remove()
|
||||
this.map = null
|
||||
}
|
||||
|
||||
// Called by bbk-checkout-form right after it un-hides this element.
|
||||
show() {
|
||||
this.element.hidden = false
|
||||
|
||||
// Leaflet measures its container's size on init — doing that while
|
||||
// the element (or an ancestor) is still `hidden` produces a
|
||||
// collapsed/blank map, so this is deferred to the same tick `hidden`
|
||||
// is cleared, then Leaflet is nudged once more via invalidateSize().
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.map) this.initMap()
|
||||
this.map.invalidateSize()
|
||||
|
||||
if (!this.loaded) this.loadLockers()
|
||||
})
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.element.hidden = true
|
||||
}
|
||||
|
||||
initMap() {
|
||||
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(this.map)
|
||||
|
||||
// Delegated: popup content is re-inserted by Leaflet on every open,
|
||||
// so a listener bound once on the map's container beats binding (and
|
||||
// losing) one on the button each time a popup renders.
|
||||
this.map.getContainer().addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-locker-id]')
|
||||
if (button) this.select(button.dataset.lockerId)
|
||||
})
|
||||
}
|
||||
|
||||
async loadLockers() {
|
||||
this.setStatus(this.loadingValue)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.lockersUrlValue, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const { lockers } = await response.json()
|
||||
this.loaded = true
|
||||
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
|
||||
this.renderMarkers(lockers)
|
||||
this.setStatus('')
|
||||
} catch {
|
||||
this.setStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
renderMarkers(lockers) {
|
||||
this.markers.forEach((marker) => marker.remove())
|
||||
this.markers = new Map(lockers.map((locker) => {
|
||||
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
|
||||
.addTo(this.map)
|
||||
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
|
||||
|
||||
return [String(locker.id), marker]
|
||||
}))
|
||||
|
||||
if (this.markers.size) {
|
||||
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
popupHtml(locker) {
|
||||
const isSelected = String(locker.id) === this.selectedId
|
||||
|
||||
return `
|
||||
<div class="bbk-box-now-popup">
|
||||
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
|
||||
<p class="bbk-box-now-popup-name">${locker.name}</p>
|
||||
<p class="bbk-box-now-popup-address">
|
||||
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
|
||||
${locker.postalCode ? ` ${locker.postalCode}` : ''}
|
||||
</p>
|
||||
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
|
||||
data-locker-id="${locker.id}"
|
||||
${isSelected ? 'disabled' : ''}
|
||||
>
|
||||
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async select(lockerId) {
|
||||
const locker = this.lockers?.get(String(lockerId))
|
||||
if (!locker) return
|
||||
|
||||
const previousId = this.selectedId
|
||||
this.selectedId = String(lockerId)
|
||||
|
||||
this.restyleMarker(previousId, ICON)
|
||||
this.restyleMarker(this.selectedId, ICON_SELECTED)
|
||||
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
|
||||
|
||||
this.chosenTarget.hidden = false
|
||||
this.chosenTarget.textContent = locker.addressLine1
|
||||
? `${locker.name} — ${locker.addressLine1}`
|
||||
: locker.name
|
||||
|
||||
const body = new FormData()
|
||||
body.append('locker_id', locker.id)
|
||||
body.append('locker_name', locker.name ?? '')
|
||||
body.append('locker_address', locker.addressLine1 ?? '')
|
||||
|
||||
try {
|
||||
await fetch(this.selectUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort autosave, same as the rest of checkout — a failed
|
||||
// save here surfaces later at place-order time via the normal
|
||||
// shipment-creation error path, not as an inline field error.
|
||||
}
|
||||
}
|
||||
|
||||
restyleMarker(lockerId, icon) {
|
||||
if (!lockerId) return
|
||||
this.markers.get(lockerId)?.setIcon(icon)
|
||||
}
|
||||
|
||||
setStatus(text) {
|
||||
if (!this.hasStatusTarget) return
|
||||
|
||||
this.statusTarget.textContent = text
|
||||
this.statusTarget.hidden = !text
|
||||
}
|
||||
}
|
||||
@@ -123,12 +123,34 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async selectShipping(event) {
|
||||
this.toggleBoxNowLocker(event.target.value)
|
||||
|
||||
// Tracked so flush() can await it — nothing else stops "place order"
|
||||
// (a separate, unrelated click) from racing ahead of this request.
|
||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
||||
await this.shippingPromise
|
||||
}
|
||||
|
||||
// The dummy Box Now locker <select> (see shipping-options.blade.php)
|
||||
// lives inside the #bbk-shipping-options fragment this controller
|
||||
// re-renders wholesale on every shipping-option change — so its own
|
||||
// Stimulus controller reconnects fresh each time and has no memory of
|
||||
// which option was previously selected. This is the one place that
|
||||
// knows the newly-chosen option's identifier, so it also owns
|
||||
// showing/hiding the picker.
|
||||
toggleBoxNowLocker(identifier) {
|
||||
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
|
||||
if (!picker) return
|
||||
|
||||
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
|
||||
|
||||
if (identifier === 'box-now') {
|
||||
controller?.show()
|
||||
} else {
|
||||
controller?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async doSelectShipping(value) {
|
||||
this.saveController?.abort()
|
||||
this.setStatus('saving')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
|
||||
import BbkCartController from './bbk-cart-controller'
|
||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
||||
import BbkPaymentController from './bbk-payment-controller'
|
||||
@@ -13,6 +14,7 @@ import BbkPaymentController from './bbk-payment-controller'
|
||||
// only that one import line in the host entry point differs per project.
|
||||
export function registerCheckout(application) {
|
||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
||||
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
|
||||
application.register('bbk-cart', BbkCartController)
|
||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
||||
application.register('bbk-payment', BbkPaymentController)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
||||
import { formatPrice } from '../utils/format-price'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError']
|
||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
|
||||
static values = {
|
||||
variants: Array,
|
||||
selected: Number,
|
||||
@@ -90,7 +90,7 @@ export default class extends Controller {
|
||||
// own server-side check have the final word rather than
|
||||
// silently blocking the shopper here.
|
||||
} finally {
|
||||
if (submit) submit.disabled = false
|
||||
if (submit) submit.disabled = !this.selectedVariant?.inStock
|
||||
}
|
||||
|
||||
form.dataset.bbkStockChecked = 'true'
|
||||
@@ -111,6 +111,10 @@ export default class extends Controller {
|
||||
this.stockErrorTarget.hidden = true
|
||||
}
|
||||
|
||||
get selectedVariant() {
|
||||
return this.variantsValue.find(v => v.id === this.selectedValue)
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const option = event.currentTarget.dataset.option
|
||||
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||
@@ -156,6 +160,9 @@ export default class extends Controller {
|
||||
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||
if (purchasableInput) purchasableInput.value = id
|
||||
|
||||
// Out-of-stock variant (as of page load) can't be added at all.
|
||||
if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
<meta name="robots" content="noindex, follow">
|
||||
@endpush
|
||||
|
||||
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||
@push('scripts')
|
||||
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||
@@ -52,6 +57,18 @@
|
||||
]) !!}
|
||||
</p>
|
||||
|
||||
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds
|
||||
the `h-captcha-response` field itself. Fixed height reserves the
|
||||
iframe's space so the button doesn't jump when it loads. --}}
|
||||
<x-ui.field for="login-captcha" :error="$errors->first('h-captcha-response')">
|
||||
<div
|
||||
id="login-captcha"
|
||||
class="h-captcha min-h-[78px]"
|
||||
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||
@if ($errors->has('h-captcha-response')) aria-describedby="login-captcha-error" @endif
|
||||
></div>
|
||||
</x-ui.field>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{{--
|
||||
<x-checkout::add-to-cart :purchasable="$variantId" />
|
||||
|
||||
A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller
|
||||
(fetch) and hands the rendered cart body to the drawer over the
|
||||
`bbk-cart:changed` window event.
|
||||
|
||||
Props:
|
||||
purchasable ProductVariant id. Omit to render no hidden id field — the host
|
||||
must then supply [data-bbk-purchasable-input] itself (e.g. a
|
||||
variant picker writing the selected id into it).
|
||||
quantity Integer for the hidden quantity field, or false to omit it
|
||||
(the host then puts its own name="quantity" control in the slot).
|
||||
|
||||
The button and any quantity control come from the slot, so the host owns all
|
||||
appearance. Extra attributes (class, etc.) land on the <form>.
|
||||
--}}
|
||||
@props([
|
||||
'purchasable' => null,
|
||||
'quantity' => 1,
|
||||
'action' => null,
|
||||
])
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
|
||||
data-controller="bbk-add-to-cart"
|
||||
data-action="bbk-add-to-cart#add"
|
||||
{{ $attributes->class('bbk-add-to-cart') }}
|
||||
>
|
||||
@csrf
|
||||
|
||||
@if (! is_null($purchasable))
|
||||
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
|
||||
@endif
|
||||
|
||||
@if ($quantity !== false)
|
||||
<input type="hidden" name="quantity" value="{{ $quantity }}">
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
|
||||
</form>
|
||||
@@ -1,15 +0,0 @@
|
||||
{{--
|
||||
Read-only formatted address. $address is any Lunar address model
|
||||
(OrderAddress / CartAddress) — same column names on both.
|
||||
--}}
|
||||
@props(['address'])
|
||||
|
||||
<address class="bbk-address-lines">
|
||||
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
|
||||
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
|
||||
<span>{{ $address->line_one }}</span>
|
||||
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
|
||||
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
|
||||
@if ($address->state)<span>{{ $address->state }}</span>@endif
|
||||
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
|
||||
</address>
|
||||
@@ -1,29 +0,0 @@
|
||||
{{--
|
||||
<x-checkout::field name="billing_first_name" label="First name" required />
|
||||
|
||||
Generic labelled text input with old-input repopulation and validation
|
||||
error display — the module's own equivalent of a host x-ui.field, used
|
||||
instead of it per the module's independence rule. All styling is .bbk-field*
|
||||
(resources/css/checkout.css); no host classes.
|
||||
--}}
|
||||
@props([
|
||||
'name',
|
||||
'label',
|
||||
'type' => 'text',
|
||||
'value' => null,
|
||||
'required' => false,
|
||||
])
|
||||
|
||||
<div class="bbk-field">
|
||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
||||
<input
|
||||
type="{{ $type }}"
|
||||
name="{{ $name }}"
|
||||
id="bbk-{{ $name }}"
|
||||
value="{{ old($name, $value) }}"
|
||||
@if ($required) required @endif
|
||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
||||
>
|
||||
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
|
||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
||||
</div>
|
||||
@@ -1,52 +0,0 @@
|
||||
{{--
|
||||
The state/region + country pair for one address (billing or shipping).
|
||||
|
||||
Single-country store ($storeCountry set): region is a <select> of that
|
||||
country's Lunar states, submitting `->name` (table-rate-shipping resolves
|
||||
zones with State::whereName()), and country is a fixed hidden field + label.
|
||||
Otherwise: free-text region + full country <select>, as before.
|
||||
--}}
|
||||
@props([
|
||||
'prefix',
|
||||
'storeCountry' => null,
|
||||
'regions' => [],
|
||||
'countries' => [],
|
||||
'address' => null,
|
||||
])
|
||||
|
||||
<div class="bbk-field-row">
|
||||
@if ($storeCountry)
|
||||
<x-checkout::select
|
||||
:name="$prefix . '_state'"
|
||||
label="{{ __('checkout.page.state') }}"
|
||||
:options="$regions"
|
||||
value-field="name"
|
||||
translation-group="states"
|
||||
:value="$address?->state"
|
||||
placeholder="{{ __('checkout.page.state_placeholder') }}"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="bbk-field">
|
||||
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
|
||||
<p class="bbk-field-static">
|
||||
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
|
||||
? __("core::countries.{$storeCountry->name}")
|
||||
: $storeCountry->name }}
|
||||
</p>
|
||||
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
|
||||
</div>
|
||||
@else
|
||||
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
|
||||
|
||||
<x-checkout::select
|
||||
:name="$prefix . '_country_id'"
|
||||
label="{{ __('checkout.page.country') }}"
|
||||
:options="$countries"
|
||||
translation-group="countries"
|
||||
:value="$address?->country_id"
|
||||
placeholder="{{ __('checkout.page.country_placeholder') }}"
|
||||
required
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,62 +0,0 @@
|
||||
{{--
|
||||
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
|
||||
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
|
||||
|
||||
`options` is an iterable of models/objects; `label` is always read from
|
||||
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
|
||||
`name` for Lunar states — table-rate-shipping resolves those with
|
||||
State::whereName(), so the address must carry the exact name string).
|
||||
|
||||
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
|
||||
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
|
||||
boboko-core's lang/el/countries.php, lang/el/states.php) for the
|
||||
DISPLAYED label only — the submitted `value` is always the untranslated
|
||||
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
|
||||
off the original English name. Falls back to the raw name when no
|
||||
translation exists for the current locale (e.g. English, or a country
|
||||
outside the covered set).
|
||||
--}}
|
||||
@props([
|
||||
'name',
|
||||
'label',
|
||||
'options' => [],
|
||||
'value' => null,
|
||||
'placeholder' => null,
|
||||
'required' => false,
|
||||
'valueField' => 'id',
|
||||
'translationGroup' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$optionLabel = function ($option) use ($translationGroup) {
|
||||
if (! $translationGroup) {
|
||||
return $option->name;
|
||||
}
|
||||
|
||||
$key = "core::{$translationGroup}.{$option->name}";
|
||||
|
||||
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
|
||||
};
|
||||
@endphp
|
||||
|
||||
@php($selected = old($name, $value))
|
||||
|
||||
<div class="bbk-field">
|
||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
||||
<select
|
||||
name="{{ $name }}"
|
||||
id="bbk-{{ $name }}"
|
||||
@if ($required) required @endif
|
||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
||||
>
|
||||
@if ($placeholder)
|
||||
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
|
||||
@endif
|
||||
@foreach ($options as $option)
|
||||
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
|
||||
{{ $optionLabel($option) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
||||
</div>
|
||||
@@ -1,18 +0,0 @@
|
||||
@props([
|
||||
'name',
|
||||
'label',
|
||||
'value' => null,
|
||||
'required' => false,
|
||||
])
|
||||
|
||||
<div class="bbk-field">
|
||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
||||
<textarea
|
||||
name="{{ $name }}"
|
||||
id="bbk-{{ $name }}"
|
||||
rows="3"
|
||||
@if ($required) required @endif
|
||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
||||
>{{ old($name, $value) }}</textarea>
|
||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
||||
</div>
|
||||
@@ -1,132 +0,0 @@
|
||||
{{--
|
||||
Order confirmation. Reached only via a session flash of the placed order id
|
||||
(CheckoutController::confirmation) — not deep-linkable. $order is a
|
||||
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
|
||||
--}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="bbk-confirmation">
|
||||
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
|
||||
|
||||
<dl class="bbk-confirmation-meta">
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
|
||||
<dd>{{ $order->reference }}</dd>
|
||||
</div>
|
||||
|
||||
@if ($order->billingAddress?->contact_email)
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.email_label') }}</dt>
|
||||
<dd>{{ $order->billingAddress->contact_email }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($paymentMethodName)
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.payment_heading') }}</dt>
|
||||
<dd>{{ $paymentMethodName }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
|
||||
<dd>{{ $shippingLine->description }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
|
||||
|
||||
{{-- Guests: logging in with the order's email attaches it to an account
|
||||
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
|
||||
so it shows in their history. --}}
|
||||
@guest
|
||||
@if ($loginRoute = config('checkout.login_route'))
|
||||
<p class="bbk-checkout-note">
|
||||
{{ __('checkout.page.confirmation_login_hint') }}
|
||||
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@endguest
|
||||
|
||||
<div class="bbk-confirmation-body">
|
||||
<div class="bbk-confirmation-lines">
|
||||
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
|
||||
<div class="bbk-confirmation-line">
|
||||
<div class="bbk-cart-item-media">
|
||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bbk-confirmation-line-detail">
|
||||
<span class="bbk-confirmation-line-name">
|
||||
{{ $line->description }}
|
||||
<span class="bbk-confirmation-line-qty">× {{ $line->quantity }}</span>
|
||||
</span>
|
||||
|
||||
@if ($line->option)
|
||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
||||
@endif
|
||||
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
</div>
|
||||
|
||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="bbk-cart-summary">
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
||||
<span>{{ $order->sub_total?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($order->discount_total?->value > 0)
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
||||
<span>{{ __('checkout.cart.discount') }}</span>
|
||||
<span>−{{ $order->discount_total->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
||||
<span>{{ $order->shipping_total?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($order->tax_total?->value > 0)
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.tax') }}</span>
|
||||
<span>{{ $order->tax_total->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
||||
<span>{{ __('checkout.cart.total') }}</span>
|
||||
<span>{{ $order->total?->formatted() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bbk-confirmation-addresses">
|
||||
@if ($order->shippingAddress)
|
||||
<div class="bbk-confirmation-address">
|
||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
|
||||
<x-checkout::address-lines :address="$order->shippingAddress" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($order->billingAddress)
|
||||
<div class="bbk-confirmation-address">
|
||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
|
||||
<x-checkout::address-lines :address="$order->billingAddress" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,33 +0,0 @@
|
||||
{{--
|
||||
Slide-in cart drawer. Rendered once, globally, from the app layout
|
||||
(@include('checkout::drawer')). Structure only — all styling lives in
|
||||
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
|
||||
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
|
||||
--}}
|
||||
<div class="bbk-cart" data-controller="bbk-cart" hidden>
|
||||
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
|
||||
|
||||
<aside
|
||||
class="bbk-cart-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bbk-cart-heading"
|
||||
data-bbk-cart-target="panel"
|
||||
>
|
||||
<header class="bbk-cart-panel-header">
|
||||
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-cart-dismiss"
|
||||
data-action="bbk-cart#close"
|
||||
aria-label="{{ __('checkout.cart.close') }}"
|
||||
>×</button>
|
||||
</header>
|
||||
|
||||
@include('checkout::partials.cart-error')
|
||||
|
||||
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
|
||||
@include('checkout::partials.cart-body')
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -1,279 +0,0 @@
|
||||
{{--
|
||||
The checkout page. Two columns: left is contact + billing + shipping +
|
||||
shipping method, right is the order summary (the same cart-body partial the
|
||||
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
|
||||
checkout.css). Stops short of payment for this slice — see
|
||||
CheckoutController's class docblock.
|
||||
|
||||
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
|
||||
$countries come from CheckoutController::show().
|
||||
--}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="bbk-checkout-page">
|
||||
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
|
||||
|
||||
<div class="bbk-checkout">
|
||||
<div
|
||||
class="bbk-checkout-main"
|
||||
data-controller="bbk-checkout-form bbk-payment"
|
||||
data-action="input->bbk-checkout-form#scheduleSave"
|
||||
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
|
||||
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
|
||||
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
|
||||
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
|
||||
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
|
||||
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
|
||||
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
|
||||
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
|
||||
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
|
||||
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
|
||||
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
|
||||
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
|
||||
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
|
||||
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
|
||||
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
|
||||
>
|
||||
|
||||
{{-- Contact. Logged in: the order email is the account's (forced
|
||||
server-side in saveAddress()), so there's no field. Guests type
|
||||
their email, plus a login link when config('checkout.login_route')
|
||||
is set; the storefront's login page sends them back here and Lunar
|
||||
merges the guest cart into the account. --}}
|
||||
@php($loginRoute = config('checkout.login_route'))
|
||||
<section class="bbk-checkout-section">
|
||||
@auth
|
||||
<p class="bbk-checkout-logged-in">
|
||||
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
|
||||
</p>
|
||||
@else
|
||||
@if ($loginRoute)
|
||||
<p class="bbk-checkout-login-prompt">
|
||||
{{ __('checkout.page.login_prompt') }}
|
||||
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<x-checkout::field
|
||||
name="contact_email"
|
||||
label="{{ __('checkout.page.email_label') }}"
|
||||
type="email"
|
||||
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
|
||||
required
|
||||
form="bbk-address-form"
|
||||
/>
|
||||
@endauth
|
||||
|
||||
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
|
||||
required — direct marketing under ePrivacy (GR L. 3471/2006
|
||||
art. 11), so it needs an explicit opt-in and checkout can't be
|
||||
gated on it. Narrow scope by design (boboko-core's
|
||||
setRecoveryConsent) — a general newsletter opt-in, if wanted,
|
||||
is a separate checkbox. --}}
|
||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="recovery_consent"
|
||||
value="1"
|
||||
form="bbk-address-form"
|
||||
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
|
||||
>
|
||||
{{ __('checkout.page.recovery_consent') }}
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
|
||||
(this form, plus the contact email/consent which sit outside it but
|
||||
link via form="bbk-address-form") is debounced and POSTed as the whole
|
||||
form; the shipping-method radios are excluded in scheduleSave(). --}}
|
||||
<form
|
||||
id="bbk-address-form"
|
||||
method="POST"
|
||||
action="{{ route('checkout.address.save', app()->getLocale()) }}"
|
||||
data-bbk-checkout-form-target="form"
|
||||
>
|
||||
@csrf
|
||||
|
||||
{{-- Billing --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
|
||||
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
|
||||
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
|
||||
</div>
|
||||
|
||||
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
|
||||
(:has on the checkbox), saved/cleared by saveAddress(), and
|
||||
required at place-order. --}}
|
||||
<div class="bbk-invoice">
|
||||
<label class="bbk-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="wants_invoice"
|
||||
value="1"
|
||||
aria-controls="bbk-invoice-fields"
|
||||
@checked($wantsInvoice)
|
||||
>
|
||||
{{ __('checkout.page.wants_invoice') }}
|
||||
</label>
|
||||
|
||||
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
|
||||
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
|
||||
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
|
||||
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
|
||||
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
|
||||
</div>
|
||||
|
||||
<x-checkout::region-country
|
||||
prefix="billing"
|
||||
:store-country="$storeCountry"
|
||||
:regions="$regions"
|
||||
:countries="$countries"
|
||||
:address="$billingAddress"
|
||||
/>
|
||||
|
||||
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
|
||||
</section>
|
||||
|
||||
{{-- Shipping --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
|
||||
|
||||
<label class="bbk-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="same_as_billing"
|
||||
value="1"
|
||||
data-bbk-checkout-form-target="sameAsBilling"
|
||||
data-action="bbk-checkout-form#toggleSameAsBilling"
|
||||
@checked($shipToBilling)
|
||||
>
|
||||
{{ __('checkout.page.same_as_billing') }}
|
||||
</label>
|
||||
|
||||
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
|
||||
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
|
||||
</div>
|
||||
|
||||
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
|
||||
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
|
||||
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
|
||||
</div>
|
||||
|
||||
<x-checkout::region-country
|
||||
prefix="shipping"
|
||||
:store-country="$storeCountry"
|
||||
:regions="$regions"
|
||||
:countries="$countries"
|
||||
:address="$shippingAddress"
|
||||
/>
|
||||
|
||||
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
|
||||
</div>
|
||||
|
||||
<x-checkout::textarea
|
||||
name="shipping_delivery_instructions"
|
||||
label="{{ __('checkout.page.delivery_instructions') }}"
|
||||
:value="$shippingAddress?->delivery_instructions"
|
||||
/>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<p
|
||||
class="bbk-checkout-status"
|
||||
data-bbk-checkout-form-target="status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
></p>
|
||||
|
||||
{{-- Shipping method — resolves from the saved shipping address;
|
||||
re-rendered as a fragment by bbk-checkout-form after each
|
||||
autosave / option change. --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
|
||||
|
||||
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
|
||||
@include('checkout::partials.shipping-options', [
|
||||
'shippingAddress' => $shippingAddress,
|
||||
'shippingOptions' => $shippingOptions,
|
||||
])
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Payment --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
|
||||
|
||||
<div id="bbk-payment-methods">
|
||||
@include('checkout::partials.payment-methods', [
|
||||
'paymentMethods' => $paymentMethods,
|
||||
'cart' => $cart,
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
|
||||
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
|
||||
|
||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
||||
<input type="checkbox" data-bbk-payment-target="terms">
|
||||
{!! __('checkout.page.terms_accept', [
|
||||
'terms' => route('legal.terms', app()->getLocale()),
|
||||
'privacy' => route('legal.privacy', app()->getLocale()),
|
||||
]) !!}
|
||||
</label>
|
||||
|
||||
<p class="bbk-checkout-withdrawal">
|
||||
{!! __('checkout.page.withdrawal_notice', [
|
||||
'link' => route('legal.shipping-returns', app()->getLocale()),
|
||||
]) !!}
|
||||
</p>
|
||||
|
||||
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-checkout-continue"
|
||||
data-bbk-payment-target="submit"
|
||||
data-action="bbk-payment#placeOrder"
|
||||
@disabled($lines->isEmpty())
|
||||
>
|
||||
{{ __('checkout.page.place_order') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
|
||||
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
|
||||
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
|
||||
<span class="bbk-spinner" aria-hidden="true"></span>
|
||||
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<aside class="bbk-checkout-aside">
|
||||
<div class="bbk-checkout-summary" data-controller="bbk-cart">
|
||||
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
|
||||
@include('checkout::partials.cart-error')
|
||||
<div data-bbk-cart-target="body" aria-live="polite">
|
||||
@include('checkout::partials.cart-body')
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,121 +0,0 @@
|
||||
{{--
|
||||
Server-rendered cart contents. Rendered inline on first page load inside
|
||||
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
|
||||
bbk-cart-controller after every mutation. $cart / $lines come from the view
|
||||
composer in CheckoutModuleServiceProvider.
|
||||
|
||||
The data-bbk-cart-* attributes on the root are the module's read API for the
|
||||
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
|
||||
each swap and re-emits them on the `bbk-cart:updated` window event.
|
||||
--}}
|
||||
@php($count = $lines->sum('quantity'))
|
||||
|
||||
{{-- @dump($lines) --}}
|
||||
|
||||
<div
|
||||
class="bbk-cart-content"
|
||||
data-bbk-cart-count="{{ $count }}"
|
||||
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
|
||||
>
|
||||
@if ($lines->isEmpty())
|
||||
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
|
||||
@else
|
||||
<ul class="bbk-cart-items">
|
||||
@each('checkout::partials.cart-line', $lines, 'line')
|
||||
</ul>
|
||||
|
||||
<div class="bbk-cart-summary">
|
||||
<div class="bbk-cart-coupon">
|
||||
@if ($cart?->coupon_code)
|
||||
<div class="bbk-cart-coupon-applied">
|
||||
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
|
||||
data-action="submit->bbk-cart#submit"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bbk-cart-coupon-remove">
|
||||
{{ __('checkout.cart.coupon_remove') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<form
|
||||
class="bbk-cart-coupon-form"
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
|
||||
data-action="submit->bbk-cart#submit"
|
||||
>
|
||||
@csrf
|
||||
<label class="bbk-visually-hidden" for="bbk-coupon-code">
|
||||
{{ __('checkout.cart.coupon_label') }}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
id="bbk-coupon-code"
|
||||
class="bbk-cart-coupon-input"
|
||||
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="bbk-cart-coupon-submit">
|
||||
{{ __('checkout.cart.coupon_apply') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($couponError ?? false)
|
||||
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
||||
<span>{{ $cart?->subTotal?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($cart?->discountTotal?->value > 0)
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
||||
<span>{{ __('checkout.cart.discount') }}</span>
|
||||
<span>−{{ $cart->discountTotal->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Shipping + tax appear once the shopper has a shipping address
|
||||
(i.e. they're on the checkout page). In the drawer, where no
|
||||
address is set yet, only subtotal + total show. --}}
|
||||
@if ($cart?->shippingAddress)
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
||||
@if ($cart->shippingAddress->shipping_option)
|
||||
<span>{{ $cart->shippingTotal?->formatted() }}</span>
|
||||
@else
|
||||
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($cart?->taxTotal?->value > 0)
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.tax') }}</span>
|
||||
<span>{{ $cart->taxTotal->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Always shown — equals subtotal with nothing else applied,
|
||||
diverges as discount / shipping / tax come in. --}}
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
||||
<span>{{ __('checkout.cart.total') }}</span>
|
||||
<span>{{ $cart?->total?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
|
||||
{{ __('checkout.cart.checkout') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
{{--
|
||||
Shared error slot for any host wrapping cart-body in a bbk-cart controller
|
||||
instance (the drawer, and the checkout page's own order summary) —
|
||||
bbk-cart-controller.js#showError() writes into whichever one is present.
|
||||
Without this element in a given host, a rejected quantity update (e.g.
|
||||
over stock) still gets rejected server-side, but the shopper never sees
|
||||
why.
|
||||
--}}
|
||||
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
|
||||
@@ -1,103 +0,0 @@
|
||||
{{--
|
||||
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
|
||||
@each in cart-body). The two forms post through bbk-cart-controller
|
||||
(fetch + method spoofing) and the response re-renders cart-body.
|
||||
--}}
|
||||
@php
|
||||
$variant = $line->purchasable;
|
||||
$product = $variant?->product;
|
||||
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
|
||||
// The variant's own image (falls back to the product's thumbnail
|
||||
// internally — see ProductVariant::getThumbnail()) — the specific option
|
||||
// the shopper picked, not just the product in general.
|
||||
$thumb = $variant?->getThumbnailImage() ?: null;
|
||||
$variantLabel = $variant?->getOption();
|
||||
// Not routed through checkout::'s own locale-explicit convention — this
|
||||
// is a storefront route, so it follows the storefront's own (implicit
|
||||
// locale) call shape, same as App\Catalog\ProductCard. Carries the
|
||||
// variant id along so the product page can restore the same option the
|
||||
// shopper actually has in their cart, not just default to the first one
|
||||
// (see product-form-controller.js reading ?variant= on connect()).
|
||||
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
|
||||
@endphp
|
||||
|
||||
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
||||
<div class="bbk-cart-item-media">
|
||||
@if ($thumb)
|
||||
@if ($productUrl)
|
||||
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
|
||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
||||
</a>
|
||||
@else
|
||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bbk-cart-item-detail">
|
||||
@if ($productUrl)
|
||||
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
|
||||
@else
|
||||
<p class="bbk-cart-item-title">{{ $name }}</p>
|
||||
@endif
|
||||
@if ($variantLabel)
|
||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
||||
@endif
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
||||
|
||||
<form
|
||||
class="bbk-cart-qty"
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||
>
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-cart-qty-btn"
|
||||
data-action="bbk-cart#step"
|
||||
data-bbk-cart-dir-param="-1"
|
||||
aria-label="{{ __('checkout.cart.decrease') }}"
|
||||
>−</button>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
value="{{ $line->quantity }}"
|
||||
min="0"
|
||||
inputmode="numeric"
|
||||
class="bbk-cart-qty-input"
|
||||
data-action="change->bbk-cart#submit"
|
||||
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
|
||||
aria-label="{{ __('checkout.cart.quantity') }}"
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-cart-qty-btn"
|
||||
data-action="bbk-cart#step"
|
||||
data-bbk-cart-dir-param="1"
|
||||
aria-label="{{ __('checkout.cart.increase') }}"
|
||||
>+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bbk-cart-item-aside">
|
||||
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||
data-action="submit->bbk-cart#submit"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="bbk-cart-item-remove"
|
||||
aria-label="{{ __('checkout.cart.remove') }}"
|
||||
>×</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,49 +0,0 @@
|
||||
{{--
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
|
||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
||||
CartController::customFieldsMeta()). A file answer only carries a File id
|
||||
(Modules\Core\File\Models\File is the source of truth for name/mime/disk/
|
||||
path — never duplicated into meta), resolved here and linked through
|
||||
boboko-core's own signed download route (files.download), minted fresh on
|
||||
every render, with a thumbnail when the browser can display the format
|
||||
(HEIC can't be shown outside Safari, so it gets the name only).
|
||||
--}}
|
||||
@php
|
||||
$fields = $line->meta['custom_fields'] ?? [];
|
||||
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
@endphp
|
||||
|
||||
@if (! empty($fields))
|
||||
<dl class="bbk-line-fields">
|
||||
@foreach ($fields as $field)
|
||||
<div class="bbk-line-field">
|
||||
<dt>{{ $field['label'] }}</dt>
|
||||
<dd>
|
||||
@if ($field['type'] === 'file')
|
||||
@php
|
||||
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
|
||||
@endphp
|
||||
@if ($file)
|
||||
@php
|
||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
||||
'files.download',
|
||||
now()->addHours(2),
|
||||
['file' => $file->id],
|
||||
);
|
||||
@endphp
|
||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
||||
@if (in_array($file->mime, $previewable, true))
|
||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
||||
@endif
|
||||
<span>{{ $file->original_name }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
{{ $field['value'] }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
@@ -1,30 +0,0 @@
|
||||
{{--
|
||||
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
|
||||
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
|
||||
filtered to enabled + driver-resolves + isConfigured()). Selecting one
|
||||
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
|
||||
controller whether to mount the Stripe Element.
|
||||
|
||||
$paymentMethods, $cart come from the page / controller.
|
||||
--}}
|
||||
@php($selected = $cart?->meta['payment_method'] ?? null)
|
||||
|
||||
@if ($paymentMethods->isEmpty())
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
|
||||
@else
|
||||
<div class="bbk-checkout-payment-options">
|
||||
@foreach ($paymentMethods as $method)
|
||||
<label class="bbk-checkout-payment-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment_type"
|
||||
value="{{ $method->type }}"
|
||||
data-payment-driver="{{ $method->driver }}"
|
||||
@checked($selected === $method->type)
|
||||
data-action="change->bbk-payment#selectMethod"
|
||||
>
|
||||
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@@ -1,50 +0,0 @@
|
||||
{{--
|
||||
Shipping methods for the checkout page. Rendered inline by page.blade.php on
|
||||
load, and re-rendered as a fragment by CheckoutController after every
|
||||
address save / option change (bbk-checkout-form swaps it in). Radios
|
||||
autosave via bbk-checkout-form#selectShipping — no submit button. A single
|
||||
resolved option is auto-selected server-side and shown as a fixed line.
|
||||
|
||||
$shippingAddress, $shippingOptions come from the controller / page scope.
|
||||
--}}
|
||||
@php($selected = $shippingAddress?->shipping_option)
|
||||
|
||||
{{-- Rate resolution needs country (always Greece here) + postcode; until a
|
||||
postcode is saved there's nothing to quote against yet. --}}
|
||||
@if (! $shippingAddress?->postcode)
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
|
||||
@elseif ($shippingOptions->isEmpty())
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
|
||||
@elseif ($shippingOptions->count() === 1)
|
||||
@php($only = $shippingOptions->first())
|
||||
<div class="bbk-checkout-shipping-confirmed">
|
||||
<span class="bbk-checkout-shipping-option-detail">
|
||||
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
|
||||
@if ($only->description)
|
||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="bbk-checkout-shipping-options">
|
||||
@foreach ($shippingOptions as $option)
|
||||
<label class="bbk-checkout-shipping-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping_option"
|
||||
value="{{ $option->identifier }}"
|
||||
@checked($selected === $option->identifier)
|
||||
data-action="change->bbk-checkout-form#selectShipping"
|
||||
>
|
||||
<span class="bbk-checkout-shipping-option-detail">
|
||||
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
|
||||
@if ($option->description)
|
||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@@ -3,10 +3,10 @@
|
||||
])
|
||||
|
||||
<section {{ $attributes }}>
|
||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16 items-center">
|
||||
<h2
|
||||
class="text-h2 leading-tight"
|
||||
class="text-h1 leading-[1.3] max-w-xl text-center"
|
||||
aria-label="Γράψου στο newsletter μας"
|
||||
data-controller="appear"
|
||||
data-appear-visible-class="is-visible"
|
||||
@@ -17,13 +17,13 @@ class="text-h2 leading-tight"
|
||||
μας
|
||||
</h2>
|
||||
|
||||
<p class="max-w-md text-neutral-600">
|
||||
<p class="max-w-xl text-neutral-600 text-center">
|
||||
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
|
||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-xl items-center">
|
||||
@csrf
|
||||
<div class="relative">
|
||||
<div class="relative max-w-xs w-full">
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
@@ -44,7 +44,7 @@ class="absolute right-0 bottom-2 cursor-pointer"
|
||||
</div>
|
||||
|
||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
<span class="text-sm">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||
</x-ui.checkbox>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
:href="$product['href'] ?? '#'"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
:sold-out="$product['soldOut'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
// "absolute" via the class prop — Tailwind's generated stylesheet always
|
||||
// orders the "relative" utility after "absolute", so on a class clash
|
||||
// "relative" silently wins and the button never actually gets positioned.
|
||||
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline '.$variantClasses.' '.$sizeClasses);
|
||||
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline disabled:cursor-not-allowed disabled:opacity-50 '.$variantClasses.' '.$sizeClasses);
|
||||
|
||||
$attrs = $href
|
||||
? $attributes->merge(['href' => $href, 'class' => $class])
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
'href' => '#',
|
||||
'variantId' => null,
|
||||
'hasCustomFields' => false,
|
||||
// Shows the "sold" star and drops the quick add-to-cart/personalize button.
|
||||
'soldOut' => false,
|
||||
// Heart toggle at the top right of the image; needs productId.
|
||||
'productId' => null,
|
||||
'wishlist' => false,
|
||||
@@ -36,7 +38,9 @@ class="w-full h-auto block"
|
||||
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
|
||||
@endif
|
||||
|
||||
@if ($hasCustomFields)
|
||||
@if ($soldOut)
|
||||
<x-ui.sold-badge class="absolute top-2 right-2 {{ $wishlist && $productId ? 'mt-12' : '' }}" />
|
||||
@elseif ($hasCustomFields)
|
||||
{{-- Custom fields have to be filled in on the product page. --}}
|
||||
<x-ui.button
|
||||
:href="$href"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{-- Black "sold" star for out-of-stock products. Position it from the caller
|
||||
(e.g. class="absolute top-2 right-2"). --}}
|
||||
<div {{ $attributes->class('z-10 flex h-20 w-22 items-center justify-center text-black') }}>
|
||||
<svg viewBox="0 0 87 80" class="absolute inset-0 size-full" fill="currentColor" aria-hidden="true">
|
||||
<path d="M56.942,0l2.607,18.642,19.143-3.363L69.468,31.842,87,40,69.468,48.158l9.224,16.563L59.549,61.358,56.942,80,43.5,66.4,30.058,80,27.451,61.358,8.308,64.721l9.224-16.563L0,40l17.532-8.158L8.308,15.279l19.143,3.363L30.058,0,43.5,13.6Z"/>
|
||||
</svg>
|
||||
<span class="relative text-base italic font-black text-white uppercase">{{ __('storefront.product.sold') }}</span>
|
||||
</div>
|
||||
@@ -3,6 +3,11 @@
|
||||
@section('title', __('storefront.nav.contact'))
|
||||
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
|
||||
|
||||
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||
@push('scripts')
|
||||
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
|
||||
@@ -30,25 +35,35 @@
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<x-ui.field label="Το όνομά σου" for="contact-name" :error="$errors->first('name')">
|
||||
<x-ui.field label="Το όνομά σου *" for="contact-name" :error="$errors->first('name')">
|
||||
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
|
||||
:aria-invalid="$errors->has('name') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field label="Το email σου" for="contact-email" :error="$errors->first('email')">
|
||||
<x-ui.field label="Το email σου *" for="contact-email" :error="$errors->first('email')">
|
||||
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
|
||||
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
|
||||
</x-ui.field>
|
||||
</div>
|
||||
|
||||
<x-ui.field label="Το μήνυμά σου" for="contact-message" :error="$errors->first('message')">
|
||||
<x-ui.field label="Το μήνυμά σου *" for="contact-message" :error="$errors->first('message')">
|
||||
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
|
||||
:aria-invalid="$errors->has('message') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
|
||||
</x-ui.field>
|
||||
|
||||
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha (see auth/login). --}}
|
||||
<x-ui.field for="contact-captcha" :error="$errors->first('h-captcha-response')">
|
||||
<div
|
||||
id="contact-captcha"
|
||||
class="h-captcha min-h-[78px]"
|
||||
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||
@if ($errors->has('h-captcha-response')) aria-describedby="contact-captcha-error" @endif
|
||||
></div>
|
||||
</x-ui.field>
|
||||
|
||||
@if (session('contact_error'))
|
||||
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
||||
@endif
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
@media only screen and (max-width: 480px) {
|
||||
.email-container { width: 100% !important; max-width: 100% !important; }
|
||||
.email-header { padding: 24px 20px 12px !important; }
|
||||
.email-gutter { padding: 0 !important; }
|
||||
.email-card { padding: 28px 20px !important; }
|
||||
.email-footer { padding: 0 20px 32px !important; }
|
||||
}
|
||||
@@ -69,7 +70,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 24px;">
|
||||
<td class="email-gutter" style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="background-color:#ffffff;border-radius:0;">
|
||||
<tr>
|
||||
<td class="email-card" style="padding:40px 32px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#000000;text-align:left;">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{--
|
||||
@include('emails.partials.address', ['address' => $order->shippingAddress])
|
||||
|
||||
An order address, read-only, same fields as the account order page.
|
||||
--}}
|
||||
<p style="margin:0;font-size:15px;line-height:1.6;">
|
||||
{{ trim($address->first_name.' '.$address->last_name) }}<br>
|
||||
@if ($address->company_name){{ $address->company_name }}<br>@endif
|
||||
@if ($address->tax_identifier)ΑΦΜ: {{ $address->tax_identifier }}<br>@endif
|
||||
{{ $address->line_one }}<br>
|
||||
@if ($address->line_two){{ $address->line_two }}<br>@endif
|
||||
{{ trim($address->postcode.' '.$address->city) }}<br>
|
||||
@if ($address->state){{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}<br>@endif
|
||||
@if ($address->contact_phone){{ $address->contact_phone }}@endif
|
||||
</p>
|
||||
@@ -0,0 +1,43 @@
|
||||
{{--
|
||||
@include('emails.partials.button', ['url' => ..., 'label' => ...])
|
||||
|
||||
The site's primary button (x-ui.button, .btn-primary): neutral-200 fill,
|
||||
black border, bold italic uppercase, black offset shadow. The shadow is
|
||||
built from table cells, not box-shadow (Gmail and Outlook drop it): a
|
||||
black column on the right starting $offset down, and a black row at the
|
||||
bottom starting $offset in. The button cell spans the first two rows, so
|
||||
the black column always matches its height.
|
||||
|
||||
Uppercased here with the tonos removed, since emails can't run the site's
|
||||
strip-accents script and CSS uppercase would keep the accents.
|
||||
--}}
|
||||
@php
|
||||
$offset = 8;
|
||||
$label = strtr(mb_strtoupper($label), [
|
||||
'Ά' => 'Α', 'Έ' => 'Ε', 'Ή' => 'Η', 'Ί' => 'Ι', 'Ό' => 'Ο', 'Ύ' => 'Υ', 'Ώ' => 'Ω',
|
||||
'ΐ' => 'Ϊ', 'ΰ' => 'Ϋ',
|
||||
]);
|
||||
$spacer = "font-size:1px;line-height:1px;mso-line-height-rule:exactly;";
|
||||
@endphp
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0;border-collapse:separate;">
|
||||
<tr>
|
||||
<td rowspan="2" bgcolor="#e5e5e5" style="background-color:#e5e5e5;border:1px solid #000000;border-radius:0;mso-padding-alt:14px 28px;">
|
||||
<a href="{{ $url }}" target="_blank" style="display:inline-block;padding:14px 28px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:20px;mso-line-height-rule:exactly;font-weight:700;font-style:italic;color:#000000;text-decoration:none;">{{ $label }}</a>
|
||||
</td>
|
||||
<td width="{{ $offset }}" height="{{ $offset }}" style="width:{{ $offset }}px;height:{{ $offset }}px;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="{{ $offset }}" style="height:{{ $offset }}px;padding:0 0 0 {{ $offset }}px;{{ $spacer }}">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td height="{{ $offset }}" bgcolor="#000000" style="height:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td width="{{ $offset }}" height="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;height:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,2 @@
|
||||
{{-- @include('emails.partials.section-heading', ['text' => ...]) --}}
|
||||
<h2 style="margin:0 0 12px;padding:0 0 8px;border-bottom:1px solid #000000;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:18px;line-height:1.3;font-weight:800;color:#000000;">{{ $text }}</h2>
|
||||
@@ -43,7 +43,7 @@ class="absolute left-4 lg:left-8 top-1/2 z-10 -translate-y-1/2 hover:-translate-
|
||||
class="{{ $loop->first ? '' : 'hidden' }} group h-full flex flex-col justify-center pb-8 relative"
|
||||
>
|
||||
<div class="relative flex items-center justify-center">
|
||||
<a href="{{ $product['href'] }}" class="block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
|
||||
<a href="{{ $product['href'] }}" class="relative block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
|
||||
@if($product['image'])
|
||||
<img
|
||||
src="{{ $product['image'] }}"
|
||||
@@ -58,9 +58,16 @@ class="w-full h-full object-cover"
|
||||
@else
|
||||
<span class="text-neutral-500 text-sm">{{ __('storefront.product.no_image') }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Inside the link so it sits on the image's corner, not the full-width slide's. --}}
|
||||
@if ($product['soldOut'] ?? false)
|
||||
<x-ui.sold-badge class="absolute top-2 right-2" />
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@if ($product['hasCustomFields'] ?? false)
|
||||
@if ($product['soldOut'] ?? false)
|
||||
{{-- Sold: no quick add-to-cart/personalize, same as x-ui.product-card. --}}
|
||||
@elseif ($product['hasCustomFields'] ?? false)
|
||||
<x-ui.button
|
||||
:href="$product['href']"
|
||||
size="md"
|
||||
@@ -173,6 +180,7 @@ class="max-w-xs"
|
||||
:href="$product['href']"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
:sold-out="$product['soldOut'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -187,7 +195,5 @@ class="max-w-xs"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-20">
|
||||
<x-newsletter-split />
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -196,8 +196,13 @@ class="underline-slide font-semibold whitespace-nowrap"
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@php
|
||||
// Same initial pick as product-form-controller.js's connect()
|
||||
// (?variant= or the first), so the button doesn't flip on load.
|
||||
$initialVariant = collect($variantsData)->firstWhere('id', (int) request('variant')) ?? ($variantsData[0] ?? null);
|
||||
@endphp
|
||||
<x-checkout::add-to-cart
|
||||
:purchasable="$variantsData[0]['id'] ?? null"
|
||||
:purchasable="$initialVariant['id'] ?? null"
|
||||
:quantity="false"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
@@ -207,7 +212,7 @@ class="flex flex-col gap-6"
|
||||
|
||||
<div class="flex items-stretch gap-10">
|
||||
<x-ui.quantity name="quantity" />
|
||||
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||
<x-ui.button type="submit" class="flex-1" data-product-form-target="submit" :disabled="! ($initialVariant['inStock'] ?? true)">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||
</div>
|
||||
</x-checkout::add-to-cart>
|
||||
|
||||
@@ -270,7 +275,7 @@ class="flex flex-col gap-6"
|
||||
@if(!empty($product['recommendations']))
|
||||
<x-product-grid
|
||||
class="mt-20"
|
||||
title="Σχετικά προϊόντα"
|
||||
:title="__('storefront.product.related')"
|
||||
:products="collect($product['recommendations'])->map(fn (array $recommendation) => [
|
||||
'name' => $recommendation['name'],
|
||||
'price' => $recommendation['price'],
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
|
||||
@section('title', "Λάβαμε την πληρωμή σου")
|
||||
|
||||
@section('preheader', "Λάβαμε την πληρωμή {$amount} για την παραγγελία #{$reference}.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Λάβαμε την πληρωμή σου
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Λάβαμε την πληρωμή σου <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
|
||||
@section('title', "Η παραγγελία σου ολοκληρώθηκε")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} ολοκληρώθηκε.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου ολοκληρώθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> ολοκληρώθηκε. Ευχαριστούμε που ψώνισες από εμάς!</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
|
||||
@section('title', "Η παραγγελία σου παραδόθηκε")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} παραδόθηκε.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου παραδόθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> παραδόθηκε. Ελπίζουμε να σου αρέσει!</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
|
||||
@section('title', "Η παραγγελία σου στάλθηκε")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} είναι καθ’ οδόν.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου στάλθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> είναι καθ’ οδόν. Θα τη λάβεις σύντομα.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
|
||||
@section('title', "Η παραγγελία σου είναι έτοιμη για παραλαβή")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} σε περιμένει στο κατάστημα.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου είναι έτοιμη για παραλαβή
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> είναι έτοιμη και σε περιμένει στο κατάστημα.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
+169
-13
@@ -1,19 +1,175 @@
|
||||
<p>Hi,</p>
|
||||
{{--
|
||||
Order confirmation (boboko-core's OrderPlacedNotification).
|
||||
|
||||
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
|
||||
Core only passes $reference, $total and $lines, so the order itself is
|
||||
reached through a line (every OrderLine belongs to it). Interim until core
|
||||
passes the Order to this view.
|
||||
|
||||
<ul>
|
||||
@foreach ($lines as $line)
|
||||
<li>
|
||||
{{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }}
|
||||
{{-- Text answers only — photo answers are deliberately left out of emails. --}}
|
||||
Sent from the queue with no locale of its own, so it always renders in the
|
||||
app default (Greek). Copy is Greek on purpose, like the other core emails.
|
||||
--}}
|
||||
@extends('emails.layout')
|
||||
|
||||
@php
|
||||
$order = $lines->first()->order;
|
||||
$productLines = $lines->where('type', '!=', 'shipping');
|
||||
$shippingLine = $lines->firstWhere('type', 'shipping');
|
||||
|
||||
// By type, like the account order page: the method may since have been
|
||||
// disabled, but the order still shows what was used.
|
||||
$paymentMethodName = \Modules\Core\Payment\Models\PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
// Lunar doesn't copy the coupon onto the order; the cart row keeps it.
|
||||
$couponCode = $order->cart?->coupon_code;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
$comments = $order->shippingAddress?->delivery_instructions;
|
||||
|
||||
$muted = 'color:#5c5c5c;font-size:14px;';
|
||||
$font = "font-family:'Manrope',Arial,Helvetica,sans-serif;";
|
||||
@endphp
|
||||
|
||||
@section('title', "Η παραγγελία σου #{$reference} επιβεβαιώθηκε")
|
||||
|
||||
@section('preheader', "Ευχαριστούμε! Λάβαμε την παραγγελία σου #{$reference}, σύνολο {$total}.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;{{ $font }}font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Ευχαριστούμε για την παραγγελία σου!
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 24px;">
|
||||
Λάβαμε την παραγγελία σου <strong>#{{ $reference }}</strong>
|
||||
στις {{ $order->placed_at?->format('d/m/Y') }}.
|
||||
Θα σε ενημερώσουμε με email μόλις σταλεί.
|
||||
</p>
|
||||
|
||||
<div style="margin:0 0 40px;">
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders.show', ['locale' => 'el', 'orderId' => $order->id]),
|
||||
'label' => 'Δες την παραγγελία σου',
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Items --}}
|
||||
@include('emails.partials.section-heading', ['text' => 'Προϊόντα'])
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 8px;">
|
||||
@foreach ($productLines as $line)
|
||||
<tr>
|
||||
<td width="64" valign="top" style="width:64px;padding:12px 12px 12px 0;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
|
||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||
<img src="{{ $thumb }}" width="64" height="64" alt="{{ $line->description }}" style="display:block;width:64px;height:64px;object-fit:cover;background-color:#e5e5e5;border:0;{{ $font }}font-size:11px;color:#5c5c5c;">
|
||||
@else
|
||||
<div style="width:64px;height:64px;background-color:#e5e5e5;"></div>
|
||||
@endif
|
||||
</td>
|
||||
<td valign="top" style="padding:12px 12px 12px 0;{{ $font }}font-size:15px;line-height:1.4;color:#000000;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
|
||||
<strong>{{ $line->description }}</strong>
|
||||
|
||||
@if ($line->option)
|
||||
<br><span style="{{ $muted }}">{{ $line->option }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Text answers only: photo answers are deliberately left out of emails. --}}
|
||||
@foreach ($line->meta['custom_fields'] ?? [] as $field)
|
||||
@if ($field['type'] !== 'file')
|
||||
<br>{{ $field['label'] }}: {!! nl2br(e($field['value'])) !!}
|
||||
@if ($field['type'] !== 'file' && filled($field['value'] ?? null))
|
||||
<br><span style="{{ $muted }}">{{ $field['label'] }}: {!! nl2br(e($field['value'])) !!}</span>
|
||||
@endif
|
||||
@endforeach
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<p>Total: <strong>{{ $total }}</strong></p>
|
||||
<br><span style="{{ $muted }}">
|
||||
Ποσότητα: {{ $line->quantity }}@if ($line->quantity > 1) × {{ $line->unit_price->formatted() }}@endif
|
||||
</span>
|
||||
</td>
|
||||
<td valign="top" align="right" style="padding:12px 0;white-space:nowrap;{{ $font }}font-size:15px;line-height:1.4;font-weight:700;color:#000000;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
|
||||
{{ $line->sub_total?->formatted() }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
{{-- Totals --}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 40px;border-top:1px solid #000000;">
|
||||
<tr>
|
||||
<td style="padding:12px 0 4px;{{ $font }}font-size:15px;color:#000000;">Υποσύνολο</td>
|
||||
<td align="right" style="padding:12px 0 4px;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">{{ $order->sub_total?->formatted() }}</td>
|
||||
</tr>
|
||||
|
||||
@if ($order->discount_total?->value > 0)
|
||||
<tr>
|
||||
<td style="padding:4px 0;{{ $font }}font-size:15px;color:#000000;">
|
||||
Έκπτωση@if ($couponCode) <span style="{{ $muted }}">({{ $couponCode }})</span>@endif
|
||||
</td>
|
||||
<td align="right" style="padding:4px 0;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">−{{ $order->discount_total->formatted() }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
|
||||
<tr>
|
||||
<td style="padding:4px 0;{{ $font }}font-size:15px;color:#000000;">
|
||||
Μεταφορικά@if ($shippingLine) <span style="{{ $muted }}">({{ $shippingLine->description }})</span>@endif
|
||||
</td>
|
||||
<td align="right" style="padding:4px 0;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">
|
||||
{{ $order->shipping_total?->value > 0 ? $order->shipping_total->formatted() : 'Δωρεάν' }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:12px 0 0;border-top:1px solid #000000;{{ $font }}font-size:20px;line-height:1.3;font-weight:800;color:#000000;">Σύνολο</td>
|
||||
<td align="right" style="padding:12px 0 0;border-top:1px solid #000000;white-space:nowrap;{{ $font }}font-size:20px;line-height:1.3;font-weight:800;color:#000000;">{{ $total }}</td>
|
||||
</tr>
|
||||
|
||||
{{-- Prices include VAT (lunar.pricing.stored_inclusive_of_tax), so this is informational. --}}
|
||||
@if ($order->tax_total?->value > 0)
|
||||
<tr>
|
||||
<td colspan="2" align="right" style="padding:4px 0 0;{{ $font }}{{ $muted }}">Περιλαμβάνεται Φ.Π.Α. {{ $order->tax_total->formatted() }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
|
||||
{{-- Payment + shipping --}}
|
||||
@include('emails.partials.section-heading', ['text' => 'Πληρωμή και αποστολή'])
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 40px;">
|
||||
@foreach (array_filter([
|
||||
'Τρόπος πληρωμής' => $paymentMethodName,
|
||||
'Τρόπος αποστολής' => $shippingLine?->description,
|
||||
'Παραστατικό' => ($order->meta['wants_invoice'] ?? false) ? 'Τιμολόγιο' : 'Απόδειξη',
|
||||
'Email' => $email,
|
||||
]) as $label => $value)
|
||||
<tr>
|
||||
<td valign="top" style="padding:4px 12px 4px 0;{{ $font }}{{ $muted }}line-height:1.5;">{{ $label }}</td>
|
||||
<td valign="top" align="right" style="padding:4px 0;{{ $font }}font-size:15px;line-height:1.5;color:#000000;word-break:break-word;">{{ $value }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
{{-- Addresses: stacked, never side by side, so they read well on a phone. --}}
|
||||
@if ($order->shippingAddress)
|
||||
@include('emails.partials.section-heading', ['text' => 'Διεύθυνση αποστολής'])
|
||||
<div style="margin:0 0 {{ $comments ? '16px' : '40px' }};">
|
||||
@include('emails.partials.address', ['address' => $order->shippingAddress])
|
||||
</div>
|
||||
|
||||
@if ($comments)
|
||||
<p style="margin:0 0 40px;font-size:15px;line-height:1.6;">
|
||||
<span style="{{ $muted }}">Σχόλια</span><br>
|
||||
{!! nl2br(e($comments)) !!}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($order->billingAddress)
|
||||
@include('emails.partials.section-heading', ['text' => 'Στοιχεία χρέωσης'])
|
||||
@include('emails.partials.address', ['address' => $order->billingAddress])
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
|
||||
@section('title', "Έγινε επιστροφή χρημάτων")
|
||||
|
||||
@section('preheader', "Επιστρέψαμε {$amount} για την παραγγελία #{$reference}.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Έγινε επιστροφή χρημάτων
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Σου επιστρέψαμε <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
<p>Hi,</p>
|
||||
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||
@extends('emails.layout')
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
|
||||
@php
|
||||
// Core passes Lunar's English label; map it back to the status key so the
|
||||
// shared <x-order-status> can show the translated one. Interim, until core
|
||||
// passes the order (or its status) to this view.
|
||||
$status = collect(config('lunar.orders.statuses'))
|
||||
->search(fn (array $config) => $config['label'] === $statusLabel) ?: $statusLabel;
|
||||
@endphp
|
||||
|
||||
@section('title', "Ενημέρωση παραγγελίας")
|
||||
|
||||
@section('preheader', "Η παραγγελία σου #{$reference} ενημερώθηκε.")
|
||||
|
||||
@section('content')
|
||||
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||
Η παραγγελία σου ενημερώθηκε
|
||||
</h1>
|
||||
|
||||
<p style="margin:0 0 32px;">Η κατάσταση της παραγγελίας σου <strong>#{{ $reference }}</strong> είναι τώρα: <strong><x-order-status :status="$status" /></strong></p>
|
||||
|
||||
@include('emails.partials.button', [
|
||||
'url' => route('account.orders', ['locale' => 'el']),
|
||||
'label' => 'Οι παραγγελίες μου',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p style="margin:0;">
|
||||
Έχεις ερώτηση για την παραγγελία σου;
|
||||
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||
</p>
|
||||
@endsection
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
:href="$product['href']"
|
||||
:variant-id="$product['variantId']"
|
||||
:has-custom-fields="$product['hasCustomFields']"
|
||||
:sold-out="$product['soldOut']"
|
||||
:product-id="$product['id']"
|
||||
:wishlist="true"
|
||||
/>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Checkout\CartController;
|
||||
use App\Http\Controllers\Checkout\CheckoutController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
* Cart + checkout module routes.
|
||||
*
|
||||
* The {locale} prefix and `locale` middleware mirror the 3dealer host's
|
||||
* localization convention (CLAUDE.md » Localization). When this module moves to
|
||||
* boboko-core the route definitions travel with it, but each host wraps them in
|
||||
* whatever prefix/middleware it uses. Every action still declares $locale as its
|
||||
* first parameter regardless, per the ControllerDispatcher positional-args
|
||||
* gotcha documented in CLAUDE.md.
|
||||
*
|
||||
* Loaded from CheckoutModuleServiceProvider inside the `web` middleware group.
|
||||
*/
|
||||
Route::prefix('{locale}')
|
||||
->middleware('locale')
|
||||
->group(function () {
|
||||
// No standalone cart page — the drawer (checkout::drawer) is the cart.
|
||||
Route::get('checkout', [CheckoutController::class, 'show'])
|
||||
->name('checkout.show');
|
||||
|
||||
Route::post('checkout/address', [CheckoutController::class, 'saveAddress'])
|
||||
->name('checkout.address.save');
|
||||
|
||||
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
|
||||
->name('checkout.shipping-option.select');
|
||||
|
||||
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
|
||||
->name('checkout.payment-method.select');
|
||||
|
||||
Route::post('checkout/place-order', [CheckoutController::class, 'placeOrder'])
|
||||
->name('checkout.place-order');
|
||||
|
||||
Route::get('checkout/order-status', [CheckoutController::class, 'orderStatus'])
|
||||
->name('checkout.order-status');
|
||||
|
||||
Route::get('checkout/confirmation', [CheckoutController::class, 'confirmation'])
|
||||
->name('checkout.confirmation');
|
||||
|
||||
Route::post('cart/lines', [CartController::class, 'add'])
|
||||
->name('checkout.cart.add');
|
||||
|
||||
Route::patch('cart/lines/{line}', [CartController::class, 'updateLine'])
|
||||
->whereNumber('line')
|
||||
->name('checkout.cart.update');
|
||||
|
||||
Route::delete('cart/lines/{line}', [CartController::class, 'remove'])
|
||||
->whereNumber('line')
|
||||
->name('checkout.cart.remove');
|
||||
|
||||
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
|
||||
->name('checkout.cart.coupon.apply');
|
||||
|
||||
Route::delete('cart/coupon', [CartController::class, 'removeCoupon'])
|
||||
->name('checkout.cart.coupon.remove');
|
||||
});
|
||||
Reference in New Issue
Block a user