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) }) } // Removing the last line while sitting on the checkout page (via // the order summary's own remove form) must not leave "place // order" clickable with nothing left to charge for — this fires // from both the drawer and the checkout page's own summary // instance, whichever the shopper actually used. const count = event.detail?.count if (typeof count === 'number' && this.hasSubmitTarget) { this.submitTarget.disabled = count === 0 } } 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', // Card only — matches the server confirming with // automatic_payment_methods.allow_redirects = 'never' (no // return_url in our flow: 3-D Secure resolves in-page via // handleNextAction, never a full-page redirect). paymentMethodTypes: ['card'], }) 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 ────────────────────────────────────────────────── // Sibling controller on the same element (.bbk-checkout-main) — used to // flush a pending debounced address autosave before placing the order. get checkoutForm() { return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form') } async placeOrder() { this.clearError() this.submitTarget.disabled = true // A debounced address save (and the shipping-option auto-select that // happens as part of it) might still be pending — make sure the // server has the latest state before we ask it to place the order. await this.checkoutForm?.flush() if (!this.termsTarget.checked) { this.submitTarget.disabled = false this.showError(this.termsRequiredValue) return } const radio = this.selectedRadio() if (!radio) { this.submitTarget.disabled = false this.showError(this.chooseMethodValue) return } 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 } // Points at the section that actually needs attention, rather than // leaving a generic error and making the shopper hunt for it — e.g. a // region with 2+ shipping methods needs an explicit pick, easy to miss. if (data.field === 'shipping_option') { document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' }) this.fail(data.message || data.error || this.genericErrorValue, { scroll: false }) 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, { scroll = true } = {}) { this.showError(message, { scroll }) this.submitTarget.disabled = false } showError(message, { scroll = true } = {}) { this.errorTarget.textContent = message this.errorTarget.hidden = false if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' }) } clearError() { this.errorTarget.textContent = '' this.errorTarget.hidden = true } }