tf-checkout-react 2.0.0-beta.0 → 2.0.0-beta.2

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.2",
3
3
  "license": "MIT",
4
4
  "main": "dist/index.js",
5
5
  "typings": "dist/index.d.ts",
@@ -372,7 +372,12 @@ export const EmbeddedCheckout: FC<EmbeddedCheckoutProps> = ({
372
372
  messages,
373
373
  className,
374
374
  }) => (
375
- <CheckoutProvider theme={theme} locale={locale} messages={messages} className={className}>
375
+ <CheckoutProvider
376
+ theme={theme}
377
+ locale={locale}
378
+ messages={messages}
379
+ className={className}
380
+ >
376
381
  <Flow
377
382
  key={eventId}
378
383
  eventId={eventId}
@@ -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
 
@@ -115,7 +122,7 @@ export const AuthModal: FC<AuthModalProps> = ({
115
122
  if (event.target === event.currentTarget && !auth.isSubmitting) onClose()
116
123
  }
117
124
  const isPasswordReset = auth.view === 'forgot-password'
118
- const title = isPasswordReset ? 'Password reset' : 'Ticket Fairy account'
125
+ const title = isPasswordReset ? t('auth.passwordReset') : t('auth.title')
119
126
 
120
127
  return (
121
128
  <div className={cx('tf-checkout-auth', className)} onMouseDown={closeFromBackdrop}>
@@ -153,7 +160,7 @@ export const AuthModal: FC<AuthModalProps> = ({
153
160
  )}
154
161
  onClick={() => auth.showLogin(auth.suggestedEmail)}
155
162
  >
156
- Login
163
+ {t('auth.logIn')}
157
164
  </button>
158
165
  <button
159
166
  type="button"
@@ -165,7 +172,7 @@ export const AuthModal: FC<AuthModalProps> = ({
165
172
  )}
166
173
  onClick={() => auth.showRegister(auth.suggestedEmail)}
167
174
  >
168
- Sign Up
175
+ {t('auth.signUp')}
169
176
  </button>
170
177
  </div>
171
178
  )}
@@ -47,7 +47,7 @@ export const ForgotPasswordForm: FC<ForgotPasswordFormProps> = ({
47
47
  {successMessage}
48
48
  </div>
49
49
  <Button type="button" block onClick={() => onBackToLogin(email)}>
50
- Back to log in
50
+ {t('auth.backToLogin')}
51
51
  </Button>
52
52
  </div>
53
53
  )
@@ -55,9 +55,7 @@ export const ForgotPasswordForm: FC<ForgotPasswordFormProps> = ({
55
55
 
56
56
  return (
57
57
  <form className="tf-checkout-auth__form" onSubmit={submit} noValidate>
58
- <p className="tf-checkout-auth__intro">
59
- Enter your email and we’ll send you instructions to reset your password.
60
- </p>
58
+ <p className="tf-checkout-auth__intro">{t('auth.forgotIntro')}</p>
61
59
  {error && (
62
60
  <div className="tf-checkout-auth__alert" role="alert">
63
61
  {error}
@@ -79,14 +77,14 @@ export const ForgotPasswordForm: FC<ForgotPasswordFormProps> = ({
79
77
  />
80
78
  </Field>
81
79
  <Button type="submit" block loading={loading}>
82
- Send reset instructions
80
+ {t('auth.sendResetInstructions')}
83
81
  </Button>
84
82
  <button
85
83
  type="button"
86
84
  className="tf-checkout-auth__text-button tf-checkout-auth__back"
87
85
  onClick={() => onBackToLogin(email)}
88
86
  >
89
- Back to log in
87
+ {t('auth.backToLogin')}
90
88
  </button>
91
89
  </form>
92
90
  )
@@ -88,7 +88,7 @@ export const LoginForm: FC<LoginFormProps> = ({
88
88
  />
89
89
  </Field>
90
90
  <Button type="submit" block loading={loading}>
91
- Login
91
+ {t('auth.logIn')}
92
92
  </Button>
93
93
  {allowPasswordReset && onForgotPassword && (
94
94
  <button
@@ -96,7 +96,7 @@ export const LoginForm: FC<LoginFormProps> = ({
96
96
  className="tf-checkout-auth__text-button tf-checkout-auth__forgot"
97
97
  onClick={() => onForgotPassword(email)}
98
98
  >
99
- Forgot password?
99
+ {t('auth.forgotPassword')}
100
100
  </button>
101
101
  )}
102
102
  </form>
@@ -236,7 +236,7 @@ export const RegisterForm: FC<RegisterFormProps> = ({
236
236
  onChange={(event) => setField('stateId', event.target.value)}
237
237
  >
238
238
  <option value="">
239
- {states.length > 0 ? 'Select state / region' : 'Not required'}
239
+ {states.length > 0 ? t('auth.selectState') : t('auth.notRequired')}
240
240
  </option>
241
241
  {states.map((state) => (
242
242
  <option key={state.id} value={state.id}>
@@ -326,7 +326,7 @@ export const RegisterForm: FC<RegisterFormProps> = ({
326
326
  </Field>
327
327
  </FieldRow>
328
328
  <Button type="submit" block loading={loading}>
329
- Create Account
329
+ {t('auth.createAccount')}
330
330
  </Button>
331
331
  </form>
332
332
  )
@@ -28,14 +28,14 @@ export const ResetPasswordForm: FC<ResetPasswordFormProps> = ({
28
28
  const validation = useMemo(() => {
29
29
  if (!submitted) return {}
30
30
  return {
31
- token: token ? '' : 'This password-reset link is missing its token.',
31
+ token: token ? '' : t('auth.missingToken'),
32
32
  password:
33
33
  password.length < 8
34
- ? 'Password must have at least 8 characters.'
34
+ ? t('auth.passwordTooShort')
35
35
  : !/[@$!%*#?&]/.test(password)
36
- ? 'Password must contain at least one special character.'
36
+ ? t('auth.passwordNeedsSpecial')
37
37
  : '',
38
- confirmation: password !== confirmation ? 'Passwords must match.' : '',
38
+ confirmation: password !== confirmation ? t('auth.passwordsMustMatch') : '',
39
39
  }
40
40
  }, [confirmation, password, submitted, token])
41
41
 
@@ -58,11 +58,11 @@ export const ResetPasswordForm: FC<ResetPasswordFormProps> = ({
58
58
  return (
59
59
  <div className="tf-checkout-auth__form">
60
60
  <div className="tf-checkout-auth__success" role="status">
61
- Your password has been changed. You can now log in.
61
+ {t('auth.passwordChanged')}
62
62
  </div>
63
63
  {onBackToLogin && (
64
64
  <Button type="button" block onClick={onBackToLogin}>
65
- Log in
65
+ {t('auth.logIn')}
66
66
  </Button>
67
67
  )}
68
68
  </div>
@@ -105,7 +105,7 @@ export const ResetPasswordForm: FC<ResetPasswordFormProps> = ({
105
105
  />
106
106
  </Field>
107
107
  <Button type="submit" block loading={reset.isPending} disabled={!token}>
108
- Submit
108
+ {t('common.submit')}
109
109
  </Button>
110
110
  </form>
111
111
  )
package/src/configure.ts CHANGED
@@ -5,11 +5,7 @@
5
5
  *
6
6
  * configureCheckout({ env: 'PROD', clientId, clientSecret })
7
7
  */
8
- import {
9
- configure,
10
- type StorageAdapter,
11
- type TtfConfig,
12
- } from 'tf-checkout-shared'
8
+ import { configure, type StorageAdapter, type TtfConfig } from 'tf-checkout-shared'
13
9
 
14
10
  const isBrowser =
15
11
  typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
package/src/confirmer.ts CHANGED
@@ -17,11 +17,7 @@ import {
17
17
  type StripeCardElement,
18
18
  type StripeElements,
19
19
  } from '@stripe/stripe-js'
20
- import type {
21
- CheckoutBilling,
22
- CheckoutPorts,
23
- PaymentConfirmer,
24
- } from 'tf-checkout-shared'
20
+ import type { CheckoutBilling, CheckoutPorts, PaymentConfirmer } from 'tf-checkout-shared'
25
21
 
26
22
  import type { WebXenditTokenizer } from './xendit'
27
23
 
@@ -21,6 +21,7 @@ import {
21
21
  import { Button } from '../ui/Button'
22
22
  import { Card } from '../ui/Card'
23
23
  import { useCheckoutContext } from '../ui/CheckoutProvider'
24
+ import { useT } from '../ui/I18nProvider'
24
25
  import { CustomFieldGroupFields } from '../ui/CustomFields'
25
26
  import { Loader } from '../ui/Loader'
26
27
  import { PriceLine } from '../ui/PriceLine'
@@ -45,12 +46,16 @@ export const AddOns: FC<AddOnsProps> = ({
45
46
  eventId,
46
47
  onContinue,
47
48
  onSkip,
48
- buttonLabel = 'CONFIRM SELECTION',
49
- eyebrow = 'Get Your Tickets',
50
- heading = 'UPGRADES & ADD-ONS',
49
+ buttonLabel,
50
+ eyebrow,
51
+ heading,
51
52
  className,
52
53
  }) => {
54
+ const t = useT()
53
55
  const checkout = useCheckoutContext()
56
+ const buttonText = buttonLabel ?? t('addons.confirmSelection')
57
+ const eyebrowText = eyebrow ?? t('addons.eyebrow')
58
+ const headingText = heading ?? t('addons.heading')
54
59
  // No onContinue ⇒ combined form (inside <EmbeddedCheckout/>): apply the selection live
55
60
  // (no "confirm" click) — the old checkout applied add-ons at checkout too.
56
61
  const combined = !onContinue
@@ -97,8 +102,8 @@ export const AddOns: FC<AddOnsProps> = ({
97
102
  const hasRequired = (checkout.requiredAddOns ?? []).some((a) => a.required)
98
103
  const head = (
99
104
  <div className="tf-checkout-addons__head">
100
- {eyebrow && <span className="tf-checkout-addons__eyebrow">{eyebrow}</span>}
101
- <span className="tf-checkout-addons__heading">{heading}</span>
105
+ {eyebrowText && <span className="tf-checkout-addons__eyebrow">{eyebrowText}</span>}
106
+ <span className="tf-checkout-addons__heading">{headingText}</span>
102
107
  {onSkip && !hasRequired && (
103
108
  <button type="button" className="tf-checkout-addons__skip" onClick={onSkip}>
104
109
  Skip
@@ -190,7 +195,9 @@ export const AddOns: FC<AddOnsProps> = ({
190
195
  )}
191
196
  </div>
192
197
  {soldOut(a) ? (
193
- <span className="tf-checkout-tickets__soldout">Sold out</span>
198
+ <span className="tf-checkout-tickets__soldout">
199
+ {t('tickets.soldOut')}
200
+ </span>
194
201
  ) : (
195
202
  <QuantitySelect
196
203
  value={n}
@@ -227,7 +234,7 @@ export const AddOns: FC<AddOnsProps> = ({
227
234
  loading={checkout.isBusy}
228
235
  disabled={checkout.isBusy || selectedCount === 0}
229
236
  >
230
- {checkout.isBusy ? 'Updating…' : buttonLabel}
237
+ {checkout.isBusy ? t('addons.updating') : buttonText}
231
238
  </Button>
232
239
  )}
233
240
  {missing.length > 0 && (
@@ -34,10 +34,12 @@ import { PhoneInput, type CountryIso2 } from 'react-international-phone'
34
34
  import { Button } from '../ui/Button'
35
35
  import { Card, SectionHeader } from '../ui/Card'
36
36
  import { useCheckoutContext, useCheckoutValidation } from '../ui/CheckoutProvider'
37
+ import { useT } from '../ui/I18nProvider'
37
38
  import { CustomFieldInput } from '../ui/CustomFields'
38
39
  import { Checkbox, Field, FieldRow, Input } from '../ui/Field'
39
40
  import { BillingAddressFields } from './billing/BillingAddressFields'
40
41
  import { BuyerField } from './billing/BuyerField'
42
+ import { DOB_MIN, isBlankPhone, toDateInputValue, todayISO } from './billing/fieldRules'
41
43
  import { TicketHoldersCard } from './billing/TicketHoldersCard'
42
44
  import type { BillingDraft, HolderDraft } from './billing/types'
43
45
 
@@ -84,22 +86,26 @@ export interface CheckoutPrivacyPolicy {
84
86
  export const BillingInfo: FC<BillingInfoProps> = ({
85
87
  eventId,
86
88
  onContinue,
87
- heading = 'Billing Information',
88
- buttonLabel = 'Continue to payment',
89
+ heading,
90
+ buttonLabel,
89
91
  onLogin,
90
92
  skipWhenProfileComplete = false,
91
93
  requireEmailConfirmation = false,
92
94
  collectTicketHolders = false,
93
95
  collectHolderContact = false,
94
- ttfOptInLabel = 'Keep me updated about future events from The Ticket Fairy',
96
+ ttfOptInLabel,
95
97
  brandOptInLabel,
96
98
  privacyPolicy,
97
99
  showPrivacyError = false,
98
100
  step,
99
101
  className,
100
102
  }) => {
103
+ const t = useT()
101
104
  const checkout = useCheckoutContext()
102
105
  const validation = useCheckoutValidation()
106
+ const headingText = heading ?? t('billing.heading')
107
+ const buttonText = buttonLabel ?? t('billing.continueToPayment')
108
+ const ttfOptInText = ttfOptInLabel ?? t('billing.ttfOptIn')
103
109
  const profile = hooks.useProfile({ retry: false })
104
110
  const event = hooks.useEvent(eventId)
105
111
  // No onContinue ⇒ this is one combined form (inside <EmbeddedCheckout/>): mirror the
@@ -199,7 +205,8 @@ export const BillingInfo: FC<BillingInfoProps> = ({
199
205
  email: prev.email || profileBilling.email || '',
200
206
  confirmEmail:
201
207
  prev.confirmEmail || (checkout.isLoggedIn ? profileBilling.email : '') || '',
202
- phone: prev.phone || profileBilling.phone,
208
+ phone:
209
+ (isBlankPhone(prev.phone) ? undefined : prev.phone) || profileBilling.phone,
203
210
  streetAddress: prev.streetAddress || profileBilling.streetAddress,
204
211
  city: prev.city || profileBilling.city,
205
212
  zip: prev.zip || profileBilling.zip,
@@ -209,6 +216,16 @@ export const BillingInfo: FC<BillingInfoProps> = ({
209
216
  const keys = Object.keys(next) as (keyof BillingDraft)[]
210
217
  return keys.every((k) => next[k] === prev[k]) ? prev : next
211
218
  })
219
+ // Date of birth lives outside `billing`, so it has to be seeded here too —
220
+ // and it has to be here rather than in its own effect or in the useState
221
+ // initialiser: the initialiser runs before the profile resolves, and a
222
+ // separate effect would re-fire and overwrite the buyer, since the live
223
+ // commit below depends on `dob`. This effect already runs exactly once.
224
+ setDob((prev) => {
225
+ if (prev) return prev
226
+ const { dobYear, dobMonth, dobDay } = profileBilling
227
+ return toDateInputValue(dobYear, dobMonth, dobDay) ?? prev
228
+ })
212
229
  }, [profile.data, checkout.isLoggedIn])
213
230
 
214
231
  // Resize holders to the slot count, preserving typed values; tag ticket type.
@@ -297,13 +314,22 @@ export const BillingInfo: FC<BillingInfoProps> = ({
297
314
  countries.find((country) => country.code?.toLowerCase() === 'us')
298
315
  if (!preferred) return
299
316
  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
- }))
317
+ setBilling((current) => {
318
+ // The profile-seed effect above can run in this same commit, so the
319
+ // `billing` this effect closed over is stale — it still shows no country
320
+ // even though the seed just supplied one (with its state). Re-check the
321
+ // live value here: without it the default overwrote the seeded country
322
+ // and blanked `stateId`, which is why a returning buyer's State/County
323
+ // came back empty while every other saved field prefilled.
324
+ if (current.countryId) return current
325
+ return {
326
+ ...current,
327
+ countryId: String(preferred.id),
328
+ countryCode: preferred.code?.toUpperCase(),
329
+ stateId: undefined,
330
+ stateCode: undefined,
331
+ }
332
+ })
307
333
  }, [
308
334
  billing.countryId,
309
335
  countries,
@@ -342,28 +368,41 @@ export const BillingInfo: FC<BillingInfoProps> = ({
342
368
  const patchHolder = (i: number, patch: Partial<HolderDraft>) =>
343
369
  setHolders((hs) => hs.map((h, j) => (j === i ? { ...h, ...patch } : h)))
344
370
 
371
+ const dobMax = useMemo(todayISO, [])
345
372
  const [dobYear, dobMonth, dobDay] = dob ? dob.split('-').map(Number) : []
373
+ // A dial code on its own is not a phone number — see `isBlankPhone`.
374
+ const buyerPhone = isBlankPhone(billing.phone) ? undefined : billing.phone
346
375
  const committedBilling: CheckoutBilling = {
347
376
  ...checkout.billing,
348
377
  ...billing,
378
+ phone: buyerPhone,
349
379
  stateRequired: gates.showAddress && Boolean(billing.countryId) && states.length > 0,
350
380
  dataCapture,
351
381
  dobDay,
352
382
  dobMonth,
353
383
  dobYear,
354
384
  }
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
- }))
385
+ const committedHolders: CheckoutHolder[] = holders.map((holder) => {
386
+ const holderPhone = isBlankPhone(holder.phone) ? '' : holder.phone
387
+ return {
388
+ firstName: holderNamesRequired
389
+ ? holder.firstName
390
+ : holder.firstName || billing.firstName,
391
+ lastName: holderNamesRequired
392
+ ? holder.lastName
393
+ : holder.lastName || billing.lastName,
394
+ email:
395
+ showHolders && collectHolderContact
396
+ ? holder.email
397
+ : holder.email || billing.email,
398
+ phone:
399
+ showHolders && collectHolderContact
400
+ ? holderPhone
401
+ : holderPhone || buyerPhone || '',
402
+ ticketTypeId: holder.ticketTypeId,
403
+ ticketDataCapture: holder.ticketDataCapture,
404
+ }
405
+ })
367
406
  const missingDetails = missingCheckoutDetails(
368
407
  {
369
408
  billing: committedBilling,
@@ -383,7 +422,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
383
422
  const complete = missingDetails.length === 0
384
423
  const showValidation = validation.attempt > 0
385
424
  const missingSet = new Set(missingDetails)
386
- const fieldError = (key: string, message = 'This field is required.') =>
425
+ const fieldError = (key: string, message = t('billing.fieldRequired')) =>
387
426
  showValidation && missingSet.has(key) ? message : undefined
388
427
  const dobError = showValidation
389
428
  ? missingDetails.find(
@@ -399,7 +438,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
399
438
  const commit = () => {
400
439
  checkout.setBilling(committedBilling)
401
440
  checkout.setHolders(committedHolders)
402
- if (ttfOptInLabel || brandOptInLabel) checkout.setConsent(consent)
441
+ if (ttfOptInText || brandOptInLabel) checkout.setConsent(consent)
403
442
  }
404
443
 
405
444
  // Combined mode: keep the engine in sync as the form is filled, so <Payment/>
@@ -451,7 +490,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
451
490
  return (
452
491
  <>
453
492
  <Card className={className}>
454
- <SectionHeader step={step} title={heading} />
493
+ <SectionHeader step={step} title={headingText} />
455
494
 
456
495
  {onLogin && !checkout.isLoggedIn && (
457
496
  <div className="tf-checkout-billing__login">
@@ -472,7 +511,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
472
511
  invalid={Boolean(fieldError('First name'))}
473
512
  >
474
513
  <Input
475
- placeholder="First name *"
514
+ placeholder={t('billing.firstNamePlaceholder')}
476
515
  value={billing.firstName}
477
516
  required
478
517
  invalid={Boolean(fieldError('First name'))}
@@ -485,7 +524,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
485
524
  invalid={Boolean(fieldError('Last name'))}
486
525
  >
487
526
  <Input
488
- placeholder="Last name *"
527
+ placeholder={t('billing.lastNamePlaceholder')}
489
528
  value={billing.lastName}
490
529
  required
491
530
  invalid={Boolean(fieldError('Last name'))}
@@ -502,7 +541,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
502
541
  <Input
503
542
  type="email"
504
543
  autoComplete="email"
505
- placeholder="Email *"
544
+ placeholder={t('billing.emailPlaceholder')}
506
545
  value={billing.email}
507
546
  required
508
547
  invalid={Boolean(fieldError('Valid email address'))}
@@ -514,14 +553,14 @@ export const BillingInfo: FC<BillingInfoProps> = ({
514
553
  <Field
515
554
  error={fieldError(
516
555
  'Matching email confirmation',
517
- 'Email addresses must match.'
556
+ t('billing.emailMustMatch')
518
557
  )}
519
558
  invalid={Boolean(fieldError('Matching email confirmation'))}
520
559
  >
521
560
  <Input
522
561
  type="email"
523
562
  autoComplete="email"
524
- placeholder="Confirm email *"
563
+ placeholder={t('billing.confirmEmailPlaceholder')}
525
564
  value={billing.confirmEmail ?? ''}
526
565
  required
527
566
  invalid={Boolean(fieldError('Matching email confirmation'))}
@@ -536,21 +575,21 @@ export const BillingInfo: FC<BillingInfoProps> = ({
536
575
  billing.confirmEmail &&
537
576
  billing.confirmEmail.trim().toLowerCase() !==
538
577
  billing.email.trim().toLowerCase() && (
539
- <p className="tf-checkout-error-text">The email addresses do not match.</p>
578
+ <p className="tf-checkout-error-text">{t('billing.emailMismatch')}</p>
540
579
  )}
541
580
  {emailTaken && (
542
581
  <p className="tf-checkout-note tf-checkout-billing__email-taken">
543
- This email is already registered.{' '}
582
+ {t('billing.emailRegistered')}{' '}
544
583
  {onLogin ? (
545
584
  <button
546
585
  type="button"
547
586
  className="tf-checkout-billing__login-link"
548
587
  onClick={() => onLogin(billing.email)}
549
588
  >
550
- Log in to autofill your details
589
+ {t('billing.loginAutofill')}
551
590
  </button>
552
591
  ) : (
553
- 'Log in to autofill your details.'
592
+ t('billing.loginAutofillPlain')
554
593
  )}
555
594
  </p>
556
595
  )}
@@ -564,18 +603,18 @@ export const BillingInfo: FC<BillingInfoProps> = ({
564
603
  countries={countries}
565
604
  error={fieldError(
566
605
  f.kind === 'nationality'
567
- ? 'Nationality'
606
+ ? t('billing.nationality')
568
607
  : f.kind === 'gender'
569
- ? 'Gender'
570
- : 'Instagram handle'
608
+ ? t('billing.gender')
609
+ : t('billing.instagramHandle')
571
610
  )}
572
611
  />
573
612
  ))}
574
613
 
575
614
  {gates.showPhone && (
576
615
  <Field
577
- label={`Phone${gates.phoneRequired ? ' *' : ''}`}
578
- error={fieldError('Valid phone number', 'Enter a valid phone number.')}
616
+ label={gates.phoneRequired ? t('billing.phone') : t('billing.phoneOptional')}
617
+ error={fieldError('Valid phone number', t('billing.phoneInvalid'))}
579
618
  invalid={Boolean(fieldError('Valid phone number'))}
580
619
  >
581
620
  <PhoneInput
@@ -584,13 +623,20 @@ export const BillingInfo: FC<BillingInfoProps> = ({
584
623
  fieldError('Valid phone number') ? ' tf-checkout-input--invalid' : ''
585
624
  }`}
586
625
  defaultCountry={phoneCountry}
626
+ // The flag owns the country; the input holds the national number
627
+ // only. Without `disableDialCodeAndPrefix` the empty field still
628
+ // expects a full international number, so a Mexican buyer typing
629
+ // "5512345678" has it read as a dial code and the country guess
630
+ // flips them to Brazil (+55) — submitting the wrong code.
631
+ disableDialCodeAndPrefix
632
+ showDisabledDialCodeAndPrefix
587
633
  disableDialCodePrefill
588
634
  value={billing.phone ?? ''}
589
635
  required={gates.phoneRequired}
590
- placeholder="Phone number"
636
+ placeholder={t('billing.phonePlaceholder')}
591
637
  inputProps={{
592
638
  autoComplete: 'tel',
593
- 'aria-label': 'Phone',
639
+ 'aria-label': t('billing.phoneAria'),
594
640
  'aria-invalid': Boolean(fieldError('Valid phone number')),
595
641
  }}
596
642
  onChange={(phone) => setBilling((current) => ({ ...current, phone }))}
@@ -600,14 +646,16 @@ export const BillingInfo: FC<BillingInfoProps> = ({
600
646
 
601
647
  {gates.showDob && (
602
648
  <Field
603
- label={`Date of birth *${
604
- gates.minimumAge ? ` (must be ${gates.minimumAge}+)` : ''
605
- }`}
649
+ label={
650
+ gates.minimumAge
651
+ ? t('billing.dobWithAge', { age: gates.minimumAge })
652
+ : t('billing.dob')
653
+ }
606
654
  error={
607
655
  dobError === 'Date of birth'
608
- ? 'Date of birth is required.'
656
+ ? t('billing.dobRequired')
609
657
  : dobError
610
- ? `You must be ${gates.minimumAge} or older.`
658
+ ? t('billing.ageMinimum', { age: gates.minimumAge ?? '' })
611
659
  : undefined
612
660
  }
613
661
  invalid={Boolean(dobError)}
@@ -616,6 +664,12 @@ export const BillingInfo: FC<BillingInfoProps> = ({
616
664
  type="date"
617
665
  value={dob}
618
666
  required
667
+ // A date input with no bounds lets the browser's year segment run
668
+ // to six digits ("202555") and accept dates in the future. The
669
+ // range both caps the year at four digits and keeps the value a
670
+ // date someone could actually have been born on.
671
+ min={DOB_MIN}
672
+ max={dobMax}
619
673
  invalid={Boolean(dobError)}
620
674
  aria-invalid={Boolean(dobError)}
621
675
  onChange={(e) => setDob(e.target.value)}
@@ -673,24 +727,24 @@ export const BillingInfo: FC<BillingInfoProps> = ({
673
727
  className="tf-checkout-checkbox--consent"
674
728
  label={
675
729
  <>
676
- I have read and agree to {privacyPolicy.brandName}{' '}
730
+ {t('billing.privacyAgree', { brand: privacyPolicy.brandName })}{' '}
677
731
  <a href={privacyPolicy.url} target="_blank" rel="noreferrer">
678
- {privacyPolicy.linkLabel || 'Privacy Policy.'}
732
+ {privacyPolicy.linkLabel || t('billing.privacyLink')}
679
733
  </a>
680
734
  {' *'}
681
735
  </>
682
736
  }
683
737
  />
684
738
  {(showPrivacyError || showValidation) && !consent.ttfOptIn && (
685
- <div className="tf-checkout-consent__error">Required</div>
739
+ <div className="tf-checkout-consent__error">{t('common.required')}</div>
686
740
  )}
687
741
  </div>
688
- ) : ttfOptInLabel ? (
742
+ ) : ttfOptInText ? (
689
743
  <Checkbox
690
744
  checked={consent.ttfOptIn}
691
745
  onChange={(e) => setConsent((c) => ({ ...c, ttfOptIn: e.target.checked }))}
692
746
  className="tf-checkout-checkbox--consent"
693
- label={ttfOptInLabel}
747
+ label={ttfOptInText}
694
748
  />
695
749
  ) : null}
696
750
  {!combined && (
@@ -706,7 +760,7 @@ export const BillingInfo: FC<BillingInfoProps> = ({
706
760
  onContinue?.()
707
761
  }}
708
762
  >
709
- {buttonLabel}
763
+ {buttonText}
710
764
  </Button>
711
765
  </>
712
766
  )}