tf-checkout-react 2.0.0-beta.1 → 2.0.0-beta.3

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.
@@ -24,6 +24,7 @@ import {
24
24
  import { Button } from '../ui/Button'
25
25
  import { Card, SectionHeader } from '../ui/Card'
26
26
  import { useCheckoutContext } from '../ui/CheckoutProvider'
27
+ import { useT } from '../ui/I18nProvider'
27
28
  import { cx } from '../ui/cx'
28
29
  import { Field, FieldRow, Input } from '../ui/Field'
29
30
  import { Loader } from '../ui/Loader'
@@ -34,6 +35,7 @@ const WaitingListForm: FC<{
34
35
  ticket: TicketType
35
36
  onJoin: (entry: WaitingListEntry) => Promise<void>
36
37
  }> = ({ ticket, onJoin }) => {
38
+ const tr = useT()
37
39
  const [form, setForm] = useState({ firstName: '', lastName: '', email: '' })
38
40
  const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle')
39
41
  const [error, setError] = useState('')
@@ -42,7 +44,7 @@ const WaitingListForm: FC<{
42
44
  if (state === 'done')
43
45
  return (
44
46
  <p className="tf-checkout-note tf-checkout-tickets__wl-done">
45
- You’re on the waiting list — we’ll email you if a spot opens.
47
+ {tr('tickets.waitingListJoined')}
46
48
  </p>
47
49
  )
48
50
 
@@ -51,14 +53,14 @@ const WaitingListForm: FC<{
51
53
  <FieldRow>
52
54
  <Field>
53
55
  <Input
54
- placeholder="First name"
56
+ placeholder={tr('tickets.firstName')}
55
57
  value={form.firstName}
56
58
  onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
57
59
  />
58
60
  </Field>
59
61
  <Field>
60
62
  <Input
61
- placeholder="Last name"
63
+ placeholder={tr('tickets.lastName')}
62
64
  value={form.lastName}
63
65
  onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
64
66
  />
@@ -66,7 +68,7 @@ const WaitingListForm: FC<{
66
68
  </FieldRow>
67
69
  <Field>
68
70
  <Input
69
- placeholder="Email"
71
+ placeholder={tr('tickets.email')}
70
72
  value={form.email}
71
73
  onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
72
74
  />
@@ -86,7 +88,7 @@ const WaitingListForm: FC<{
86
88
  disabled={!ok || state === 'busy'}
87
89
  variant="secondary"
88
90
  >
89
- Join the waiting list
91
+ {tr('tickets.joinWaitingList')}
90
92
  </Button>
91
93
  {error && <p className="tf-checkout-error-text">{error}</p>}
92
94
  </div>
@@ -124,7 +126,7 @@ export const Tickets: FC<TicketsProps> = ({
124
126
  eventId,
125
127
  title = false,
126
128
  onContinue,
127
- buttonLabel = 'Get tickets',
129
+ buttonLabel,
128
130
  timeSlotDates,
129
131
  step,
130
132
  onSelectSeats,
@@ -133,7 +135,9 @@ export const Tickets: FC<TicketsProps> = ({
133
135
  readAttributionFromUrl = true,
134
136
  className,
135
137
  }) => {
138
+ const tr = useT()
136
139
  const checkout = useCheckoutContext()
140
+ const buttonText = buttonLabel ?? tr('tickets.getTickets')
137
141
  const ticketsQuery = hooks.useTicketsWithMeta(eventId)
138
142
  const promoMeta = ticketsQuery.data?.promo
139
143
  const showWaitingList = ticketsQuery.data?.showWaitingList ?? false
@@ -256,7 +260,9 @@ export const Tickets: FC<TicketsProps> = ({
256
260
  // Types that can still be bought with a quantity dropdown (all of them for a
257
261
  // non-seated event). When empty on a seated event, only "Select seats" shows.
258
262
  const directPurchasable = tickets.filter((t) => !seatMapRow(t))
259
- const seatCtaLabel = eventInfo?.tableMapEnabled ? 'Select on map' : 'Select seats'
263
+ const seatCtaLabel = eventInfo?.tableMapEnabled
264
+ ? tr('tickets.selectOnMap')
265
+ : tr('tickets.selectSeats')
260
266
 
261
267
  // A "Tables" divider before the first table type — only when regular tickets
262
268
  // actually precede it (a tables-first list gets no stray divider).
@@ -312,7 +318,9 @@ export const Tickets: FC<TicketsProps> = ({
312
318
  <div className="tf-checkout-tickets__group">{t.groupName}</div>
313
319
  ) : (
314
320
  t.id === firstTableId && (
315
- <div className="tf-checkout-tickets__group">Reserve tables</div>
321
+ <div className="tf-checkout-tickets__group">
322
+ {tr('tickets.reserveTables')}
323
+ </div>
316
324
  )
317
325
  )}
318
326
  <div className="tf-checkout-tickets__row-wrap">
@@ -330,10 +338,17 @@ export const Tickets: FC<TicketsProps> = ({
330
338
  setVisibleDescription((current) => (current === t.id ? null : t.id))
331
339
  }
332
340
  >
333
- {visibleDescription === t.id ? 'Hide info' : 'View info'}
341
+ {visibleDescription === t.id
342
+ ? tr('tickets.hideInfo')
343
+ : tr('tickets.viewInfo')}
334
344
  </button>
335
345
  )}
336
346
  </div>
347
+ {/* Only on a type that can be bought, as the old checkout does —
348
+ "Selling fast" beside "Sold out" contradicts itself. */}
349
+ {t.badgeText && !unavailable && (
350
+ <span className="tf-checkout-tickets__badge">{t.badgeText}</span>
351
+ )}
337
352
  <div className="tf-checkout-tickets__price">
338
353
  {discounted && (
339
354
  <span className="tf-checkout-tickets__old">
@@ -346,18 +361,18 @@ export const Tickets: FC<TicketsProps> = ({
346
361
  isFree && 'tf-checkout-tickets__amount--free'
347
362
  )}
348
363
  >
349
- {isFree ? 'FREE' : money(sym, t.price)}
364
+ {isFree ? tr('tickets.free') : money(sym, t.price)}
350
365
  </span>
351
366
  {/* Fees sub-line (the "(… with fees)" the old checkout shows) —
352
367
  omitted for free/sold-out types. */}
353
368
  {!isFree && !t.soldOut && (
354
369
  <span className="tf-checkout-tickets__fees">
355
- ({money(sym, t.priceWithFees)} with fees)
370
+ {tr('tickets.withFees', { price: money(sym, t.priceWithFees) })}
356
371
  </span>
357
372
  )}
358
373
  {t.depositPercent != null && (
359
374
  <span className="tf-checkout-tickets__deposit">
360
- {t.depositPercent.toFixed(2)}% DEPOSIT
375
+ {tr('tickets.deposit', { percent: t.depositPercent.toFixed(2) })}
361
376
  </span>
362
377
  )}
363
378
  </div>
@@ -367,17 +382,21 @@ export const Tickets: FC<TicketsProps> = ({
367
382
  {seatMapRow(t) ? null : unavailable ? (
368
383
  <span className="tf-checkout-tickets__soldout">
369
384
  {t.soldOut
370
- ? 'Sold out'
385
+ ? tr('tickets.soldOut')
371
386
  : notStarted
372
- ? 'Sales not started'
373
- : 'Sales Ended'}
387
+ ? tr('tickets.salesNotStarted')
388
+ : tr('tickets.salesEnded')}
374
389
  </span>
375
390
  ) : (
376
391
  <div className="tf-checkout-tickets__qty">
377
392
  {/* Tables pick a number of GUESTS (bounded by minGuests/maxGuests),
378
393
  not a count of tickets — the label makes that explicit, matching
379
394
  the old checkout's "GUESTS" affordance. */}
380
- {t.isTable && <span className="tf-checkout-tickets__guests">Guests</span>}
395
+ {t.isTable && (
396
+ <span className="tf-checkout-tickets__guests">
397
+ {tr('tickets.guests')}
398
+ </span>
399
+ )}
381
400
  <QuantitySelect
382
401
  value={qty[t.id] ?? 0}
383
402
  min={range.min}
@@ -407,7 +426,7 @@ export const Tickets: FC<TicketsProps> = ({
407
426
  className="tf-checkout-tickets__wl-toggle"
408
427
  onClick={() => setWlOpen((w) => ({ ...w, [t.id]: true }))}
409
428
  >
410
- Join the waiting list
429
+ {tr('tickets.joinWaitingList')}
411
430
  </button>
412
431
  ))}
413
432
  </div>
@@ -447,7 +466,7 @@ export const Tickets: FC<TicketsProps> = ({
447
466
 
448
467
  {isTimeSlot && dates.length > 0 && (
449
468
  <div className="tf-checkout-tickets__currency">
450
- <span className="tf-checkout-tickets__currency-label">Date</span>
469
+ <span className="tf-checkout-tickets__currency-label">{tr('common.date')}</span>
451
470
  <select
452
471
  className="tf-checkout-select"
453
472
  value={slotDate}
@@ -467,7 +486,9 @@ export const Tickets: FC<TicketsProps> = ({
467
486
 
468
487
  {checkout.currency && checkout.currency.available.length > 1 && (
469
488
  <div className="tf-checkout-tickets__currency">
470
- <span className="tf-checkout-tickets__currency-label">Currency</span>
489
+ <span className="tf-checkout-tickets__currency-label">
490
+ {tr('common.currency')}
491
+ </span>
471
492
  <select
472
493
  className="tf-checkout-select"
473
494
  value={checkout.currency.currency}
@@ -483,7 +504,7 @@ export const Tickets: FC<TicketsProps> = ({
483
504
  )}
484
505
 
485
506
  {isTimeSlot && !slotQuery.isLoading && tickets.length === 0 && slotDate && (
486
- <p className="tf-checkout-note">No sessions available on this date.</p>
507
+ <p className="tf-checkout-note">{tr('tickets.noSessions')}</p>
487
508
  )}
488
509
 
489
510
  {isTimeSlot
@@ -504,14 +525,16 @@ export const Tickets: FC<TicketsProps> = ({
504
525
  className="tf-checkout-tickets__promo-toggle"
505
526
  onClick={() => setPromoOpen(true)}
506
527
  >
507
- Got a promo code? Click here
528
+ {tr('tickets.gotPromoCode')}
508
529
  </button>
509
530
  ) : (
510
531
  <>
511
532
  <FieldRow>
512
533
  <Field>
513
534
  <Input
514
- placeholder={isAccess ? 'Access code' : 'Promo code'}
535
+ placeholder={
536
+ isAccess ? tr('tickets.accessCode') : tr('tickets.promoCode')
537
+ }
515
538
  value={code}
516
539
  onChange={(e) => setCode(e.target.value)}
517
540
  onKeyDown={(e) => e.key === 'Enter' && applyCode()}
@@ -551,10 +574,10 @@ export const Tickets: FC<TicketsProps> = ({
551
574
  disabled={checkout.isBusy || selected.length === 0}
552
575
  >
553
576
  {checkout.isBusy
554
- ? 'Reserving…'
577
+ ? tr('tickets.reserving')
555
578
  : selectionIsTable
556
- ? 'Reserve tables'
557
- : buttonLabel}
579
+ ? tr('tickets.reserveTables')
580
+ : buttonText}
558
581
  </Button>
559
582
  )}
560
583
 
@@ -1,6 +1,7 @@
1
1
  import React, { type Dispatch, type FC, type SetStateAction } from 'react'
2
2
 
3
3
  import { Field, FieldRow, Input, Select } from '../../ui/Field'
4
+ import { useT } from '../../ui/I18nProvider'
4
5
  import type { BillingDraft, BillingFieldError, LocationOption } from './types'
5
6
 
6
7
  interface BillingAddressFieldsProps {
@@ -19,19 +20,20 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
19
20
  fieldError,
20
21
  setBilling,
21
22
  }) => {
23
+ const t = useT()
22
24
  const setValue =
23
25
  (key: keyof BillingDraft) => (event: React.ChangeEvent<HTMLInputElement>) =>
24
26
  setBilling((current) => ({ ...current, [key]: event.target.value }))
25
27
 
26
28
  return (
27
29
  <>
28
- <div className="tf-checkout-billing__section-label">Billing address *</div>
30
+ <div className="tf-checkout-billing__section-label">{t('billing.address')}</div>
29
31
  <Field
30
- error={fieldError('Street address', 'Street address is required.')}
32
+ error={fieldError('Street address', t('billing.streetRequired'))}
31
33
  invalid={Boolean(fieldError('Street address'))}
32
34
  >
33
35
  <Input
34
- placeholder="Street address *"
36
+ placeholder={t('billing.streetPlaceholder')}
35
37
  autoComplete="street-address"
36
38
  value={billing.streetAddress ?? ''}
37
39
  required
@@ -42,11 +44,11 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
42
44
  </Field>
43
45
  <FieldRow>
44
46
  <Field
45
- error={fieldError('City', 'City is required.')}
47
+ error={fieldError('City', t('billing.cityRequired'))}
46
48
  invalid={Boolean(fieldError('City'))}
47
49
  >
48
50
  <Input
49
- placeholder="City *"
51
+ placeholder={t('billing.cityPlaceholder')}
50
52
  autoComplete="address-level2"
51
53
  value={billing.city ?? ''}
52
54
  required
@@ -56,11 +58,11 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
56
58
  />
57
59
  </Field>
58
60
  <Field
59
- error={fieldError('ZIP / postcode', 'ZIP / postcode is required.')}
61
+ error={fieldError('ZIP / postcode', t('billing.postcodeRequired'))}
60
62
  invalid={Boolean(fieldError('ZIP / postcode'))}
61
63
  >
62
64
  <Input
63
- placeholder="ZIP / Postcode *"
65
+ placeholder={t('billing.postcodePlaceholder')}
64
66
  autoComplete="postal-code"
65
67
  value={billing.zip ?? ''}
66
68
  required
@@ -72,12 +74,12 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
72
74
  </FieldRow>
73
75
  <FieldRow>
74
76
  <Field
75
- error={fieldError('Country', 'Country is required.')}
77
+ error={fieldError('Country', t('billing.countryRequired'))}
76
78
  invalid={Boolean(fieldError('Country'))}
77
79
  >
78
80
  <Select
79
81
  value={billing.countryId ?? ''}
80
- aria-label="Country"
82
+ aria-label={t('billing.countryLabel')}
81
83
  required
82
84
  invalid={Boolean(fieldError('Country'))}
83
85
  aria-invalid={Boolean(fieldError('Country'))}
@@ -94,7 +96,7 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
94
96
  }))
95
97
  }}
96
98
  >
97
- <option value="">— country * —</option>
99
+ <option value="">{t('billing.countryEmpty')}</option>
98
100
  {countries.map((country) => (
99
101
  <option key={country.id} value={country.id}>
100
102
  {country.name}
@@ -103,13 +105,13 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
103
105
  </Select>
104
106
  </Field>
105
107
  <Field
106
- error={fieldError('State / county', 'State / county is required.')}
108
+ error={fieldError('State / county', t('billing.stateRequired'))}
107
109
  invalid={Boolean(fieldError('State / county'))}
108
110
  >
109
111
  <Select
110
112
  value={billing.stateId ?? ''}
111
113
  disabled={!billing.countryId || states.length === 0}
112
- aria-label="State / region"
114
+ aria-label={t('billing.stateLabel')}
113
115
  required={states.length > 0}
114
116
  invalid={Boolean(fieldError('State / county'))}
115
117
  aria-invalid={Boolean(fieldError('State / county'))}
@@ -124,7 +126,11 @@ export const BillingAddressFields: FC<BillingAddressFieldsProps> = ({
124
126
  }))
125
127
  }}
126
128
  >
127
- <option value="">— state / county{states.length > 0 ? ' *' : ''} —</option>
129
+ <option value="">
130
+ {states.length > 0
131
+ ? t('billing.stateEmptyRequired')
132
+ : t('billing.stateEmpty')}
133
+ </option>
128
134
  {states.map((state) => (
129
135
  <option key={state.id} value={state.id}>
130
136
  {state.name}
@@ -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,101 +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
- // 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
106
- disableDialCodePrefill
107
- value={holder.phone}
108
- required
109
- inputProps={{
110
- autoComplete: 'tel',
111
- 'aria-label': phoneKey,
112
- 'aria-invalid': Boolean(fieldError(phoneKey)),
113
- }}
114
- onChange={(phone) => patchHolder(index, { phone })}
115
- />
116
- </Field>
117
- </FieldRow>
118
- )}
119
- {renderCustomFields(holder, index)}
120
- </div>
121
- )
122
- })}
123
- </Card>
124
- )
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
+ }
@@ -39,6 +39,21 @@
39
39
  font-size: 14px;
40
40
  line-height: 1.25;
41
41
  }
42
+ /* Organiser's scarcity badge ("Selling fast"). Its own token so a host can
43
+ recolour it without also recolouring validation errors. */
44
+ .tf-checkout-tickets__badge {
45
+ display: inline-block;
46
+ max-width: 100%;
47
+ margin-top: 4px;
48
+ padding: 2px 7px;
49
+ border-radius: 3px;
50
+ background: var(--tf-checkout-badge, var(--tf-checkout-error));
51
+ color: var(--tf-checkout-badge-contrast, #fff);
52
+ font-size: 11px;
53
+ font-weight: 600;
54
+ line-height: 1.4;
55
+ overflow-wrap: anywhere;
56
+ }
42
57
  .tf-checkout-tickets__description-toggle {
43
58
  appearance: none;
44
59
  border: 0;
@@ -145,7 +160,9 @@
145
160
  font-weight: 400;
146
161
  text-align: center;
147
162
  cursor: pointer;
148
- transition: border-color 0.12s, background 0.12s;
163
+ transition:
164
+ border-color 0.12s,
165
+ background 0.12s;
149
166
  }
150
167
  .tf-checkout-tickets__promo-toggle:hover {
151
168
  background: color-mix(in srgb, var(--tf-checkout-text) 78%, var(--tf-checkout-surface));
@@ -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
@@ -50,7 +52,9 @@ export const useInstallmentPicker = () => {
50
52
  {o.count} × {o.per_installment_formatted ?? o.per_installment}
51
53
  </span>
52
54
  <span className="tf-checkout-installments__meta">
53
- {o.total_formatted ? `${o.total_formatted} total` : ''}
55
+ {o.total_formatted
56
+ ? t('payment.installmentTotal', { amount: o.total_formatted })
57
+ : ''}
54
58
  {o.diff_from_original_formatted
55
59
  ? ` · ${o.diff_from_original_formatted}`
56
60
  : ''}
@@ -59,7 +63,7 @@ export const useInstallmentPicker = () => {
59
63
  ))}
60
64
  </div>
61
65
  <Button variant="ghost" block onClick={() => choose(null)}>
62
- Pay in full instead
66
+ {t('payment.payInFull')}
63
67
  </Button>
64
68
  </div>
65
69
  </div>