From 2cac70c53ad00b56c428a31755a784f8a48ef6ff Mon Sep 17 00:00:00 2001 From: elvira Date: Wed, 9 Sep 2026 19:16:20 +0300 Subject: [PATCH] payment beginning (stripe) --- .../Checkout/CheckoutController.php | 145 ++++++++++ config/services.php | 13 + .../seeders/CheckoutTranslationsSeeder.php | 50 ++++ resources/css/checkout.css | 137 ++++++++++ .../checkout/bbk-checkout-form-controller.js | 13 +- .../js/checkout/bbk-payment-controller.js | 253 ++++++++++++++++++ resources/js/checkout/index.js | 2 + .../components/address-lines.blade.php | 15 ++ .../views/checkout/confirmation.blade.php | 84 ++++++ resources/views/checkout/page.blade.php | 64 ++++- .../partials/payment-methods.blade.php | 30 +++ routes/checkout.php | 12 + 12 files changed, 806 insertions(+), 12 deletions(-) create mode 100644 resources/js/checkout/bbk-payment-controller.js create mode 100644 resources/views/checkout/components/address-lines.blade.php create mode 100644 resources/views/checkout/confirmation.blade.php create mode 100644 resources/views/checkout/partials/payment-methods.blade.php diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index fa4f465..b9d9c35 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -4,18 +4,26 @@ 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\Validator; use Illuminate\Validation\Rule; use Illuminate\View\View; +use Lunar\Exceptions\Carts\CartException; +use Lunar\Exceptions\FingerprintMismatchException; +use Lunar\Facades\CartSession; use Lunar\Facades\ShippingManifest; 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\Payment\Enums\PaymentResultStatus; /** * The checkout page — one page, sections (contact / billing / shipping / @@ -65,6 +73,7 @@ public function show(string $locale): View 'billingAddress' => $cart?->billingAddress, 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, + 'paymentMethods' => $this->checkout->getPaymentMethods(), 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), 'storeCountry' => $storeCountry, 'countries' => $storeCountry @@ -213,6 +222,142 @@ public function selectShippingOption(string $locale, Request $request): JsonResp 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); + } + + $fingerprint = (string) ($this->cart->current()?->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); + } + + return match ($result->status) { + PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale), + PaymentResultStatus::Pending => response()->json([ + 'status' => 'pending', + 'clientSecret' => $result->continuation?->value, + ]), + PaymentResultStatus::Failed => 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', 'shippingAddress', 'billingAddress'])->find($orderId) + : null; + + if (! $order) { + return redirect()->route('products', $locale); + } + + return view('checkout::confirmation', ['order' => $order]); + } + + 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 + { + return $this->cart->current() + ?->orders() + ->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, and drop a diff --git a/config/services.php b/config/services.php index 5307b21..7c13ec1 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,17 @@ 'host' => env('STOIC_HOST'), ], + // Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver. + // `key` is the SECRET key (this ecosystem's convention — StripeManager calls + // Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the + // publishable key for Stripe.js; `webhooks.lunar` is the signing secret the + // webhook route verifies against. + 'stripe' => [ + 'key' => env('STRIPE_SECRET'), + 'public_key' => env('STRIPE_PUBLIC_KEY'), + 'webhooks' => [ + 'lunar' => env('STRIPE_WEBHOOK_SECRET'), + ], + ], + ]; diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php index 817337d..f650db6 100644 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ b/database/seeders/CheckoutTranslationsSeeder.php @@ -124,6 +124,56 @@ private function lines(): array ], '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 Terms of Sale and the Privacy Policy", + "Αποδέχομαι τους Όρους Πώλησης και την Πολιτική Απορρήτου", + ], + 'page.terms_required' => [ + 'You must accept the terms to place your order.', + 'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.', + ], + 'page.withdrawal_notice' => [ + "You have a 14-day right of withdrawal. See details.", + "Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. Δες λεπτομέρειες.", + ], + 'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'], + 'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'], + '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', 'Συνέχεια αγορών'], ]; } } diff --git a/resources/css/checkout.css b/resources/css/checkout.css index ad52daa..56b9437 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -661,3 +661,140 @@ .bbk-checkout-summary-heading { /* 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; } + +/* ── Payment ───────────────────────────────────────────────────────── */ + +.bbk-checkout-payment-options { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bbk-checkout-payment-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-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); } + +.bbk-checkout-payment-option-name { font-weight: 600; } + +.bbk-payment-element { margin: 0.25rem 0; } + +.bbk-checkout-withdrawal { + margin: 0; + font-size: 0.8125rem; + color: var(--bbk-color-muted); +} + +.bbk-checkout-withdrawal a { color: inherit; } + +.bbk-checkout-error { + margin: 0; + font-size: 0.875rem; + color: var(--bbk-color-danger); +} + +/* Processing overlay — fixed, covers the page while a payment confirms. */ +.bbk-checkout-processing { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent); + text-align: center; + padding: 1.5rem; +} + +.bbk-spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--bbk-color-border); + border-top-color: var(--bbk-color-accent); + border-radius: 50%; + animation: bbk-spin 0.8s linear infinite; +} + +@keyframes bbk-spin { + to { transform: rotate(360deg); } +} + +/* ── Confirmation page ─────────────────────────────────────────────── */ + +.bbk-confirmation { + max-width: 720px; + margin: 0 auto; + padding: 3rem 1.5rem 5rem; + font-family: var(--bbk-font); + color: var(--bbk-color-text); +} + +.bbk-confirmation-heading { + margin: 0 0 1rem; + font-size: 1.75rem; + font-weight: 700; +} + +.bbk-confirmation-ref { margin: 0 0 0.25rem; } + +.bbk-confirmation-body { + margin: 2rem 0; + display: grid; + gap: 2.5rem; +} + +@media (min-width: 640px) { + .bbk-confirmation-body { grid-template-columns: 1fr 1fr; } +} + +.bbk-confirmation-lines { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bbk-confirmation-line { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.bbk-confirmation-line-qty { color: var(--bbk-color-muted); } + +.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; } + +.bbk-confirmation-addresses { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.bbk-confirmation-address-heading { + margin: 0 0 0.5rem; + font-size: 0.9375rem; + font-weight: 700; +} + +.bbk-address-lines { + font-style: normal; + display: flex; + flex-direction: column; + gap: 0.125rem; + font-size: 0.875rem; + color: var(--bbk-color-muted); +} + +.bbk-confirmation-continue { + max-width: 280px; + text-decoration: none; +} diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index 5ec63e7..83648d4 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -86,11 +86,14 @@ export default class extends Controller { // ── Autosave ─────────────────────────────────────────────────────── scheduleSave(event) { - // The shipping-method radios live inside this controller's element too, - // but they have their own handler — don't also autosave the address for them. - if (this.hasShippingOptionsTarget && this.shippingOptionsTarget.contains(event.target)) { - return - } + // The shipping-method and payment radios live inside this controller's + // element too, and this action is bound on .bbk-checkout-main to also + // catch the contact email/consent that sit outside the
. Only + // 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, // so the indicator isn't flickering "saving" on every keystroke. diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js new file mode 100644 index 0000000..57e12d8 --- /dev/null +++ b/resources/js/checkout/bbk-payment-controller.js @@ -0,0 +1,253 @@ +import { Controller } from '@hotwired/stimulus' +import { csrfToken } from './csrf' + +const STRIPE_JS = 'https://js.stripe.com/v3/' +const POLL_INTERVAL = 1500 +const POLL_TIMEOUT = 30000 + +// The payment step of the checkout page. Sits alongside bbk-checkout-form on +// .bbk-checkout-main. +// +// - selectMethod: radio change -> persist via /payment-method, refresh the +// summary (COD fee), mount/unmount the Stripe Payment Element +// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side +// and POSTs it to /place-order, then routes on the JSON result: +// { redirect } -> order placed, go to confirmation +// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then +// poll /order-status until the webhook places it +// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable +export default class extends Controller { + static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText'] + + static values = { + selectUrl: String, + placeOrderUrl: String, + orderStatusUrl: String, + stripeKey: String, + amount: Number, + currency: String, + termsRequired: String, + chooseMethod: String, + genericError: String, + processingSlow: String, + } + + connect() { + this.stripe = null + this.elements = null + this.paymentElement = null + + this.onSummaryUpdate = (event) => { + const total = event.detail?.total + if (typeof total === 'number' && this.elements) { + this.amountValue = total + this.elements.update({ amount: Math.max(total, 1) }) + } + } + window.addEventListener('bbk-cart:updated', this.onSummaryUpdate) + + if (this.selectedIsStripe()) this.mountStripe() + } + + disconnect() { + window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate) + this.unmountStripe() + } + + // ── Method selection ────────────────────────────────────────────── + + async selectMethod(event) { + const isStripe = event.target.dataset.paymentDriver === 'stripe' + + try { + const response = await fetch(this.selectUrlValue, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + body: new URLSearchParams({ payment_type: event.target.value }), + }) + if (response.ok) { + const data = await response.json() + if (data.summaryHtml != null) { + window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } })) + } + } + } catch { + // summary just won't refresh — non-fatal + } + + isStripe ? this.mountStripe() : this.unmountStripe() + } + + selectedRadio() { + return this.element.querySelector('input[name="payment_type"]:checked') + } + + selectedIsStripe() { + return this.selectedRadio()?.dataset.paymentDriver === 'stripe' + } + + // ── Stripe Payment Element ──────────────────────────────────────── + + async loadStripe() { + if (window.Stripe) return window.Stripe + + await new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${STRIPE_JS}"]`) + if (existing) { + existing.addEventListener('load', resolve) + existing.addEventListener('error', reject) + return + } + const script = document.createElement('script') + script.src = STRIPE_JS + script.onload = resolve + script.onerror = reject + document.head.appendChild(script) + }) + + return window.Stripe + } + + async mountStripe() { + if (this.paymentElement || !this.stripeKeyValue) return + + const Stripe = await this.loadStripe() + this.stripe = this.stripe || Stripe(this.stripeKeyValue) + + this.elements = this.stripe.elements({ + mode: 'payment', + amount: Math.max(this.amountValue, 1), + currency: this.currencyValue, + paymentMethodCreation: 'manual', + }) + this.paymentElement = this.elements.create('payment') + this.paymentElement.mount(this.elementTarget) + this.elementTarget.hidden = false + } + + unmountStripe() { + this.paymentElement?.unmount() + this.paymentElement = null + this.elements = null + + if (this.hasElementTarget) { + this.elementTarget.innerHTML = '' + this.elementTarget.hidden = true + } + } + + // ── Place order ────────────────────────────────────────────────── + + async placeOrder() { + this.clearError() + + if (!this.termsTarget.checked) { + this.showError(this.termsRequiredValue) + return + } + + const radio = this.selectedRadio() + if (!radio) { + this.showError(this.chooseMethodValue) + return + } + + this.submitTarget.disabled = true + + let paymentMethodId = null + if (radio.dataset.paymentDriver === 'stripe') { + const { error: submitError } = await this.elements.submit() + if (submitError) return this.fail(submitError.message) + + const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements }) + if (pmError) return this.fail(pmError.message) + paymentMethodId = paymentMethod.id + } + + let data + try { + const response = await fetch(this.placeOrderUrlValue, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + body: new URLSearchParams({ + payment_type: radio.value, + payment_method: paymentMethodId ?? '', + terms_accepted: '1', + }), + }) + data = await response.json() + } catch { + return this.fail(this.genericErrorValue) + } + + if (data.redirect) { + window.location.assign(data.redirect) + return + } + + if (data.status === 'pending' && data.clientSecret) { + await this.resolvePending(data.clientSecret) + return + } + + this.fail(data.message || data.error || this.genericErrorValue) + } + + async resolvePending(clientSecret) { + this.processingTarget.hidden = false + + const { error } = await this.stripe.handleNextAction({ clientSecret }) + if (error) { + this.processingTarget.hidden = true + return this.fail(error.message) + } + + // 3-D Secure cleared client-side — the webhook places the order. Poll. + const startedAt = Date.now() + const tick = async () => { + try { + const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } }) + const data = await response.json() + if (data.placed && data.redirect) { + window.location.assign(data.redirect) + return + } + } catch { + // keep polling + } + + if (Date.now() - startedAt > POLL_TIMEOUT) { + this.processingTextTarget.textContent = this.processingSlowValue + return + } + setTimeout(tick, POLL_INTERVAL) + } + tick() + } + + // ── helpers ────────────────────────────────────────────────────── + + fail(message) { + this.showError(message) + this.submitTarget.disabled = false + } + + showError(message) { + this.errorTarget.textContent = message + this.errorTarget.hidden = false + this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + + clearError() { + this.errorTarget.textContent = '' + this.errorTarget.hidden = true + } +} diff --git a/resources/js/checkout/index.js b/resources/js/checkout/index.js index 4db42d2..1163918 100644 --- a/resources/js/checkout/index.js +++ b/resources/js/checkout/index.js @@ -1,6 +1,7 @@ import BbkAddToCartController from './bbk-add-to-cart-controller' import BbkCartController from './bbk-cart-controller' import BbkCheckoutFormController from './bbk-checkout-form-controller' +import BbkPaymentController from './bbk-payment-controller' // Registers the checkout module's Stimulus controllers onto the host app's // Stimulus application. Call once from the host's JS entry point: @@ -14,4 +15,5 @@ export function registerCheckout(application) { application.register('bbk-add-to-cart', BbkAddToCartController) application.register('bbk-cart', BbkCartController) application.register('bbk-checkout-form', BbkCheckoutFormController) + application.register('bbk-payment', BbkPaymentController) } diff --git a/resources/views/checkout/components/address-lines.blade.php b/resources/views/checkout/components/address-lines.blade.php new file mode 100644 index 0000000..957989d --- /dev/null +++ b/resources/views/checkout/components/address-lines.blade.php @@ -0,0 +1,15 @@ +{{-- + Read-only formatted address. $address is any Lunar address model + (OrderAddress / CartAddress) — same column names on both. +--}} +@props(['address']) + +
+ {{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }} + @if ($address->company_name){{ $address->company_name }}@endif + {{ $address->line_one }} + @if ($address->line_two){{ $address->line_two }}@endif + {{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }} + @if ($address->state){{ $address->state }}@endif + @if ($address->contact_phone){{ $address->contact_phone }}@endif +
diff --git a/resources/views/checkout/confirmation.blade.php b/resources/views/checkout/confirmation.blade.php new file mode 100644 index 0000000..baa7c50 --- /dev/null +++ b/resources/views/checkout/confirmation.blade.php @@ -0,0 +1,84 @@ +{{-- + 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') +
+

{{ __('checkout.page.confirmation_heading') }}

+ +

+ {{ __('checkout.page.confirmation_order_number') }}: {{ $order->reference }} +

+

{{ __('checkout.page.confirmation_email_note') }}

+ +
+
+ @foreach ($order->lines->where('type', '!=', 'shipping') as $line) +
+ + {{ $line->description }} + × {{ $line->quantity }} + + {{ $line->sub_total?->formatted() }} +
+ @endforeach + +
+
+ {{ __('checkout.cart.subtotal') }} + {{ $order->sub_total?->formatted() }} +
+ + @if ($order->discount_total?->value > 0) +
+ {{ __('checkout.cart.discount') }} + −{{ $order->discount_total->formatted() }} +
+ @endif + +
+ {{ __('checkout.cart.shipping') }} + {{ $order->shipping_total?->formatted() }} +
+ + @if ($order->tax_total?->value > 0) +
+ {{ __('checkout.cart.tax') }} + {{ $order->tax_total->formatted() }} +
+ @endif + +
+ {{ __('checkout.cart.total') }} + {{ $order->total?->formatted() }} +
+
+
+ +
+ @if ($order->shippingAddress) +
+

{{ __('checkout.page.confirmation_shipping_to') }}

+ +
+ @endif + + @if ($order->billingAddress) +
+

{{ __('checkout.page.confirmation_billing') }}

+ +
+ @endif +
+
+ + + {{ __('checkout.page.confirmation_continue') }} + +
+@endsection diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php index a798e0d..f3d344f 100644 --- a/resources/views/checkout/page.blade.php +++ b/resources/views/checkout/page.blade.php @@ -19,13 +19,23 @@
{{-- Contact --}} @@ -215,12 +225,52 @@ class="bbk-checkout-status"
- {{-- 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. --}} - + {{-- Payment --}} +
+

{{ __('checkout.page.payment_heading') }}

+ +
+ @include('checkout::partials.payment-methods', [ + 'paymentMethods' => $paymentMethods, + 'cart' => $cart, + ]) +
+ + {{-- Stripe Payment Element mounts here when a Stripe method is picked. --}} + + + + +

+ {!! __('checkout.page.withdrawal_notice', [ + 'link' => route('legal.shipping-returns', app()->getLocale()), + ]) !!} +

+ + + + +
+ + {{-- Fixed overlay while a payment is confirming (3-D Secure / webhook + poll). Inside .bbk-checkout-main so bbk-payment can target it. --}} +
diff --git a/resources/views/checkout/partials/payment-methods.blade.php b/resources/views/checkout/partials/payment-methods.blade.php new file mode 100644 index 0000000..3891042 --- /dev/null +++ b/resources/views/checkout/partials/payment-methods.blade.php @@ -0,0 +1,30 @@ +{{-- + Payment method radios. $paymentMethods is Collection 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()) +

{{ __('checkout.page.payment_method_none') }}

+@else +
+ @foreach ($paymentMethods as $method) + + @endforeach +
+@endif diff --git a/routes/checkout.php b/routes/checkout.php index 6039049..47f8f36 100644 --- a/routes/checkout.php +++ b/routes/checkout.php @@ -29,6 +29,18 @@ 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');