checkout process - start

This commit is contained in:
elvira
2026-09-08 20:41:49 +03:00
parent 7577426f49
commit 2d85591a43
12 changed files with 923 additions and 6 deletions
+243
View File
@@ -382,3 +382,246 @@ .bbk-cart-empty {
color: var(--bbk-color-muted);
padding: 2.5rem 0;
}
/* ───────────────────────────────────────────────────────────────────
Checkout page — two columns: fields on the left, order summary (the
same cart-body partial the drawer uses) on the right.
─────────────────────────────────────────────────────────────────── */
.bbk-checkout-page {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 5rem;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-checkout-heading {
margin: 0 0 2rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-checkout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 3rem;
align-items: start;
}
@media (max-width: 860px) {
.bbk-checkout { grid-template-columns: 1fr; }
}
.bbk-checkout-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bbk-checkout-section {
padding-bottom: 2rem;
border-bottom: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.bbk-checkout-section-heading {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.bbk-checkout-note {
margin: 0;
color: var(--bbk-color-muted);
font-size: 0.875rem;
}
/* Contact tabs */
.bbk-checkout-tabs { display: flex; flex-direction: column; gap: 1rem; }
.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;
}
/* Fields */
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
.bbk-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.bbk-field-row { grid-template-columns: 1fr; }
}
.bbk-field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-muted);
}
.bbk-field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
}
.bbk-field-input:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-field-input:disabled {
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
.bbk-field-error {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
textarea.bbk-field-input { resize: vertical; }
.bbk-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* For a full-sentence label that can wrap — align the box to the first line. */
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--bbk-color-muted);
}
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
.bbk-checkout-shipping-fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Shipping method */
.bbk-checkout-shipping-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-shipping-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-shipping-option-detail {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.bbk-checkout-shipping-option-name { font-weight: 600; }
.bbk-checkout-shipping-option-description {
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-shipping-option-price { font-weight: 600; }
/* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-checkout-continue:hover { opacity: 0.85; }
.bbk-checkout-continue:disabled {
cursor: not-allowed;
opacity: 0.4;
}
/* Order summary column */
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
.bbk-checkout-summary {
padding: 1.5rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius);
background: var(--bbk-color-bg);
}
.bbk-checkout-summary-heading {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
nowhere further to send you from here. */
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
@@ -0,0 +1,46 @@
import { Controller } from '@hotwired/stimulus'
// Static, client-only interactivity for the checkout page's address form — no
// fetch, no cart mutation, just show/hide. Nothing here talks to the server;
// CheckoutController::saveAddress() is what actually persists anything.
export default class extends Controller {
static targets = [
'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
'sameAsBilling', 'shippingFields',
]
connect() {
// Reflect whatever the server rendered (old input on a validation
// redisplay, or the default) before any click happens.
if (this.hasSameAsBillingTarget) this.syncShippingFields()
}
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')
}
toggleSameAsBilling() {
this.syncShippingFields()
}
// Disabled fields are simply never submitted by the browser — the server
// never sees stale shipping values while "same as billing" is checked.
syncShippingFields() {
const sameAsBilling = this.sameAsBillingTarget.checked
this.shippingFieldsTarget.hidden = sameAsBilling
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = sameAsBilling
})
}
}
+2
View File
@@ -1,5 +1,6 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
@@ -12,4 +13,5 @@ import BbkCartController from './bbk-cart-controller'
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
}
@@ -0,0 +1,30 @@
{{--
<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)]) }}
>
@error($name)
<p class="bbk-field-error">{{ $message }}</p>
@enderror
</div>
@@ -0,0 +1,38 @@
{{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
`options` is an iterable of {id, name} (a Collection of models works
directly) — value/label pulled from those two keys.
--}}
@props([
'name',
'label',
'options' => [],
'value' => null,
'placeholder' => null,
'required' => false,
])
@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->id }}" @selected((string) $selected === (string) $option->id)>
{{ $option->name }}
</option>
@endforeach
</select>
@error($name)
<p class="bbk-field-error">{{ $message }}</p>
@enderror
</div>
@@ -0,0 +1,20 @@
@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>
@error($name)
<p class="bbk-field-error">{{ $message }}</p>
@enderror
</div>
+250
View File
@@ -0,0 +1,250 @@
{{--
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">
{{-- Contact --}}
<section class="bbk-checkout-section">
<div
class="bbk-checkout-tabs"
role="tablist"
aria-label="{{ __('checkout.page.contact_heading') }}"
data-controller="bbk-checkout-form"
>
<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>
<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>
<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"
/>
{{-- One combined marketing opt-in (offers + news + unfinished-order
reminders). Optional, unticked, never required — all of it is
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.
Persistence + prefill from the cart is Task 2 (back-end);
data_get() here already reads it once that lands. --}}
<label class="bbk-checkbox bbk-checkbox--stacked">
<input
type="checkbox"
name="marketing_consent"
value="1"
form="bbk-address-form"
{{ old('marketing_consent', data_get($cart, 'meta.marketing_consent')) ? 'checked' : '' }}
>
{{ __('checkout.page.marketing_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>
</section>
<form id="bbk-address-form" method="POST" action="{{ route('checkout.address.save', app()->getLocale()) }}">
@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>
<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" />
</div>
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
<x-checkout::field name="billing_line_two" label="{{ __('checkout.page.address_line_two') }}" :value="$billingAddress?->line_two" />
<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>
<div class="bbk-field-row">
<x-checkout::field name="billing_state" label="{{ __('checkout.page.state') }}" :value="$billingAddress?->state" />
<x-checkout::select
name="billing_country_id"
label="{{ __('checkout.page.country') }}"
:options="$countries"
:value="$billingAddress?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
</div>
<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"
{{ old('same_as_billing', $shippingAddress === null || $shippingAddress?->is($billingAddress)) ? 'checked' : '' }}
>
{{ __('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_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 />
<x-checkout::field name="shipping_line_two" label="{{ __('checkout.page.address_line_two') }}" :value="$shippingAddress?->line_two" />
<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>
<div class="bbk-field-row">
<x-checkout::field name="shipping_state" label="{{ __('checkout.page.state') }}" :value="$shippingAddress?->state" />
<x-checkout::select
name="shipping_country_id"
label="{{ __('checkout.page.country') }}"
:options="$countries"
:value="$shippingAddress?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
</div>
<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>
<button type="submit" class="bbk-checkout-continue">
{{ __('checkout.page.save_address') }}
</button>
</form>
{{-- Shipping method — only resolvable once a shipping address exists --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
@if ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@else
<form method="POST" action="{{ route('checkout.shipping-option.select', app()->getLocale()) }}">
@csrf
<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 }}"
{{ old('shipping_option', $shippingAddress?->shipping_option) === $option->identifier ? 'checked' : '' }}
required
>
<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">{{ $option->description }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@error('shipping_option')
<p class="bbk-field-error">{{ $message }}</p>
@enderror
<button type="submit" class="bbk-checkout-continue">
{{ __('checkout.page.select_shipping_method') }}
</button>
</form>
@endif
</section>
{{-- TODO: payment — next slice. boboko-core's Offline driver is the only
one fully wired end-to-end today; Stripe needs its client_secret /
3-D Secure continuation handled before it can drive a real form here. --}}
<button type="button" class="bbk-checkout-continue" disabled>
{{ __('checkout.page.continue_to_payment') }}
</button>
</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>
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</div>
</aside>
</div>
</div>
@endsection
@@ -92,11 +92,9 @@ class="bbk-cart-coupon-input"
<span>{{ $cart?->total?->formatted() }}</span>
</div>
{{-- TODO: point at the checkout page once that slice exists —
the drawer is the cart, there's no cart page for this to fall back to. --}}
<button type="button" class="bbk-cart-checkout" disabled>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</button>
</a>
</div>
@endif
</div>
+7 -1
View File
@@ -52,7 +52,13 @@
<body class="min-h-screen antialiased">
<x-header />
@include('checkout::drawer')
{{-- Not on the checkout page itself — its order-summary column already
plays the drawer's role there, and both reacting to the same
`bbk-cart:changed` event would pop the (redundant) drawer open over
the page every time a line's quantity changes in the summary. --}}
@unless (request()->routeIs('checkout.show'))
@include('checkout::drawer')
@endunless
<main id="main-content" class="min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
@yield('content')