tf-checkout-react 2.0.0-beta.0 → 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.esm.js +80 -27
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +80 -27
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/AuthModal.tsx +9 -2
- package/src/sections/BillingInfo.tsx +67 -20
- package/src/sections/Payment.tsx +20 -4
- package/src/sections/billing/TicketHoldersCard.tsx +5 -0
- package/src/sections/billing/fieldRules.ts +43 -0
package/package.json
CHANGED
package/src/auth/AuthModal.tsx
CHANGED
|
@@ -62,6 +62,13 @@ export const AuthModal: FC<AuthModalProps> = ({
|
|
|
62
62
|
const previouslyFocused = useRef<HTMLElement | null>(null)
|
|
63
63
|
const submittingRef = useRef(auth.isSubmitting)
|
|
64
64
|
submittingRef.current = auth.isSubmitting
|
|
65
|
+
// Consumers pass `onClose` as an inline arrow, so its identity changes on
|
|
66
|
+
// every parent render — and the parent re-renders once a second while the
|
|
67
|
+
// reservation timer counts down. Held in a ref so the setup effect below can
|
|
68
|
+
// depend on `open` alone; with `onClose` in its deps it tore down and re-ran
|
|
69
|
+
// each tick, stealing focus back to the first field mid-typing.
|
|
70
|
+
const onCloseRef = useRef(onClose)
|
|
71
|
+
onCloseRef.current = onClose
|
|
65
72
|
|
|
66
73
|
useEffect(() => {
|
|
67
74
|
if (!open) return
|
|
@@ -83,7 +90,7 @@ export const AuthModal: FC<AuthModalProps> = ({
|
|
|
83
90
|
initialFocus?.focus()
|
|
84
91
|
|
|
85
92
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
86
|
-
if (event.key === 'Escape' && !submittingRef.current)
|
|
93
|
+
if (event.key === 'Escape' && !submittingRef.current) onCloseRef.current()
|
|
87
94
|
if (event.key !== 'Tab') return
|
|
88
95
|
const elements = focusable()
|
|
89
96
|
if (elements.length === 0) {
|
|
@@ -107,7 +114,7 @@ export const AuthModal: FC<AuthModalProps> = ({
|
|
|
107
114
|
document.body.style.overflow = previousOverflow
|
|
108
115
|
previouslyFocused.current?.focus()
|
|
109
116
|
}
|
|
110
|
-
}, [
|
|
117
|
+
}, [open])
|
|
111
118
|
|
|
112
119
|
if (!open) return null
|
|
113
120
|
|
|
@@ -38,6 +38,7 @@ import { CustomFieldInput } from '../ui/CustomFields'
|
|
|
38
38
|
import { Checkbox, Field, FieldRow, Input } from '../ui/Field'
|
|
39
39
|
import { BillingAddressFields } from './billing/BillingAddressFields'
|
|
40
40
|
import { BuyerField } from './billing/BuyerField'
|
|
41
|
+
import { DOB_MIN, isBlankPhone, toDateInputValue, todayISO } from './billing/fieldRules'
|
|
41
42
|
import { TicketHoldersCard } from './billing/TicketHoldersCard'
|
|
42
43
|
import type { BillingDraft, HolderDraft } from './billing/types'
|
|
43
44
|
|
|
@@ -199,7 +200,8 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
199
200
|
email: prev.email || profileBilling.email || '',
|
|
200
201
|
confirmEmail:
|
|
201
202
|
prev.confirmEmail || (checkout.isLoggedIn ? profileBilling.email : '') || '',
|
|
202
|
-
phone:
|
|
203
|
+
phone:
|
|
204
|
+
(isBlankPhone(prev.phone) ? undefined : prev.phone) || profileBilling.phone,
|
|
203
205
|
streetAddress: prev.streetAddress || profileBilling.streetAddress,
|
|
204
206
|
city: prev.city || profileBilling.city,
|
|
205
207
|
zip: prev.zip || profileBilling.zip,
|
|
@@ -209,6 +211,16 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
209
211
|
const keys = Object.keys(next) as (keyof BillingDraft)[]
|
|
210
212
|
return keys.every((k) => next[k] === prev[k]) ? prev : next
|
|
211
213
|
})
|
|
214
|
+
// Date of birth lives outside `billing`, so it has to be seeded here too —
|
|
215
|
+
// and it has to be here rather than in its own effect or in the useState
|
|
216
|
+
// initialiser: the initialiser runs before the profile resolves, and a
|
|
217
|
+
// separate effect would re-fire and overwrite the buyer, since the live
|
|
218
|
+
// commit below depends on `dob`. This effect already runs exactly once.
|
|
219
|
+
setDob((prev) => {
|
|
220
|
+
if (prev) return prev
|
|
221
|
+
const { dobYear, dobMonth, dobDay } = profileBilling
|
|
222
|
+
return toDateInputValue(dobYear, dobMonth, dobDay) ?? prev
|
|
223
|
+
})
|
|
212
224
|
}, [profile.data, checkout.isLoggedIn])
|
|
213
225
|
|
|
214
226
|
// Resize holders to the slot count, preserving typed values; tag ticket type.
|
|
@@ -297,13 +309,22 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
297
309
|
countries.find((country) => country.code?.toLowerCase() === 'us')
|
|
298
310
|
if (!preferred) return
|
|
299
311
|
countryDefaultApplied.current = true
|
|
300
|
-
setBilling((current) =>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
312
|
+
setBilling((current) => {
|
|
313
|
+
// The profile-seed effect above can run in this same commit, so the
|
|
314
|
+
// `billing` this effect closed over is stale — it still shows no country
|
|
315
|
+
// even though the seed just supplied one (with its state). Re-check the
|
|
316
|
+
// live value here: without it the default overwrote the seeded country
|
|
317
|
+
// and blanked `stateId`, which is why a returning buyer's State/County
|
|
318
|
+
// came back empty while every other saved field prefilled.
|
|
319
|
+
if (current.countryId) return current
|
|
320
|
+
return {
|
|
321
|
+
...current,
|
|
322
|
+
countryId: String(preferred.id),
|
|
323
|
+
countryCode: preferred.code?.toUpperCase(),
|
|
324
|
+
stateId: undefined,
|
|
325
|
+
stateCode: undefined,
|
|
326
|
+
}
|
|
327
|
+
})
|
|
307
328
|
}, [
|
|
308
329
|
billing.countryId,
|
|
309
330
|
countries,
|
|
@@ -342,28 +363,41 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
342
363
|
const patchHolder = (i: number, patch: Partial<HolderDraft>) =>
|
|
343
364
|
setHolders((hs) => hs.map((h, j) => (j === i ? { ...h, ...patch } : h)))
|
|
344
365
|
|
|
366
|
+
const dobMax = useMemo(todayISO, [])
|
|
345
367
|
const [dobYear, dobMonth, dobDay] = dob ? dob.split('-').map(Number) : []
|
|
368
|
+
// A dial code on its own is not a phone number — see `isBlankPhone`.
|
|
369
|
+
const buyerPhone = isBlankPhone(billing.phone) ? undefined : billing.phone
|
|
346
370
|
const committedBilling: CheckoutBilling = {
|
|
347
371
|
...checkout.billing,
|
|
348
372
|
...billing,
|
|
373
|
+
phone: buyerPhone,
|
|
349
374
|
stateRequired: gates.showAddress && Boolean(billing.countryId) && states.length > 0,
|
|
350
375
|
dataCapture,
|
|
351
376
|
dobDay,
|
|
352
377
|
dobMonth,
|
|
353
378
|
dobYear,
|
|
354
379
|
}
|
|
355
|
-
const committedHolders: CheckoutHolder[] = holders.map((holder) =>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
380
|
+
const committedHolders: CheckoutHolder[] = holders.map((holder) => {
|
|
381
|
+
const holderPhone = isBlankPhone(holder.phone) ? '' : holder.phone
|
|
382
|
+
return {
|
|
383
|
+
firstName: holderNamesRequired
|
|
384
|
+
? holder.firstName
|
|
385
|
+
: holder.firstName || billing.firstName,
|
|
386
|
+
lastName: holderNamesRequired
|
|
387
|
+
? holder.lastName
|
|
388
|
+
: holder.lastName || billing.lastName,
|
|
389
|
+
email:
|
|
390
|
+
showHolders && collectHolderContact
|
|
391
|
+
? holder.email
|
|
392
|
+
: holder.email || billing.email,
|
|
393
|
+
phone:
|
|
394
|
+
showHolders && collectHolderContact
|
|
395
|
+
? holderPhone
|
|
396
|
+
: holderPhone || buyerPhone || '',
|
|
397
|
+
ticketTypeId: holder.ticketTypeId,
|
|
398
|
+
ticketDataCapture: holder.ticketDataCapture,
|
|
399
|
+
}
|
|
400
|
+
})
|
|
367
401
|
const missingDetails = missingCheckoutDetails(
|
|
368
402
|
{
|
|
369
403
|
billing: committedBilling,
|
|
@@ -584,6 +618,13 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
584
618
|
fieldError('Valid phone number') ? ' tf-checkout-input--invalid' : ''
|
|
585
619
|
}`}
|
|
586
620
|
defaultCountry={phoneCountry}
|
|
621
|
+
// The flag owns the country; the input holds the national number
|
|
622
|
+
// only. Without `disableDialCodeAndPrefix` the empty field still
|
|
623
|
+
// expects a full international number, so a Mexican buyer typing
|
|
624
|
+
// "5512345678" has it read as a dial code and the country guess
|
|
625
|
+
// flips them to Brazil (+55) — submitting the wrong code.
|
|
626
|
+
disableDialCodeAndPrefix
|
|
627
|
+
showDisabledDialCodeAndPrefix
|
|
587
628
|
disableDialCodePrefill
|
|
588
629
|
value={billing.phone ?? ''}
|
|
589
630
|
required={gates.phoneRequired}
|
|
@@ -616,6 +657,12 @@ export const BillingInfo: FC<BillingInfoProps> = ({
|
|
|
616
657
|
type="date"
|
|
617
658
|
value={dob}
|
|
618
659
|
required
|
|
660
|
+
// A date input with no bounds lets the browser's year segment run
|
|
661
|
+
// to six digits ("202555") and accept dates in the future. The
|
|
662
|
+
// range both caps the year at four digits and keeps the value a
|
|
663
|
+
// date someone could actually have been born on.
|
|
664
|
+
min={DOB_MIN}
|
|
665
|
+
max={dobMax}
|
|
619
666
|
invalid={Boolean(dobError)}
|
|
620
667
|
aria-invalid={Boolean(dobError)}
|
|
621
668
|
onChange={(e) => setDob(e.target.value)}
|
package/src/sections/Payment.tsx
CHANGED
|
@@ -133,6 +133,20 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
133
133
|
const isFree = bd != null && Number(bd.total) === 0
|
|
134
134
|
const isXendit = checkout.gateway === 'xendit'
|
|
135
135
|
|
|
136
|
+
// Whether the server put OXXO on the deferred Elements config. This is
|
|
137
|
+
// `Checkout::get_payment_method_types()`'s verdict — the event's own OXXO
|
|
138
|
+
// flag, the voucher-expiry window against the event date, an MX Stripe
|
|
139
|
+
// account, a MXN 10–10,000 total, and no manual capture. Several of those
|
|
140
|
+
// inputs (the Stripe account's country, per-ticket ID verification, the
|
|
141
|
+
// event's timezone) have no client-side equivalent, so this must never be
|
|
142
|
+
// re-derived here. Absent config (free carts, Xendit, Razorpay) → false,
|
|
143
|
+
// which is what the legacy checkout does too.
|
|
144
|
+
const serverMethodTypes = (
|
|
145
|
+
checkout.stripeElementsConfig as { paymentMethodTypes?: unknown } | undefined
|
|
146
|
+
)?.paymentMethodTypes
|
|
147
|
+
const offersOxxo =
|
|
148
|
+
Array.isArray(serverMethodTypes) && serverMethodTypes.includes('oxxo')
|
|
149
|
+
|
|
136
150
|
// Build the selectable method rows from gateway + probe + snapshot.
|
|
137
151
|
const options = useMemo<PaymentMethodOption[]>(() => {
|
|
138
152
|
if (isFree || !methods) return []
|
|
@@ -167,8 +181,9 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
167
181
|
value: 'paymentPlan',
|
|
168
182
|
label: 'Payment plan',
|
|
169
183
|
})
|
|
170
|
-
// OXXO cash voucher
|
|
171
|
-
|
|
184
|
+
// OXXO cash voucher. `oxxo` is deliberately absent from
|
|
185
|
+
// SUPPORTED_REDIRECT_METHODS, so it never arrives via the loop above.
|
|
186
|
+
if (offersOxxo)
|
|
172
187
|
out.push({ id: { kind: 'oxxo' }, value: 'oxxo', label: 'OXXO (pay in cash)' })
|
|
173
188
|
}
|
|
174
189
|
if (methods.paypal)
|
|
@@ -181,7 +196,7 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
181
196
|
checkout.gateway,
|
|
182
197
|
checkout.redirectPaymentMethods,
|
|
183
198
|
checkout.paymentPlan,
|
|
184
|
-
|
|
199
|
+
offersOxxo,
|
|
185
200
|
])
|
|
186
201
|
|
|
187
202
|
// Default the selection to the first row once options exist.
|
|
@@ -204,7 +219,8 @@ export const Payment: FC<PaymentProps> = ({
|
|
|
204
219
|
* confirmer with "No card element registered". Always gate on the card.
|
|
205
220
|
*/
|
|
206
221
|
const reuseSavedCard = Boolean(savedLast4) && useSavedCard
|
|
207
|
-
const usesCardDetails =
|
|
222
|
+
const usesCardDetails =
|
|
223
|
+
!isFree && (isCardSelected || (isPlanSelected && !reuseSavedCard))
|
|
208
224
|
// Stripe's CardElement renders its own inline "Card number is invalid" text, so
|
|
209
225
|
// the pay handler can stay quiet and just scroll to it. Xendit's card form is
|
|
210
226
|
// three plain inputs with no error slot — there, the message MUST be shown or
|
|
@@ -98,6 +98,11 @@ export const TicketHoldersCard: FC<TicketHoldersCardProps> = ({
|
|
|
98
98
|
fieldError(phoneKey) ? ' tf-checkout-input--invalid' : ''
|
|
99
99
|
}`}
|
|
100
100
|
defaultCountry={phoneCountry}
|
|
101
|
+
// Matches the buyer phone field in ../BillingInfo.tsx — see
|
|
102
|
+
// the note there on why the dial code must stay out of the
|
|
103
|
+
// input.
|
|
104
|
+
disableDialCodeAndPrefix
|
|
105
|
+
showDisabledDialCodeAndPrefix
|
|
101
106
|
disableDialCodePrefill
|
|
102
107
|
value={holder.phone}
|
|
103
108
|
required
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small value rules shared by the billing form's inputs. They live here rather
|
|
3
|
+
* than in `../BillingInfo.tsx` so that file stays inside the checkout
|
|
4
|
+
* architecture budget, and because each one encodes a non-obvious constraint
|
|
5
|
+
* worth stating once.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The phone control keeps the dial code outside the input, so it reports a bare
|
|
10
|
+
* `"+52"` the moment it mounts — before the buyer has typed anything. A string
|
|
11
|
+
* that short is a country prefix, not a number, so treat it as blank: it must
|
|
12
|
+
* not out-rank a saved profile number, and it must never reach the API as a
|
|
13
|
+
* phone in its own right.
|
|
14
|
+
*/
|
|
15
|
+
export const isBlankPhone = (phone?: string) =>
|
|
16
|
+
!phone || /^\+?\d{0,4}$/.test(phone.trim())
|
|
17
|
+
|
|
18
|
+
/** Earliest date of birth the form accepts. Nobody alive predates it. */
|
|
19
|
+
export const DOB_MIN = '1900-01-01'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Today as `yyyy-mm-dd` in the buyer's own timezone. Built from the local
|
|
23
|
+
* calendar parts rather than `toISOString()`, which would roll over to
|
|
24
|
+
* tomorrow's date for anyone east of Greenwich late in the day.
|
|
25
|
+
*/
|
|
26
|
+
export const todayISO = () => {
|
|
27
|
+
const now = new Date()
|
|
28
|
+
return [
|
|
29
|
+
now.getFullYear(),
|
|
30
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
31
|
+
String(now.getDate()).padStart(2, '0'),
|
|
32
|
+
].join('-')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `dobDay/dobMonth/dobYear` → the `yyyy-mm-dd` a date input expects. */
|
|
36
|
+
export const toDateInputValue = (
|
|
37
|
+
year?: number,
|
|
38
|
+
month?: number,
|
|
39
|
+
day?: number
|
|
40
|
+
): string | undefined =>
|
|
41
|
+
year && month && day
|
|
42
|
+
? `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
43
|
+
: undefined
|