generated from boboko/starter
payment beginning (stripe)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <form>. 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -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')
|
||||
<div class="bbk-confirmation">
|
||||
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
|
||||
|
||||
<p class="bbk-confirmation-ref">
|
||||
{{ __('checkout.page.confirmation_order_number') }}: <strong>{{ $order->reference }}</strong>
|
||||
</p>
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
|
||||
|
||||
<div class="bbk-confirmation-body">
|
||||
<div class="bbk-confirmation-lines">
|
||||
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
|
||||
<div class="bbk-confirmation-line">
|
||||
<span class="bbk-confirmation-line-name">
|
||||
{{ $line->description }}
|
||||
<span class="bbk-confirmation-line-qty">× {{ $line->quantity }}</span>
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<a class="bbk-checkout-continue bbk-confirmation-continue" href="{{ route('products', app()->getLocale()) }}">
|
||||
{{ __('checkout.page.confirmation_continue') }}
|
||||
</a>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -19,13 +19,23 @@
|
||||
<div class="bbk-checkout">
|
||||
<div
|
||||
class="bbk-checkout-main"
|
||||
data-controller="bbk-checkout-form"
|
||||
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 --}}
|
||||
@@ -215,12 +225,52 @@ class="bbk-checkout-status"
|
||||
</div>
|
||||
</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>
|
||||
{{-- 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"
|
||||
>
|
||||
{{ __('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>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{{--
|
||||
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->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
Reference in New Issue
Block a user