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/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-beta.0",
2
+ "version": "2.0.0-beta.1",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -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) onClose()
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
- }, [onClose, open])
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: prev.phone || profileBilling.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
- ...current,
302
- countryId: String(preferred.id),
303
- countryCode: preferred.code?.toUpperCase(),
304
- stateId: undefined,
305
- stateCode: undefined,
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
- firstName: holderNamesRequired
357
- ? holder.firstName
358
- : holder.firstName || billing.firstName,
359
- lastName: holderNamesRequired ? holder.lastName : holder.lastName || billing.lastName,
360
- email:
361
- showHolders && collectHolderContact ? holder.email : holder.email || billing.email,
362
- phone:
363
- showHolders && collectHolderContact ? holder.phone : holder.phone || billing.phone,
364
- ticketTypeId: holder.ticketTypeId,
365
- ticketDataCapture: holder.ticketDataCapture,
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)}
@@ -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 Mexico only, and never listed in redirect methods.
171
- if (checkout.breakdown?.currencyCode === 'MXN')
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
- checkout.breakdown,
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 = !isFree && (isCardSelected || (isPlanSelected && !reuseSavedCard))
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