terms acceptance during login, checkout connection to user account and login process

This commit is contained in:
elvira
2026-09-24 19:08:13 +03:00
parent cf3681260b
commit 94803bfb67
14 changed files with 448 additions and 141 deletions
@@ -10,6 +10,8 @@
use Illuminate\View\View;
use Lunar\Models\Country;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Privacy\Services\PrivacyService;
@@ -68,6 +70,7 @@ public function update(string $locale, Request $request): RedirectResponse
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
],
'contact_phone' => ['nullable', 'string', 'max:30'],
'recovery_consent' => ['boolean'],
]);
$invoice = $request->boolean('invoice');
@@ -100,9 +103,37 @@ public function update(string $locale, Request $request): RedirectResponse
: $this->account->createAddress($user, $addressData);
}
$this->updateRecoveryConsent($customer, $request->boolean('recovery_consent'));
return redirect()->route('account')->with('status', __('storefront.account.saved'));
}
/**
* "Email me a reminder if I don't finish my order", as a standing choice.
* Stored on the customer in the same meta shape the checkout writes (see
* CheckoutController::rememberRecoveryConsent()), and applied to the
* current cart too, so opting out stops reminders for it right away.
*/
private function updateRecoveryConsent($customer, bool $consent): void
{
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
// Only an existing cart; never create one just to record this.
$cart = app(CartService::class)->current();
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
app(CheckoutService::class)->setRecoveryConsent($consent);
}
}
/**
* Self-service deletion: opens core's 30-day grace-period erasure request
* (which blocks the login right away) and logs out. Logging back in within
+25 -1
View File
@@ -25,8 +25,19 @@
*/
class LoginController extends Controller
{
public function create(string $locale): View
/**
* `?redirect=/el/checkout` (e.g. from the checkout's login tab) becomes the
* intended URL that verify() returns to. Only a same-site path is accepted:
* no scheme, no protocol-relative `//host`, so it can't redirect off-site.
*/
public function create(string $locale, Request $request): View
{
$redirect = (string) $request->query('redirect', '');
if (preg_match('#^/(?![/\\\\])#', $redirect)) {
$request->session()->put('url.intended', url($redirect));
}
return view('auth.login');
}
@@ -38,6 +49,9 @@ public function send(string $locale, Request $request, UserOtpService $otp): Red
$email = Str::lower(trim($validated['email']));
$userModel = config('auth.providers.users.model');
$isNewAccount = ! $userModel::where('email', $email)->exists();
try {
$otp->generateAndSend($email);
} catch (OtpThrottledException) {
@@ -46,6 +60,16 @@ public function send(string $locale, Request $request, UserOtpService $otp): Red
]);
}
// generateAndSend() just created the account: record that it happened
// under the login page's terms notice, and which versions it showed.
if ($isNewAccount) {
$userModel::where('email', $email)->update([
'terms_accepted_at' => now(),
'terms_version' => config('legal.terms_version'),
'privacy_policy_version' => config('legal.privacy_policy_version'),
]);
}
$request->session()->put('login.email', $email);
return redirect()->route('login.code');
@@ -7,6 +7,7 @@
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;
@@ -22,6 +23,7 @@
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;
@@ -38,7 +40,11 @@
* lenient — nothing is rejected mid-typing; required-field enforcement is the
* job of the (not-yet-built) "Continue to payment" gate.
*
* Guest-only for now — the Contact section's login tab is UI only.
* 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
@@ -51,6 +57,7 @@ class CheckoutController extends Controller
public function __construct(
private readonly CartService $cart,
private readonly CheckoutService $checkout,
private readonly CustomerAccountService $account,
) {}
public function show(string $locale): View
@@ -61,10 +68,24 @@ public function show(string $locale): View
$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) {
// Nothing recreates the address row in this path — its own current
// value is the correct "previous" to carry forward if still valid.
$shippingOptions = $this->syncShipping($cart, $cart->shippingAddress->shipping_option);
$shippingOptions = $this->syncShipping($cart, $previousOption);
// Cart's CachesProperties::refresh() explicitly nulls total/
// subTotal/shippingTotal/etc. back to their defaults — every
@@ -96,6 +117,7 @@ public function show(string $locale): View
'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()
@@ -147,7 +169,6 @@ public function saveAddress(string $locale, Request $request): JsonResponse
'shipping_first_name' => ['nullable', 'string', 'max:255'],
'shipping_last_name' => ['nullable', 'string', 'max:255'],
'shipping_company_name' => ['nullable', 'string', 'max:255'],
'shipping_line_one' => ['nullable', 'string', 'max:255'],
'shipping_city' => ['nullable', 'string', 'max:255'],
'shipping_state' => $stateRule,
@@ -160,14 +181,24 @@ public function saveAddress(string $locale, Request $request): JsonResponse
$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' => $data['billing_company_name'] ?? null,
'tax_identifier' => $data['billing_tax_identifier'] ?? 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,
@@ -178,11 +209,13 @@ public function saveAddress(string $locale, Request $request): JsonResponse
];
$shipping = $sameAsBilling
? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null]
? [
...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,
'company_name' => $data['shipping_company_name'] ?? null,
'line_one' => $data['shipping_line_one'] ?? null,
'city' => $data['shipping_city'] ?? null,
'state' => $data['shipping_state'] ?? null,
@@ -196,7 +229,11 @@ public function saveAddress(string $locale, Request $request): JsonResponse
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
$cart->meta = [...($cart->meta?->toArray() ?? []), 'ship_to_billing' => $sameAsBilling];
$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 +
@@ -204,6 +241,10 @@ public function saveAddress(string $locale, Request $request): JsonResponse
// Deliberately its own scope, not merged with any future newsletter opt-in.
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
if (Auth::check()) {
$this->rememberRecoveryConsent($request->boolean('recovery_consent'));
}
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
$rateChanged = $rateKeyAfter != $rateKeyBefore;
@@ -310,6 +351,15 @@ public function placeOrder(string $locale, Request $request): JsonResponse
], 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
@@ -528,6 +578,116 @@ private function fragments(?Cart $cart, ?Collection $options, array $errors = []
]);
}
/**
* Logged-in shopper: fills any BLANK cart address field from the account
* (name, saved default address, phone, email), on every checkout load, so
* an account filled in after checkout started still shows up. Never
* overwrites anything already in the cart.
*
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
* (meta.account_prefilled): someone who then clears them or unticks the
* box for this order shouldn't get them back on the next reload.
*
* Writes only when something actually changes, so a normal reload costs
* nothing extra.
*/
private function prefillFromAccount(Cart $cart): Cart
{
$user = Auth::user();
$customer = $this->account->customer($user);
$addresses = collect($this->account->addresses($user));
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
$firstPass = ! data_get($cart, 'meta.account_prefilled');
$fromAccount = array_filter([
'first_name' => $customer?->first_name ?: $saved?->first_name,
'last_name' => $customer?->last_name ?: $saved?->last_name,
'line_one' => $saved?->line_one,
'city' => $saved?->city,
'state' => $saved?->state,
'postcode' => $saved?->postcode,
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
'contact_email' => $user->email,
'contact_phone' => $saved?->contact_phone,
], 'filled');
$invoice = $firstPass
? array_filter([
'company_name' => $customer?->company_name,
'tax_identifier' => $customer?->tax_identifier,
], 'filled')
: [];
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
$fillBlanks = function (?array $current, array $values) {
$current ??= [];
foreach ($values as $key => $value) {
if (blank($current[$key] ?? null)) {
$current[$key] = $value;
}
}
return $current;
};
$billingBefore = $cart->billingAddress?->only($fields);
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
$shipping = $shipToBilling
? [
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
]
: $fillBlanks($shippingBefore, $fromAccount);
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
}
if ($firstPass) {
$cart->meta = [
...($cart->meta?->toArray() ?? []),
'account_prefilled' => true,
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
];
$cart->save();
}
return $cart;
}
/**
* The shopper's latest reminder choice, kept on their customer record
* (meta, same shape CheckoutService::setRecoveryConsent() writes on the
* cart) so their next checkout starts from it. The storefront's account
* page reads/writes the same keys. Candidate for a boboko-core method.
*/
private function rememberRecoveryConsent(bool $consent): void
{
$customer = $this->account->customer(Auth::user());
if (! $customer || (bool) data_get($customer, 'meta.recovery_consent') === $consent) {
return;
}
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
private function storeCountry(): ?Country
{
if (self::STORE_COUNTRY_ISO3 === null) {
@@ -0,0 +1,57 @@
<?php
namespace App\Listeners;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Customer\Services\CustomerAccountService;
/**
* When a logged-in shopper places an order and their account has no saved
* address yet, the order's shipping address (and their name, if the profile
* has none) becomes the account's. Never overwrites anything already saved.
*
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
* webhook, where nobody is logged in. Picked up by listener discovery.
*/
class SaveAddressFromFirstOrder
{
public function __construct(
private readonly CustomerAccountService $account,
) {}
public function handle(OrderPlaced $event): void
{
$order = $event->order;
$user = $order->user;
$shipping = $order->shippingAddress;
if (! $user || ! $shipping || ! $shipping->line_one) {
return;
}
$customer = $this->account->customer($user);
if (! $customer) {
return;
}
if (! $customer->first_name && ! $customer->last_name) {
$this->account->updateProfile($user, [
'first_name' => $shipping->first_name,
'last_name' => $shipping->last_name,
]);
}
if (collect($this->account->addresses($user))->isNotEmpty()) {
return;
}
$this->account->createAddress($user, [
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
'contact_email' => $user->email,
'shipping_default' => true,
'billing_default' => true,
]);
}
}
+1
View File
@@ -46,6 +46,7 @@ protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'terms_accepted_at' => 'datetime',
];
}
}
Generated
+7 -7
View File
@@ -935,16 +935,16 @@
},
{
"name": "composer/semver",
"version": "3.4.4",
"version": "3.5.0",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
"reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"shasum": ""
},
"require": {
@@ -986,7 +986,7 @@
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"description": "Version comparison library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
@@ -996,7 +996,7 @@
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
"source": "https://github.com/composer/semver/tree/3.5.0"
},
"funding": [
{
@@ -1008,7 +1008,7 @@
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
"time": "2026-09-24T14:38:51+00:00"
},
{
"name": "danharrin/date-format-converter",
+18
View File
@@ -0,0 +1,18 @@
<?php
/*
* Per-site settings for the cart + checkout module (see
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
* publishable config when the module moves to boboko-core.
*/
return [
/*
* Name of the storefront's login route. The checkout's login tab and the
* confirmation page link to it with `?redirect=<checkout path>`, so the
* login page must send the shopper back there afterwards (3dealer's
* Auth\LoginController does). null: no login offered in checkout at all.
*/
'login_route' => 'login',
];
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* When a new account was created from the login page, and which terms and
* privacy-policy versions its notice showed at the time (see
* Auth\LoginController::send()). Terms of sale are recorded per order.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('terms_accepted_at')->nullable();
$table->string('terms_version')->nullable();
$table->string('privacy_policy_version')->nullable();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['terms_accepted_at', 'terms_version', 'privacy_policy_version']);
});
}
};
+12 -32
View File
@@ -504,35 +504,12 @@ .bbk-checkout-note {
font-size: 0.875rem;
}
/* Contact tabs */
/* Contact: "logged in as" line, or the guest login prompt */
.bbk-checkout-tabs { display: flex; flex-direction: column; gap: 1rem; }
.bbk-checkout-logged-in,
.bbk-checkout-login-prompt { margin: 0; }
.bbk-checkout-tab {
display: inline-flex;
width: fit-content;
margin-right: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg);
font: inherit;
font-weight: 600;
color: var(--bbk-color-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.bbk-checkout-tab[aria-selected="true"] {
background: var(--bbk-color-text);
border-color: var(--bbk-color-text);
color: var(--bbk-color-bg);
}
.bbk-checkout-tab:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
/* Fields */
@@ -602,6 +579,12 @@ .bbk-checkbox {
}
/* For a full-sentence label that can wrap — align the box to the first line. */
/* "I want an invoice": company/ΑΦΜ only while ticked */
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
@@ -686,6 +669,8 @@ .bbk-checkout-continue {
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
@@ -869,8 +854,3 @@ .bbk-address-lines {
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
.bbk-confirmation-continue {
max-width: 280px;
text-decoration: none;
}
@@ -13,7 +13,6 @@ import { csrfToken } from './csrf'
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
@@ -41,22 +40,6 @@ export default class extends Controller {
this.saveController?.abort()
}
// ── Contact tabs ────────────────────────────────────────────────────
showGuest() {
this.guestPanelTarget.hidden = false
this.loginPanelTarget.hidden = true
this.guestTabTarget.setAttribute('aria-selected', 'true')
this.loginTabTarget.setAttribute('aria-selected', 'false')
}
showLogin() {
this.guestPanelTarget.hidden = true
this.loginPanelTarget.hidden = false
this.guestTabTarget.setAttribute('aria-selected', 'false')
this.loginTabTarget.setAttribute('aria-selected', 'true')
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
@@ -93,7 +76,6 @@ export default class extends Controller {
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
|| el.closest('[data-bbk-checkout-form-target="guestPanel"]')
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
+16
View File
@@ -133,6 +133,22 @@
</div>
</section>
{{-- Standing opt-in for abandoned-cart reminders (explicit, off by
default); checkout starts from it and can change it again. --}}
<section class="flex flex-col gap-8" aria-labelledby="account-emails-heading">
<h2 id="account-emails-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.emails_heading') }}</h2>
<div>
<input type="hidden" name="recovery_consent" value="0">
<x-ui.checkbox
id="account-recovery-consent"
name="recovery_consent"
:checked="(bool) old('recovery_consent', data_get($customer, 'meta.recovery_consent'))"
:label="__('storefront.account.recovery_consent')"
/>
</div>
</section>
<div>
<x-ui.button type="submit">{{ __('storefront.account.save') }}</x-ui.button>
</div>
+10
View File
@@ -42,6 +42,16 @@
/>
</x-ui.field>
{{-- Shown to everyone; for a new account, LoginController::send()
records the time and the terms/privacy versions. Unescaped: the
label holds the two links (Language Lines, staff only). --}}
<p class="text-sm text-neutral-500 [&_a]:underline [&_a:hover]:no-underline">
{!! __('storefront.auth.terms_notice', [
'terms' => route('legal.terms'),
'privacy' => route('legal.privacy'),
]) !!}
</p>
<div>
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
</div>
@@ -41,6 +41,17 @@
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account
(App\Listeners\ClaimGuestOrdersOnLogin), so it shows in their history. --}}
@guest
@if ($loginRoute = config('checkout.login_route'))
<p class="bbk-checkout-note">
{{ __('checkout.page.confirmation_login_hint') }}
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
@endguest
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
@@ -116,9 +127,5 @@
@endif
</div>
</div>
<a class="bbk-checkout-continue bbk-confirmation-continue" href="{{ route('products', app()->getLocale()) }}">
{{ __('checkout.page.confirmation_continue') }}
</a>
</div>
@endsection
+61 -69
View File
@@ -38,73 +38,51 @@ class="bbk-checkout-main"
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
>
{{-- Contact --}}
{{-- 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">
<div
class="bbk-checkout-tabs"
role="tablist"
aria-label="{{ __('checkout.page.contact_heading') }}"
>
<button
type="button"
class="bbk-checkout-tab"
role="tab"
aria-selected="true"
data-bbk-checkout-form-target="guestTab"
data-action="bbk-checkout-form#showGuest"
>{{ __('checkout.page.guest_tab') }}</button>
@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
<button
type="button"
class="bbk-checkout-tab"
role="tab"
aria-selected="false"
data-bbk-checkout-form-target="loginTab"
data-action="bbk-checkout-form#showLogin"
>{{ __('checkout.page.login_tab') }}</button>
<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
<div class="bbk-checkout-tab-panel" role="tabpanel" data-bbk-checkout-form-target="guestPanel">
<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"
/>
{{-- 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>
</div>
{{-- Not wired yet — Modules\Core\Auth\Services\UserOtpService exists
(email + one-time code, passwordless) but nothing in the storefront
calls it yet. UI placeholder only; see project notes. --}}
<div class="bbk-checkout-tab-panel" role="tabpanel" hidden data-bbk-checkout-form-target="loginPanel">
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-login-email">{{ __('checkout.page.login_email_label') }}</label>
<input type="email" id="bbk-login-email" class="bbk-field-input" disabled>
</div>
<button type="button" class="bbk-checkout-continue" disabled>
{{ __('checkout.page.send_code') }}
</button>
<p class="bbk-checkout-note">{{ __('checkout.page.login_coming_soon') }}</p>
</div>
</div>
{{-- 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
@@ -128,9 +106,25 @@ class="bbk-checkout-tab"
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
</div>
<div class="bbk-field-row">
<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" />
{{-- 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 />
@@ -173,8 +167,6 @@ class="bbk-checkout-tab"
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
</div>
<x-checkout::field name="shipping_company_name" label="{{ __('checkout.page.company_name') }}" :value="$shippingAddress?->company_name" />
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
<div class="bbk-field-row">