Replace Stripe Checkout with Stripe Elements for in-page payment

- PaymentIntent instead of Checkout Session on connected account
- Stripe Elements Payment Element with neo-brutalist theme
- stripe-payment.js module with waitForStripe() for deferred loading
- No inline scripts (CSP compliant), data attributes on container
- Add order_number (YYYY-MM-DD-increment) to BilletBuyer
- Payment page redesign: full-width vertical layout with event info,
  buyer info, billet listing with images/descriptions, payment form
- CSP: add js.stripe.com to script-src, api.stripe.com to connect-src
- Add stripe_pk parameter in services.yaml
- Add head block to base.html.twig for page-specific scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 16:13:06 +01:00
parent 3744fb84f1
commit d0391e5fda
11 changed files with 257 additions and 66 deletions

View File

@@ -9,6 +9,7 @@ import { initSortable } from "./modules/sortable.js"
import { initBilletDesigner } from "./modules/billet-designer.js"
import { initCommissionCalculator } from "./modules/commission-calculator.js"
import { initCart } from "./modules/cart.js"
import { initStripePayment } from "./modules/stripe-payment.js"
document.addEventListener('DOMContentLoaded', () => {
initMobileMenu()
@@ -21,4 +22,5 @@ document.addEventListener('DOMContentLoaded', () => {
initBilletDesigner()
initCommissionCalculator()
initCart()
initStripePayment()
})

View File

@@ -0,0 +1,98 @@
function waitForStripe() {
return new Promise(resolve => {
if (typeof globalThis.Stripe !== 'undefined') {
resolve()
return
}
const interval = setInterval(() => {
if (typeof globalThis.Stripe !== 'undefined') {
clearInterval(interval)
resolve()
}
}, 100)
})
}
export function initStripePayment() {
const container = document.getElementById('payment-card')
if (!container) return
const publicKey = container.dataset.stripeKey
const stripeAccount = container.dataset.stripeAccount
const intentUrl = container.dataset.intentUrl
const returnUrl = container.dataset.returnUrl
const amount = container.dataset.amount
if (!publicKey || !intentUrl) return
const submitBtn = document.getElementById('payment-submit')
const messageEl = document.getElementById('payment-message')
const messageText = document.getElementById('payment-message-text')
let stripe
let elements
waitForStripe().then(() => {
/* global Stripe */
stripe = Stripe(publicKey, {
stripeAccount: stripeAccount || undefined,
})
return globalThis.fetch(intentUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
})
.then(r => r.json())
.then(data => {
elements = stripe.elements({
clientSecret: data.clientSecret,
appearance: {
theme: 'flat',
variables: {
colorPrimary: '#4f46e5',
fontFamily: 'system-ui, sans-serif',
fontWeightNormal: '700',
borderRadius: '0px',
colorBackground: '#ffffff',
},
rules: {
'.Input': {
border: '2px solid #111827',
boxShadow: 'none',
},
'.Input:focus': {
border: '2px solid #4f46e5',
boxShadow: 'none',
},
},
},
})
const paymentElement = elements.create('payment', { layout: 'tabs' })
paymentElement.mount('#payment-element')
})
submitBtn.addEventListener('click', async () => {
if (!stripe || !elements) return
submitBtn.disabled = true
submitBtn.textContent = 'Traitement...'
messageEl.classList.add('hidden')
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl,
},
})
if (error) {
messageText.textContent = error.message
messageEl.classList.remove('hidden')
submitBtn.disabled = false
submitBtn.textContent = 'Payer ' + amount + ' \u20AC'
}
})
}