generated from boboko/starter
290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
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
|
|
}
|
|
}
|