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.
@@ -3,6 +3,7 @@ import { PhoneInput, type CountryIso2 } from 'react-international-phone'
3
3
 
4
4
  import { Card, SectionHeader } from '../../ui/Card'
5
5
  import { Field, FieldRow, Input } from '../../ui/Field'
6
+ import { useT } from '../../ui/I18nProvider'
6
7
  import type { BillingFieldError, HolderDraft } from './types'
7
8
 
8
9
  interface TicketHoldersCardProps {
@@ -24,96 +25,109 @@ export const TicketHoldersCard: FC<TicketHoldersCardProps> = ({
24
25
  fieldError,
25
26
  renderCustomFields,
26
27
  patchHolder,
27
- }) => (
28
- <Card className="tf-checkout-billing__holders-card">
29
- <SectionHeader title="Ticket Holders" />
30
- {holders.map((holder, index) => {
31
- const firstNameKey = `Ticket ${index + 1} holder first name`
32
- const lastNameKey = `Ticket ${index + 1} holder last name`
33
- const emailKey = `Ticket ${index + 1} holder email`
34
- const phoneKey = `Ticket ${index + 1} holder phone`
28
+ }) => {
29
+ const t = useT()
35
30
 
36
- return (
37
- <div key={index} className="tf-checkout-billing__holder">
38
- <div className="tf-checkout-billing__holder-title">Ticket {index + 1}</div>
39
- {holderNamesRequired && (
40
- <FieldRow>
41
- <Field
42
- label="First Name *"
43
- error={fieldError(firstNameKey, 'First name is required.')}
44
- invalid={Boolean(fieldError(firstNameKey))}
45
- >
46
- <Input
47
- value={holder.firstName}
48
- required
31
+ return (
32
+ <Card className="tf-checkout-billing__holders-card">
33
+ <SectionHeader title={t('billing.ticketHolders')} />
34
+ {holders.map((holder, index) => {
35
+ const firstNameKey = `Ticket ${index + 1} holder first name`
36
+ const lastNameKey = `Ticket ${index + 1} holder last name`
37
+ const emailKey = `Ticket ${index + 1} holder email`
38
+ const phoneKey = `Ticket ${index + 1} holder phone`
39
+
40
+ return (
41
+ <div key={index} className="tf-checkout-billing__holder">
42
+ <div className="tf-checkout-billing__holder-title">
43
+ {t('billing.holderTitle', { number: index + 1 })}
44
+ </div>
45
+ {holderNamesRequired && (
46
+ <FieldRow>
47
+ <Field
48
+ label={t('billing.holderFirstName')}
49
+ error={fieldError(firstNameKey, t('billing.firstNameRequired'))}
49
50
  invalid={Boolean(fieldError(firstNameKey))}
50
- aria-invalid={Boolean(fieldError(firstNameKey))}
51
- onChange={(event) =>
52
- patchHolder(index, { firstName: event.target.value })
53
- }
54
- />
55
- </Field>
56
- <Field
57
- label="Last Name *"
58
- error={fieldError(lastNameKey, 'Last name is required.')}
59
- invalid={Boolean(fieldError(lastNameKey))}
60
- >
61
- <Input
62
- value={holder.lastName}
63
- required
51
+ >
52
+ <Input
53
+ value={holder.firstName}
54
+ required
55
+ invalid={Boolean(fieldError(firstNameKey))}
56
+ aria-invalid={Boolean(fieldError(firstNameKey))}
57
+ onChange={(event) =>
58
+ patchHolder(index, { firstName: event.target.value })
59
+ }
60
+ />
61
+ </Field>
62
+ <Field
63
+ label={t('billing.holderLastName')}
64
+ error={fieldError(lastNameKey, t('billing.lastNameRequired'))}
64
65
  invalid={Boolean(fieldError(lastNameKey))}
65
- aria-invalid={Boolean(fieldError(lastNameKey))}
66
- onChange={(event) =>
67
- patchHolder(index, { lastName: event.target.value })
68
- }
69
- />
70
- </Field>
71
- </FieldRow>
72
- )}
73
- {collectHolderContact && (
74
- <FieldRow>
75
- <Field
76
- label="Email Address *"
77
- error={fieldError(emailKey, 'Enter a valid email address.')}
78
- invalid={Boolean(fieldError(emailKey))}
79
- >
80
- <Input
81
- type="email"
82
- autoComplete="email"
83
- value={holder.email}
84
- required
66
+ >
67
+ <Input
68
+ value={holder.lastName}
69
+ required
70
+ invalid={Boolean(fieldError(lastNameKey))}
71
+ aria-invalid={Boolean(fieldError(lastNameKey))}
72
+ onChange={(event) =>
73
+ patchHolder(index, { lastName: event.target.value })
74
+ }
75
+ />
76
+ </Field>
77
+ </FieldRow>
78
+ )}
79
+ {collectHolderContact && (
80
+ <FieldRow>
81
+ <Field
82
+ label={t('billing.email')}
83
+ error={fieldError(emailKey, t('billing.emailInvalid'))}
85
84
  invalid={Boolean(fieldError(emailKey))}
86
- aria-invalid={Boolean(fieldError(emailKey))}
87
- onChange={(event) => patchHolder(index, { email: event.target.value })}
88
- />
89
- </Field>
90
- <Field
91
- label="Phone *"
92
- error={fieldError(phoneKey, 'Enter a valid phone number.')}
93
- invalid={Boolean(fieldError(phoneKey))}
94
- >
95
- <PhoneInput
96
- className="tf-checkout-phone"
97
- inputClassName={`tf-checkout-input tf-checkout-phone__input${
98
- fieldError(phoneKey) ? ' tf-checkout-input--invalid' : ''
99
- }`}
100
- defaultCountry={phoneCountry}
101
- disableDialCodePrefill
102
- value={holder.phone}
103
- required
104
- inputProps={{
105
- autoComplete: 'tel',
106
- 'aria-label': phoneKey,
107
- 'aria-invalid': Boolean(fieldError(phoneKey)),
108
- }}
109
- onChange={(phone) => patchHolder(index, { phone })}
110
- />
111
- </Field>
112
- </FieldRow>
113
- )}
114
- {renderCustomFields(holder, index)}
115
- </div>
116
- )
117
- })}
118
- </Card>
119
- )
85
+ >
86
+ <Input
87
+ type="email"
88
+ autoComplete="email"
89
+ value={holder.email}
90
+ required
91
+ invalid={Boolean(fieldError(emailKey))}
92
+ aria-invalid={Boolean(fieldError(emailKey))}
93
+ onChange={(event) =>
94
+ patchHolder(index, { email: event.target.value })
95
+ }
96
+ />
97
+ </Field>
98
+ <Field
99
+ label={t('billing.phone')}
100
+ error={fieldError(phoneKey, t('billing.phoneInvalid'))}
101
+ invalid={Boolean(fieldError(phoneKey))}
102
+ >
103
+ <PhoneInput
104
+ className="tf-checkout-phone"
105
+ inputClassName={`tf-checkout-input tf-checkout-phone__input${
106
+ fieldError(phoneKey) ? ' tf-checkout-input--invalid' : ''
107
+ }`}
108
+ defaultCountry={phoneCountry}
109
+ // Matches the buyer phone field in ../BillingInfo.tsx — see
110
+ // the note there on why the dial code must stay out of the
111
+ // input.
112
+ disableDialCodeAndPrefix
113
+ showDisabledDialCodeAndPrefix
114
+ disableDialCodePrefill
115
+ value={holder.phone}
116
+ required
117
+ inputProps={{
118
+ autoComplete: 'tel',
119
+ 'aria-label': phoneKey,
120
+ 'aria-invalid': Boolean(fieldError(phoneKey)),
121
+ }}
122
+ onChange={(phone) => patchHolder(index, { phone })}
123
+ />
124
+ </Field>
125
+ </FieldRow>
126
+ )}
127
+ {renderCustomFields(holder, index)}
128
+ </div>
129
+ )
130
+ })}
131
+ </Card>
132
+ )
133
+ }
@@ -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
@@ -148,7 +148,11 @@ export const CheckoutProvider: FC<CheckoutProviderProps> = ({
148
148
  // binding); absent ⇒ the self-providing branch owns it. The choice is
149
149
  // static per mount, so the two branches never swap and violate the rules
150
150
  // of hooks.
151
- value ? <CheckoutRoot checkout={value} {...rest} /> : <SelfProvidedCheckout {...rest} />
151
+ value ? (
152
+ <CheckoutRoot checkout={value} {...rest} />
153
+ ) : (
154
+ <SelfProvidedCheckout {...rest} />
155
+ )
152
156
  }
153
157
  </I18nProvider>
154
158
  )
@@ -10,8 +10,10 @@ import React, { useCallback, useState } from 'react'
10
10
  import type { InstallmentOffer } from 'tf-checkout-shared'
11
11
 
12
12
  import { Button } from './Button'
13
+ import { useT } from './I18nProvider'
13
14
 
14
15
  export const useInstallmentPicker = () => {
16
+ const t = useT()
15
17
  const [pending, setPending] = useState<{
16
18
  offers: InstallmentOffer[]
17
19
  resolve: (o: InstallmentOffer | null) => void
@@ -34,10 +36,10 @@ export const useInstallmentPicker = () => {
34
36
  <div
35
37
  className="tf-checkout-installments"
36
38
  role="dialog"
37
- aria-label="Choose installments"
39
+ aria-label={t('payment.chooseInstallments')}
38
40
  >
39
41
  <div className="tf-checkout-installments__box">
40
- <strong>Pay in installments?</strong>
42
+ <strong>{t('payment.payInInstallments')}</strong>
41
43
  <div className="tf-checkout-installments__list">
42
44
  {pending.offers.map((o, i) => (
43
45
  <button
@@ -9,15 +9,13 @@
9
9
  import React, { type FC, type ReactNode } from 'react'
10
10
 
11
11
  import { cx } from './cx'
12
+ import { useT } from './I18nProvider'
12
13
  import { Field, Input } from './Field'
13
14
 
14
15
  // The method descriptor + row shape live in the SDK so both platforms render
15
16
  // the SAME option list. Imported for local use and re-exported for consumers
16
17
  // that already import them from here.
17
- import type {
18
- PaymentMethodId,
19
- PaymentMethodOption,
20
- } from 'tf-checkout-shared'
18
+ import type { PaymentMethodId, PaymentMethodOption } from 'tf-checkout-shared'
21
19
 
22
20
  export type { PaymentMethodId, PaymentMethodOption }
23
21
 
@@ -55,79 +53,87 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
55
53
  children,
56
54
  inputSlotFor,
57
55
  className,
58
- }) => (
59
- <div
60
- className={cx('tf-checkout-payment__methods', className)}
61
- role="radiogroup"
62
- aria-label="Payment method"
63
- >
64
- {options.map((o) => {
65
- const isSelected = o.value === selected
66
- const extraValue =
67
- o.extra === 'phoneNumber'
68
- ? extraValues[o.value]?.phoneNumber
69
- : extraValues[o.value]?.cashtag
70
- const extraInvalid =
71
- Boolean(isSelected && o.extra && showValidation) && !extraValue?.trim()
72
- return (
73
- <label
74
- key={o.value}
75
- className={cx(
76
- 'tf-checkout-payment__method',
77
- isSelected && 'tf-checkout-payment__method--selected'
78
- )}
79
- >
80
- <span className="tf-checkout-payment__method-head">
81
- <input
82
- type="radio"
83
- name="tf-pay-method"
84
- className="tf-checkout-payment__method-radio"
85
- checked={isSelected}
86
- onChange={() => onSelect(o.value)}
87
- />
88
- {o.iconUrl && (
89
- <img className="tf-checkout-payment__method-icon" src={o.iconUrl} alt="" />
56
+ }) => {
57
+ const t = useT()
58
+
59
+ return (
60
+ <div
61
+ className={cx('tf-checkout-payment__methods', className)}
62
+ role="radiogroup"
63
+ aria-label={t('payment.method')}
64
+ >
65
+ {options.map((o) => {
66
+ const isSelected = o.value === selected
67
+ const extraValue =
68
+ o.extra === 'phoneNumber'
69
+ ? extraValues[o.value]?.phoneNumber
70
+ : extraValues[o.value]?.cashtag
71
+ const extraInvalid =
72
+ Boolean(isSelected && o.extra && showValidation) && !extraValue?.trim()
73
+ return (
74
+ <label
75
+ key={o.value}
76
+ className={cx(
77
+ 'tf-checkout-payment__method',
78
+ isSelected && 'tf-checkout-payment__method--selected'
90
79
  )}
91
- <span className="tf-checkout-payment__method-label">{o.label}</span>
92
- </span>
80
+ >
81
+ <span className="tf-checkout-payment__method-head">
82
+ <input
83
+ type="radio"
84
+ name="tf-pay-method"
85
+ className="tf-checkout-payment__method-radio"
86
+ checked={isSelected}
87
+ onChange={() => onSelect(o.value)}
88
+ />
89
+ {o.iconUrl && (
90
+ <img
91
+ className="tf-checkout-payment__method-icon"
92
+ src={o.iconUrl}
93
+ alt=""
94
+ />
95
+ )}
96
+ <span className="tf-checkout-payment__method-label">{o.label}</span>
97
+ </span>
93
98
 
94
- {isSelected && (o.value === inputSlotFor || o.extra) && (
95
- <div className="tf-checkout-payment__method-body">
96
- {o.value === inputSlotFor && children}
97
- {o.extra && (
98
- <Field
99
- label={`${EXTRA_LABEL[o.extra]} *`}
100
- error={extraInvalid ? 'This field is required.' : undefined}
101
- invalid={extraInvalid}
102
- >
103
- <Input
104
- inputMode={o.extra === 'phoneNumber' ? 'tel' : 'text'}
105
- required
99
+ {isSelected && (o.value === inputSlotFor || o.extra) && (
100
+ <div className="tf-checkout-payment__method-body">
101
+ {o.value === inputSlotFor && children}
102
+ {o.extra && (
103
+ <Field
104
+ label={`${EXTRA_LABEL[o.extra]} *`}
105
+ error={extraInvalid ? t('billing.fieldRequired') : undefined}
106
106
  invalid={extraInvalid}
107
- aria-invalid={extraInvalid}
108
- placeholder={
109
- o.extra === 'phoneNumber' ? 'e.g. 081234567890' : '$cashtag'
110
- }
111
- value={
112
- o.extra === 'phoneNumber'
113
- ? (extraValues[o.value]?.phoneNumber ?? '')
114
- : (extraValues[o.value]?.cashtag ?? '')
115
- }
116
- onChange={(e) =>
117
- onExtraChange(
118
- o.value,
107
+ >
108
+ <Input
109
+ inputMode={o.extra === 'phoneNumber' ? 'tel' : 'text'}
110
+ required
111
+ invalid={extraInvalid}
112
+ aria-invalid={extraInvalid}
113
+ placeholder={
114
+ o.extra === 'phoneNumber' ? 'e.g. 081234567890' : '$cashtag'
115
+ }
116
+ value={
119
117
  o.extra === 'phoneNumber'
120
- ? { phoneNumber: e.target.value }
121
- : { cashtag: e.target.value }
122
- )
123
- }
124
- />
125
- </Field>
126
- )}
127
- </div>
128
- )}
129
- </label>
130
- )
131
- })}
132
- </div>
133
- )
118
+ ? (extraValues[o.value]?.phoneNumber ?? '')
119
+ : (extraValues[o.value]?.cashtag ?? '')
120
+ }
121
+ onChange={(e) =>
122
+ onExtraChange(
123
+ o.value,
124
+ o.extra === 'phoneNumber'
125
+ ? { phoneNumber: e.target.value }
126
+ : { cashtag: e.target.value }
127
+ )
128
+ }
129
+ />
130
+ </Field>
131
+ )}
132
+ </div>
133
+ )}
134
+ </label>
135
+ )
136
+ })}
137
+ </div>
138
+ )
139
+ }
@@ -7,6 +7,7 @@
7
7
  import React, { useState, type FC } from 'react'
8
8
 
9
9
  import { cx } from './cx'
10
+ import { useT } from './I18nProvider'
10
11
 
11
12
  const clock = (s: number) => {
12
13
  const m = String(Math.floor(s / 60)).padStart(2, '0')
@@ -25,9 +26,10 @@ export interface TimerBannerProps {
25
26
  export const TimerBanner: FC<TimerBannerProps> = ({
26
27
  seconds,
27
28
  urgentAt = 60,
28
- message = 'Please complete your purchase before the timer reaches zero.',
29
+ message,
29
30
  className,
30
31
  }) => {
32
+ const t = useT()
31
33
  const [dismissed, setDismissed] = useState(false)
32
34
  if (seconds === undefined || seconds <= 0 || dismissed) return null
33
35
  const urgent = seconds <= urgentAt
@@ -40,12 +42,14 @@ export const TimerBanner: FC<TimerBannerProps> = ({
40
42
  className
41
43
  )}
42
44
  >
43
- <span className="tf-checkout-timerbanner__msg">{message}</span>
45
+ <span className="tf-checkout-timerbanner__msg">
46
+ {message ?? t('timer.completePurchase')}
47
+ </span>
44
48
  <span className="tf-checkout-timerbanner__clock">{clock(seconds)}</span>
45
49
  <button
46
50
  type="button"
47
51
  className="tf-checkout-timerbanner__close"
48
- aria-label="Dismiss"
52
+ aria-label={t('common.dismiss')}
49
53
  onClick={() => setDismissed(true)}
50
54
  >
51
55
  ×