tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.11

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.
Files changed (71) hide show
  1. package/README.md +115 -24
  2. package/dist/index.d.mts +8195 -282
  3. package/dist/index.d.ts +8195 -282
  4. package/dist/index.js +32346 -7001
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32586 -7128
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +23 -11
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/scripts/verify-discussion-support.cjs +131 -0
  12. package/src/CardInput.tsx +64 -3
  13. package/src/EmbeddedCheckout.tsx +140 -38
  14. package/src/auth/AuthModal.tsx +186 -9
  15. package/src/auth/EmailCodeForm.tsx +91 -0
  16. package/src/auth/RegisterForm.tsx +91 -5
  17. package/src/auth/TwoFactorForm.tsx +74 -0
  18. package/src/auth/index.ts +4 -0
  19. package/src/configure.ts +19 -14
  20. package/src/confirmer.ts +112 -20
  21. package/src/generated/seatMapWidget.ts +3 -0
  22. package/src/index.tsx +1 -1
  23. package/src/sections/Accommodation.tsx +57 -0
  24. package/src/sections/AccountDeletion.tsx +347 -0
  25. package/src/sections/AccountEmailEnrichment.tsx +122 -0
  26. package/src/sections/AccountInformation.tsx +139 -0
  27. package/src/sections/AccountPaymentPlan.tsx +105 -0
  28. package/src/sections/AccountRewards.tsx +108 -0
  29. package/src/sections/AddOns.tsx +99 -148
  30. package/src/sections/BillingInfo.tsx +80 -78
  31. package/src/sections/Bundles.tsx +467 -0
  32. package/src/sections/EventDiscussionView.tsx +655 -0
  33. package/src/sections/NotificationPreferences.tsx +244 -0
  34. package/src/sections/OrderSummary.tsx +29 -2
  35. package/src/sections/Passes.tsx +177 -0
  36. package/src/sections/Payment.tsx +170 -53
  37. package/src/sections/PhoneAuthentication.tsx +371 -0
  38. package/src/sections/PostReaction.tsx +122 -0
  39. package/src/sections/ProfileSettings.tsx +446 -0
  40. package/src/sections/Rewards.tsx +320 -0
  41. package/src/sections/SeatMap.tsx +225 -0
  42. package/src/sections/SeatsIoMap.tsx +110 -0
  43. package/src/sections/Streaming.tsx +278 -0
  44. package/src/sections/TicketChange.tsx +379 -0
  45. package/src/sections/TicketGift.tsx +206 -0
  46. package/src/sections/TicketRefund.tsx +104 -0
  47. package/src/sections/Tickets.tsx +289 -208
  48. package/src/sections/Trips.tsx +87 -0
  49. package/src/sections/WaiverSigning.tsx +429 -0
  50. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  51. package/src/sections/billing/TicketHoldersCard.tsx +4 -0
  52. package/src/sections/discussion/CommentImages.tsx +366 -0
  53. package/src/sections/discussion/SwipeReply.tsx +88 -0
  54. package/src/sections/index.ts +46 -0
  55. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  56. package/src/sections/rewards/RewardsTree.tsx +117 -0
  57. package/src/storage.ts +8 -9
  58. package/src/ui/CheckoutProvider.tsx +51 -15
  59. package/src/ui/CustomFields.tsx +48 -12
  60. package/src/ui/Field.tsx +21 -3
  61. package/src/ui/Frozen.tsx +22 -0
  62. package/src/ui/InstallmentPicker.tsx +15 -18
  63. package/src/ui/PaymentMethods.tsx +6 -3
  64. package/src/ui/ProviderPages.tsx +214 -0
  65. package/src/ui/QuantitySelect.tsx +28 -3
  66. package/src/ui/Select.tsx +4 -1
  67. package/src/ui/SignaturePad.tsx +218 -0
  68. package/src/ui/index.ts +3 -0
  69. package/src/ui/primitives.tsx +13 -2
  70. package/src/ui/useAccountT.ts +16 -0
  71. package/src/useCheckout.ts +3 -1
@@ -0,0 +1,105 @@
1
+ import React from 'react'
2
+ import { View } from 'react-native'
3
+ import { accountPlanDate, hooks, type AccountPaymentPlan } from 'tf-checkout-shared'
4
+ import {
5
+ BodyText,
6
+ Button,
7
+ Card,
8
+ ErrorText,
9
+ MutedText,
10
+ SectionHeader,
11
+ } from '../ui/primitives'
12
+ import { useAccountT } from '../ui/useAccountT'
13
+ export interface AccountPaymentPlanSectionProps {
14
+ plan: AccountPaymentPlan
15
+ orderHash: string
16
+ locale?: string
17
+ onOpen: (url: string) => void | Promise<unknown>
18
+ onReturn?: () => void | Promise<unknown>
19
+ }
20
+ export const AccountPaymentPlanSection = ({
21
+ plan,
22
+ orderHash,
23
+ locale,
24
+ onOpen,
25
+ onReturn,
26
+ }: AccountPaymentPlanSectionProps) => {
27
+ const t = useAccountT(locale)
28
+ const flow = hooks.useAccountPlanManagement(plan, orderHash, onOpen, onReturn)
29
+ return (
30
+ <Card>
31
+ <SectionHeader title={plan.terminology || t('Payment plan')} />
32
+ <BodyText>
33
+ {t(
34
+ plan.requiresCompletion
35
+ ? 'Payment plan needs attention'
36
+ : plan.status === 'pending'
37
+ ? 'Payments in progress'
38
+ : plan.status === 'finished'
39
+ ? 'All payments complete'
40
+ : plan.status === 'cancelled'
41
+ ? 'Payment plan cancelled'
42
+ : 'Payment plan setup incomplete'
43
+ )}
44
+ </BodyText>
45
+ <BodyText>
46
+ {t('Plan total')}: {plan.currency} {plan.totalAmount}
47
+ </BodyText>
48
+ {plan.remainingAmount !== null ? (
49
+ <BodyText>
50
+ {t('Remaining balance')}: {plan.currency} {plan.remainingAmount}
51
+ </BodyText>
52
+ ) : null}
53
+ {plan.installments.map((row, index) => (
54
+ <View key={index} style={{ gap: 4, marginVertical: 8 }}>
55
+ <BodyText>
56
+ {row.currency} {row.amount} ·{' '}
57
+ {t(
58
+ row.status === 'paid'
59
+ ? 'Paid'
60
+ : row.status === 'unpaid'
61
+ ? 'Scheduled'
62
+ : row.status === 'void' || row.status === 'uncollectible'
63
+ ? 'Payment unsuccessful'
64
+ : row.status
65
+ )}
66
+ </BodyText>
67
+ <MutedText>
68
+ {accountPlanDate(
69
+ row.status === 'paid'
70
+ ? (row.chargeDate ?? row.updatedAt)
71
+ : row.scheduledDate,
72
+ locale,
73
+ row.status === 'paid'
74
+ ) || t('Date not scheduled')}
75
+ </MutedText>
76
+ </View>
77
+ ))}
78
+ {plan.status === 'pending' ? (
79
+ <MutedText>
80
+ {t('Your QR code becomes available when your payment plan is complete.')}
81
+ </MutedText>
82
+ ) : null}
83
+ <MutedText>
84
+ {t('Manage this plan on the Ticket Fairy website. Sign in there if asked.')}
85
+ </MutedText>
86
+ {flow.error ? <ErrorText>{t(flow.error)}</ErrorText> : null}
87
+ <Button
88
+ disabled={flow.busy}
89
+ onPress={() => {
90
+ void flow.open(
91
+ plan.completeUrl ? 'complete' : plan.updateCardUrl ? 'update' : 'manage'
92
+ )
93
+ }}
94
+ >
95
+ {t(
96
+ plan.completeUrl
97
+ ? 'Complete payment plan'
98
+ : plan.updateCardUrl
99
+ ? 'Update card details'
100
+ : 'View payment plan on website'
101
+ )}
102
+ </Button>
103
+ </Card>
104
+ )
105
+ }
@@ -0,0 +1,108 @@
1
+ import React, { type FC } from 'react'
2
+ import { Alert, Image, Share, View } from 'react-native'
3
+ import {
4
+ createTranslator,
5
+ hooks,
6
+ resolveLocale,
7
+ type AccountRewardsSectionProps,
8
+ } from 'tf-checkout-shared'
9
+ import {
10
+ BodyText,
11
+ Button,
12
+ Card,
13
+ ErrorText,
14
+ Loader,
15
+ SectionHeader,
16
+ } from '../ui/primitives'
17
+
18
+ /** The account aggregate has no order identity and offers no reward mutations. */
19
+ export const AccountRewardsSection: FC<AccountRewardsSectionProps> = ({
20
+ locale,
21
+ onBrowseEvents,
22
+ onViewTickets,
23
+ }) => {
24
+ const t = createTranslator({ locale: resolveLocale(locale) })
25
+ const { session, query, view } = hooks.useAccountRewardsSummary(t)
26
+ const assertOwner = hooks.useSessionAction()
27
+ const share = async (imageUrl: string) => {
28
+ try {
29
+ assertOwner()
30
+ await Share.share({ url: imageUrl, message: imageUrl })
31
+ } catch {
32
+ try {
33
+ assertOwner()
34
+ Alert.alert(t('rewards.account.shareError'))
35
+ } catch {
36
+ // A retired buyer must not display an alert in the next buyer's account.
37
+ }
38
+ }
39
+ }
40
+ return (
41
+ <View style={{ gap: 16 }}>
42
+ <SectionHeader
43
+ title={t('rewards.account.title')}
44
+ subtitle={t('rewards.account.intro')}
45
+ />
46
+ {!session.isAuthenticated ? (
47
+ <BodyText>{t('rewards.account.signIn')}</BodyText>
48
+ ) : (
49
+ <>
50
+ {query.isLoading && <Loader />}
51
+ {query.error && <ErrorText>{t('rewards.account.error')}</ErrorText>}
52
+ <Button
53
+ variant="ghost"
54
+ loading={query.isFetching}
55
+ onPress={() => void query.refetch()}
56
+ >
57
+ {t('rewards.account.refresh')}
58
+ </Button>
59
+ {view?.empty && <BodyText>{t('rewards.account.empty')}</BodyText>}
60
+ {view &&
61
+ (['ambassador', 'packs', 'prizes'] as const).map((kind) =>
62
+ view[kind].length ? (
63
+ <View key={kind} style={{ gap: 12 }}>
64
+ <SectionHeader title={t(`rewards.account.${kind}`)} />
65
+ {view[kind].map((row) => (
66
+ <Card key={row.key}>
67
+ <SectionHeader title={row.title} />
68
+ {row.lines.map((line, index) => (
69
+ <BodyText key={index}>{line}</BodyText>
70
+ ))}
71
+ </Card>
72
+ ))}
73
+ </View>
74
+ ) : null
75
+ )}
76
+ {!!view?.shareCards.length && (
77
+ <View style={{ gap: 12 }}>
78
+ <SectionHeader title={t('rewards.account.shareCards')} />
79
+ {view.shareCards.map((card) => (
80
+ <Card key={card.key}>
81
+ <Image
82
+ source={{ uri: card.imageUrl }}
83
+ accessibilityLabel={t('rewards.account.shareCard')}
84
+ resizeMode="contain"
85
+ style={{ width: '100%', aspectRatio: 1.91 }}
86
+ />
87
+ <Button variant="ghost" onPress={() => void share(card.imageUrl)}>
88
+ {t('rewards.account.share')}
89
+ </Button>
90
+ </Card>
91
+ ))}
92
+ </View>
93
+ )}
94
+ {onViewTickets && (
95
+ <Button variant="ghost" onPress={onViewTickets}>
96
+ {t('rewards.account.viewTickets')}
97
+ </Button>
98
+ )}
99
+ {onBrowseEvents && (
100
+ <Button variant="ghost" onPress={onBrowseEvents}>
101
+ {t('rewards.account.browse')}
102
+ </Button>
103
+ )}
104
+ </>
105
+ )}
106
+ </View>
107
+ )
108
+ }
@@ -8,18 +8,18 @@
8
8
  * Renders nothing when the event has no applicable add-ons (or they're all sold
9
9
  * out), so it's safe to always place between `<Tickets/>` and `<BillingInfo/>`.
10
10
  */
11
- import React, { Fragment, useEffect, useMemo, useState, type FC } from 'react'
11
+ import React, { Fragment, useMemo, type FC } from 'react'
12
12
  import { Pressable, StyleSheet, Text, View } from 'react-native'
13
13
 
14
14
  import {
15
15
  addOnMaxQuantity,
16
16
  errorMessage,
17
17
  hooks,
18
- ttf,
19
- type CustomFieldValues,
18
+ htmlToPlainText,
20
19
  type EventAddon,
21
20
  } from 'tf-checkout-shared'
22
21
 
22
+ import { FrozenFields } from '../ui/Frozen'
23
23
  import { Button, Card, ErrorText, Loader, MutedText } from '../ui/primitives'
24
24
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
25
25
  // Created by a sibling agent; imported here per the port plan. Mirrors the web
@@ -28,24 +28,6 @@ import { CustomFieldGroupFields } from '../ui/CustomFields'
28
28
  import { QuantitySelect } from '../ui/QuantitySelect'
29
29
  import type { ResolvedTheme } from '../ui/theme'
30
30
 
31
- // Web renders the add-on description via `dangerouslySetInnerHTML`. React Native
32
- // has no HTML renderer, so we strip tags + decode a few common entities to show
33
- // the description as plain text (the adaptation for native).
34
- const stripHtml = (html: string): string =>
35
- html
36
- .replace(/<\s*br\s*\/?\s*>/gi, '\n')
37
- .replace(/<\/\s*(p|div|li|h[1-6])\s*>/gi, '\n')
38
- .replace(/<[^>]+>/g, '')
39
- .replace(/&nbsp;/gi, ' ')
40
- .replace(/&amp;/gi, '&')
41
- .replace(/&lt;/gi, '<')
42
- .replace(/&gt;/gi, '>')
43
- .replace(/&#39;|&apos;/gi, "'")
44
- .replace(/&quot;/gi, '"')
45
- .replace(/\n{3,}/g, '\n\n')
46
- .replace(/[ \t]+\n/g, '\n')
47
- .trim()
48
-
49
31
  const money = (currency: string, n: number): string =>
50
32
  `${currency} ${Number(n).toFixed(2)}`
51
33
 
@@ -74,57 +56,27 @@ export const AddOns: FC<AddOnsProps> = ({
74
56
  const checkout = useCheckoutContext()
75
57
  const theme = useTheme()
76
58
  const s = useAddonStyles(theme)
77
- // No onContinue ⇒ combined form (inside <EmbeddedCheckout/>): apply the
78
- // selection live (no "confirm" click) — the old checkout applied add-ons at
79
- // checkout too.
80
- const combined = !onContinue
81
- const addonsQuery = hooks.useEventAddons(eventId)
82
- const all = addonsQuery.data ?? []
83
- const addons = ttf.events.filterAddonsForTickets(all, checkout.ticketTypeIds)
84
- // Tickets reserved in the cart — the bound for ticket-scoped add-on caps
85
- // (the hosted partial's `selectedTotalQty()`).
86
- const ticketQuantity = (checkout.breakdown?.tickets ?? []).reduce(
87
- (total, line) => total + (line.quantity || 0),
88
- 0
89
- )
90
-
91
- const [qty, setQty] = useState<Record<string, number>>(checkout.addOns ?? {})
92
- const [capture, setCapture] = useState<Record<string, CustomFieldValues>>(
93
- checkout.addOnDataCapture ?? {}
94
- )
95
- const [error, setError] = useState('')
96
-
97
- // Custom-field groups keyed by add-on id (scope 'add-on', group id = add-on id).
98
- const fieldGroups = useMemo(() => {
99
- const map: Record<string, (typeof checkout.customFields)[number]> = {}
100
- for (const g of checkout.customFields) if (g.scope === 'add-on') map[g.id] = g
101
- return map
102
- }, [checkout.customFields])
103
-
104
- // Combined mode: debounce-apply the selection to the cart (which reprices) as
105
- // it changes, instead of gating behind a "confirm" button.
106
- useEffect(() => {
107
- if (!combined) return
108
- const selection = Object.fromEntries(Object.entries(qty).filter(([, n]) => n > 0))
109
- if (JSON.stringify(selection) === JSON.stringify(checkout.addOns ?? {})) return
110
- const captureForSelected = Object.fromEntries(
111
- Object.entries(capture).filter(([id]) => (selection[id] ?? 0) > 0)
112
- )
113
- const t = setTimeout(() => {
114
- checkout.setAddonDataCapture(captureForSelected)
115
- checkout.setAddons(selection).catch((e) => setError(errorMessage(e)))
116
- }, 500)
117
- return () => clearTimeout(t)
118
- // eslint-disable-next-line react-hooks/exhaustive-deps
119
- }, [combined, qty, capture])
120
-
121
- const hasRequired = (checkout.requiredAddOns ?? []).some((a) => a.required)
59
+ const {
60
+ query: addonsQuery,
61
+ addons,
62
+ qty,
63
+ setQty,
64
+ capture,
65
+ setCapture,
66
+ error,
67
+ fieldGroups,
68
+ combined,
69
+ ticketQuantity,
70
+ hasRequired,
71
+ save,
72
+ skip,
73
+ } = hooks.useAddOnSelection(eventId, checkout, { onContinue, onSkip })
122
74
  const head = (
123
75
  <View style={s.head}>
124
76
  {eyebrow ? <Text style={s.eyebrow}>{eyebrow}</Text> : null}
125
77
  <Text style={s.heading}>{heading}</Text>
126
78
  {onSkip && !hasRequired ? (
127
- <Pressable accessibilityRole="button" onPress={onSkip} style={s.skipBtn}>
79
+ <Pressable accessibilityRole="button" onPress={skip} style={s.skipBtn}>
128
80
  <Text style={s.skip}>Skip</Text>
129
81
  </Pressable>
130
82
  ) : null}
@@ -141,6 +93,14 @@ export const AddOns: FC<AddOnsProps> = ({
141
93
  </Card>
142
94
  )
143
95
  }
96
+ if (addonsQuery.isError)
97
+ return (
98
+ <Card>
99
+ {head}
100
+ <ErrorText>{errorMessage(addonsQuery.error)}</ErrorText>
101
+ <Button onPress={() => void addonsQuery.refetch()}>Try again</Button>
102
+ </Card>
103
+ )
144
104
  if (addons.length === 0) return null
145
105
  // A catalog containing only unavailable rows is not a useful checkout step.
146
106
  // Required sold-out add-ons are already exempted by the engine preflight.
@@ -160,95 +120,86 @@ export const AddOns: FC<AddOnsProps> = ({
160
120
  }
161
121
 
162
122
  const missing = checkout.missingRequiredAddonNames
163
- const save = () => {
164
- setError('')
165
- const selection = Object.fromEntries(Object.entries(qty).filter(([, n]) => n > 0))
166
- // Only keep captured fields for add-ons that are actually selected.
167
- const captureForSelected = Object.fromEntries(
168
- Object.entries(capture).filter(([id]) => (selection[id] ?? 0) > 0)
169
- )
170
- checkout.setAddonDataCapture(captureForSelected)
171
- checkout
172
- .setAddons(selection)
173
- .then(() => onContinue?.())
174
- .catch((e) => setError(errorMessage(e)))
175
- }
176
123
 
177
124
  const selectedCount = Object.values(qty).filter((n) => n > 0).length
178
125
 
179
126
  return (
180
- <Card>
181
- {head}
182
- <Text style={s.subtitle}>PLEASE SELECT FROM THE OPTIONAL ADD-ONS BELOW</Text>
127
+ <FrozenFields frozen={checkout.orderFixed}>
128
+ <Card>
129
+ {head}
130
+ <Text style={s.subtitle}>PLEASE SELECT FROM THE OPTIONAL ADD-ONS BELOW</Text>
183
131
 
184
- {groups.map((g, gi) => (
185
- <Fragment key={g.heading ?? `g${gi}`}>
186
- {g.heading ? <Text style={s.group}>{g.heading}</Text> : null}
187
- {g.items.map((a) => {
188
- const n = qty[a.id] ?? 0
189
- const group = fieldGroups[a.id]
190
- const desc = a.description ? stripHtml(a.description) : ''
191
- return (
192
- <View key={a.id} style={s.item}>
193
- <View style={s.row}>
194
- <View style={s.info}>
195
- <Text style={s.name}>
196
- {a.name}
197
- {a.required ? <Text style={s.req}> · required</Text> : null}
198
- </Text>
199
- <Text style={s.price}>
200
- {money(a.currency, a.price)}
201
- {a.feeIncluded ? (
202
- <Text style={s.priceNote}> incl. fees</Text>
203
- ) : null}
204
- </Text>
205
- {desc ? <Text style={s.desc}>{desc}</Text> : null}
132
+ {groups.map((g, gi) => (
133
+ <Fragment key={g.heading ?? `g${gi}`}>
134
+ {g.heading ? <Text style={s.group}>{g.heading}</Text> : null}
135
+ {g.items.map((a) => {
136
+ const n = qty[a.id] ?? 0
137
+ const group = fieldGroups[a.id]
138
+ const desc = a.description ? htmlToPlainText(a.description) : ''
139
+ return (
140
+ <View key={a.id} style={s.item}>
141
+ <View style={s.row}>
142
+ <View style={s.info}>
143
+ <Text style={s.name}>
144
+ {a.name}
145
+ {a.required ? <Text style={s.req}> · required</Text> : null}
146
+ </Text>
147
+ <Text style={s.price}>
148
+ {money(a.currency, a.price)}
149
+ {a.feeIncluded ? (
150
+ <Text style={s.priceNote}> incl. fees</Text>
151
+ ) : null}
152
+ </Text>
153
+ {desc ? <Text style={s.desc}>{desc}</Text> : null}
154
+ </View>
155
+ {soldOut(a) ? (
156
+ <Text style={s.soldOut}>Sold out</Text>
157
+ ) : (
158
+ <QuantitySelect
159
+ value={n}
160
+ min={0}
161
+ max={max(a)}
162
+ onChange={(v) => setQty((q) => ({ ...q, [a.id]: v }))}
163
+ />
164
+ )}
206
165
  </View>
207
- {soldOut(a) ? (
208
- <Text style={s.soldOut}>Sold out</Text>
209
- ) : (
210
- <QuantitySelect
211
- value={n}
212
- min={0}
213
- max={max(a)}
214
- onChange={(v) => setQty((q) => ({ ...q, [a.id]: v }))}
215
- />
216
- )}
166
+ {n > 0 && group ? (
167
+ <View style={s.fields}>
168
+ <CustomFieldGroupFields
169
+ group={group}
170
+ values={capture[a.id] ?? {}}
171
+ onChange={(name: string, val: string | string[]) =>
172
+ setCapture((c) => ({
173
+ ...c,
174
+ [a.id]: { ...(c[a.id] ?? {}), [name]: val },
175
+ }))
176
+ }
177
+ />
178
+ </View>
179
+ ) : null}
217
180
  </View>
218
- {n > 0 && group ? (
219
- <View style={s.fields}>
220
- <CustomFieldGroupFields
221
- group={group}
222
- values={capture[a.id] ?? {}}
223
- onChange={(name: string, val: string | string[]) =>
224
- setCapture((c) => ({
225
- ...c,
226
- [a.id]: { ...(c[a.id] ?? {}), [name]: val },
227
- }))
228
- }
229
- />
230
- </View>
231
- ) : null}
232
- </View>
233
- )
234
- })}
235
- </Fragment>
236
- ))}
181
+ )
182
+ })}
183
+ </Fragment>
184
+ ))}
237
185
 
238
- {!combined ? (
239
- <Button
240
- onPress={save}
241
- loading={checkout.isBusy}
242
- disabled={checkout.isBusy || selectedCount === 0}
243
- >
244
- {checkout.isBusy ? 'Updating…' : buttonLabel}
245
- </Button>
246
- ) : null}
247
- {missing.length > 0 ? (
248
- <MutedText>Required add-on: {missing.join(', ')}.</MutedText>
249
- ) : null}
250
- {error || checkout.error ? <ErrorText>{error || checkout.error}</ErrorText> : null}
251
- </Card>
186
+ {!combined ? (
187
+ <Button
188
+ onPress={save}
189
+ loading={checkout.isBusy}
190
+ disabled={checkout.isBusy || selectedCount === 0}
191
+ >
192
+ {checkout.isBusy ? 'Updating…' : buttonLabel}
193
+ </Button>
194
+ ) : null}
195
+ {missing.length > 0 ? (
196
+ <MutedText>Required add-on: {missing.join(', ')}.</MutedText>
197
+ ) : null}
198
+ {error || checkout.error ? (
199
+ <ErrorText>{error || checkout.error}</ErrorText>
200
+ ) : null}
201
+ </Card>
202
+ </FrozenFields>
252
203
  )
253
204
  }
254
205