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

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 (60) hide show
  1. package/README.md +92 -8
  2. package/dist/index.d.mts +7410 -290
  3. package/dist/index.d.ts +7410 -290
  4. package/dist/index.js +23539 -5909
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23561 -5857
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +22 -10
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/src/CardInput.tsx +64 -3
  12. package/src/EmbeddedCheckout.tsx +129 -36
  13. package/src/auth/AuthModal.tsx +90 -6
  14. package/src/auth/EmailCodeForm.tsx +91 -0
  15. package/src/auth/RegisterForm.tsx +91 -5
  16. package/src/auth/TwoFactorForm.tsx +74 -0
  17. package/src/auth/index.ts +4 -0
  18. package/src/configure.ts +8 -1
  19. package/src/confirmer.ts +112 -20
  20. package/src/generated/seatMapWidget.ts +3 -0
  21. package/src/index.tsx +1 -1
  22. package/src/sections/Accommodation.tsx +57 -0
  23. package/src/sections/AccountRewards.tsx +108 -0
  24. package/src/sections/AddOns.tsx +99 -148
  25. package/src/sections/BillingInfo.tsx +63 -64
  26. package/src/sections/Bundles.tsx +467 -0
  27. package/src/sections/EventDiscussionView.tsx +637 -0
  28. package/src/sections/Passes.tsx +177 -0
  29. package/src/sections/Payment.tsx +169 -52
  30. package/src/sections/PostReaction.tsx +122 -0
  31. package/src/sections/Rewards.tsx +320 -0
  32. package/src/sections/SeatMap.tsx +225 -0
  33. package/src/sections/SeatsIoMap.tsx +110 -0
  34. package/src/sections/Streaming.tsx +278 -0
  35. package/src/sections/TicketChange.tsx +379 -0
  36. package/src/sections/TicketGift.tsx +206 -0
  37. package/src/sections/TicketRefund.tsx +104 -0
  38. package/src/sections/Tickets.tsx +289 -208
  39. package/src/sections/Trips.tsx +87 -0
  40. package/src/sections/WaiverSigning.tsx +429 -0
  41. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  42. package/src/sections/discussion/CommentImages.tsx +366 -0
  43. package/src/sections/discussion/SwipeReply.tsx +88 -0
  44. package/src/sections/index.ts +26 -0
  45. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  46. package/src/sections/rewards/RewardsTree.tsx +117 -0
  47. package/src/storage.ts +8 -9
  48. package/src/ui/CheckoutProvider.tsx +25 -13
  49. package/src/ui/Field.tsx +21 -3
  50. package/src/ui/Frozen.tsx +22 -0
  51. package/src/ui/InstallmentPicker.tsx +15 -18
  52. package/src/ui/PaymentMethods.tsx +6 -3
  53. package/src/ui/ProviderPages.tsx +134 -0
  54. package/src/ui/QuantitySelect.tsx +26 -2
  55. package/src/ui/Select.tsx +4 -1
  56. package/src/ui/SignaturePad.tsx +218 -0
  57. package/src/ui/index.ts +1 -0
  58. package/src/ui/primitives.tsx +13 -2
  59. package/src/ui/useAccountT.ts +16 -0
  60. package/src/useCheckout.ts +3 -1
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "tf-checkout-react-native",
3
- "version": "2.0.0-beta.0",
3
+ "version": "2.0.0-beta.6",
4
4
  "description": "TheTicketFairy ReactNative Checkout library",
5
- "homepage": "https://github.com/theticketfairy/tf-checkout-react-native",
5
+ "homepage": "https://github.com/theticketfairy/tf-shared-libraries#readme",
6
6
  "author": "The Ticket Fairy",
7
7
  "license": "MIT",
8
8
  "main": "./dist/index.js",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "files": [
20
20
  "src",
21
+ "scripts",
21
22
  "dist",
22
23
  "android",
23
24
  "ios",
@@ -41,7 +42,7 @@
41
42
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
42
43
  "lint:fix": "eslint \"**/*.{js,ts,tsx}\" --fix",
43
44
  "clean": "del-cli lib",
44
- "build": "tsup",
45
+ "build": "pnpm --filter tf-checkout-shared build && node scripts/build-seat-map.mjs && tsup",
45
46
  "prepare": "npm run build",
46
47
  "release": "release-it",
47
48
  "release:current": "release-it --no-increment",
@@ -57,10 +58,11 @@
57
58
  ],
58
59
  "repository": {
59
60
  "type": "git",
60
- "url": "git+https://github.com/theticketfairy/tf-checkout-react-native.git"
61
+ "url": "git+https://github.com/theticketfairy/tf-shared-libraries.git",
62
+ "directory": "packages/tf-checkout-react-native"
61
63
  },
62
64
  "bugs": {
63
- "url": "https://github.com/theticketfairy/tf-checkout-react-native/issues"
65
+ "url": "https://github.com/theticketfairy/tf-shared-libraries/issues"
64
66
  },
65
67
  "publishConfig": {
66
68
  "registry": "https://registry.npmjs.org/"
@@ -82,6 +84,7 @@
82
84
  "@types/react-test-renderer": "^19.1.0",
83
85
  "commitlint": "^19.8.1",
84
86
  "del-cli": "^6.0.0",
87
+ "esbuild": "0.27.2",
85
88
  "eslint": "^9.36.0",
86
89
  "eslint-config-prettier": "^10.1.8",
87
90
  "eslint-plugin-import": "^2.32.0",
@@ -92,10 +95,13 @@
92
95
  "jiti": "^2.6.0",
93
96
  "prettier": "3.6.2",
94
97
  "react": "19.1.0",
98
+ "react-dom": "19.1.0",
95
99
  "react-native": "0.81.4",
96
100
  "react-native-mmkv": "^3.3.1",
101
+ "react-native-webview": "13.15.0",
97
102
  "release-it": "^19.0.4",
98
103
  "tf-checkout-shared": "workspace:*",
104
+ "tf-seat-map-view": "9.4.7-beta.33",
99
105
  "tsup": "^8.5.1",
100
106
  "typescript": "^5.9.2",
101
107
  "typescript-eslint": "^8.45.0"
@@ -137,16 +143,22 @@
137
143
  "version": "0.54.3"
138
144
  },
139
145
  "dependencies": {
140
- "axios": "^1.7.9"
146
+ "axios": "^1.7.9",
147
+ "entities": "^7.0.1",
148
+ "htmlparser2": "10.1.0"
141
149
  },
142
150
  "//peerDependencies": "Anything that MUST exist exactly once in the app: React contexts (react-query) and native modules (Stripe, MMKV, Clipboard). Shipping these as regular dependencies gives the consumer a second copy, which means a second React context (their <QueryClientProvider> becomes invisible to our hooks) or a duplicate native pod. As peers there is one copy, the consumer can use react-query in their own app alongside our hooks, and no Metro/bundler aliasing is ever needed. npm 7+/pnpm install peers automatically.",
143
151
  "peerDependencies": {
144
152
  "@react-native-clipboard/clipboard": ">=1.14.0",
145
153
  "@stripe/stripe-react-native": ">=0.45.0",
146
154
  "@tanstack/react-query": ">=5",
147
- "react": "*",
148
- "react-native": "*",
149
- "react-native-mmkv": ">=3"
155
+ "react": ">=19.1",
156
+ "react-native": ">=0.81",
157
+ "react-native-mmkv": "^3.0.0",
158
+ "react-native-webview": ">=13.15.0"
150
159
  },
151
- "//dependencies": "tf-checkout-shared is INLINED into this bundle (tsup noExternal), so its own runtime deps are ours too — axios must stay declared here or tsup bundles it (dragging Node's stream into a browser build) / the consumer never installs it."
160
+ "//dependencies": "tf-checkout-shared is INLINED into this bundle (tsup noExternal), so its own runtime deps are ours too \u2014 axios, entities and htmlparser2 must stay declared here or tsup bundles them (axios drags Node's stream into a browser build) / the consumer never installs them.",
161
+ "engines": {
162
+ "node": ">=20.19.4"
163
+ }
152
164
  }
@@ -0,0 +1,31 @@
1
+ import { build } from 'esbuild'
2
+ import { readFile, mkdir, writeFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
8
+ const require = createRequire(path.join(root, 'package.json'))
9
+ const result = await build({
10
+ entryPoints: [path.join(root, 'scripts/seat-map-entry.jsx')],
11
+ bundle: true,
12
+ minify: true,
13
+ write: false,
14
+ format: 'iife',
15
+ platform: 'browser',
16
+ target: 'es2020',
17
+ define: { 'process.env.NODE_ENV': '"production"' },
18
+ alias: {
19
+ react: path.dirname(require.resolve('react/package.json')),
20
+ 'react-dom': path.dirname(require.resolve('react-dom/package.json')),
21
+ },
22
+ })
23
+ const css = await readFile(require.resolve('tf-seat-map-view/dist/index.css'), 'utf8')
24
+ await mkdir(path.join(root, 'src/generated'), { recursive: true })
25
+ await writeFile(
26
+ path.join(root, 'src/generated/seatMapWidget.ts'),
27
+ '// Generated by scripts/build-seat-map.mjs from pinned package sources.\n' +
28
+ `export const SEAT_MAP_SCRIPT = ${JSON.stringify(result.outputFiles[0].text)}\n` +
29
+ `export const SEAT_MAP_CSS = ${JSON.stringify(css)}\n`
30
+ )
31
+ console.log(`Built native seat renderer: ${result.outputFiles[0].text.length} bytes`)
@@ -0,0 +1,22 @@
1
+ import React from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import SeatMap from 'tf-seat-map-view'
4
+
5
+ const root = createRoot(document.getElementById('seat-map'))
6
+ window.__tfSeatMapUpdate = (properties) => {
7
+ window.tierPrices = properties.tierPrices
8
+ const size = window.innerWidth
9
+ root.render(
10
+ <SeatMap
11
+ {...properties}
12
+ width={size}
13
+ height={size}
14
+ events={{
15
+ onSeatClick: (event) =>
16
+ window.ReactNativeWebView?.postMessage(
17
+ JSON.stringify({ type: 'seatClick', seat: event.seat })
18
+ ),
19
+ }}
20
+ />
21
+ )
22
+ }
package/src/CardInput.tsx CHANGED
@@ -9,21 +9,82 @@
9
9
  * (`confirmer.init` → `initStripe`), which always runs before the payment step
10
10
  * is reached.
11
11
  */
12
- import React, { type FC } from 'react'
13
- import { StyleSheet } from 'react-native'
12
+ import React, { useEffect, useRef, useState, type FC } from 'react'
13
+ import { Platform, StyleSheet } from 'react-native'
14
14
  import { CardField } from '@stripe/stripe-react-native'
15
+ import {
16
+ cardClaimStep,
17
+ createCardFieldRegistry,
18
+ type CardClaimStep,
19
+ type CardFieldClaim,
20
+ type CardFieldHandle,
21
+ } from 'tf-checkout-shared'
15
22
 
16
23
  import { useTheme } from './ui/CheckoutProvider'
17
24
 
18
25
  export interface CardInputProps {
19
26
  /** Fires with the field's completeness so the payment CTA can enable/disable. */
20
27
  onComplete?: (complete: boolean) => void
28
+ /** The flow this field pays for (`claimCardField(owner)`). Default `checkout`. */
29
+ owner?: string
30
+ /** False while the flow is hidden: the field is then never used. */
31
+ visible?: boolean
21
32
  }
22
33
 
23
- export const CardInput: FC<CardInputProps> = ({ onComplete }) => {
34
+ // The native SDK holds one card field. Android clears it when any field is
35
+ // dropped; iOS only when the newest one goes. The field left is remounted so
36
+ // the SDK attaches it again; the buyer re-enters the card there.
37
+ // Each field has an owner (its flow) and a visibility; a hidden field is never
38
+ // used. Attaching a field again remounts it, so the card is entered again.
39
+ const cardFields = createCardFieldRegistry(
40
+ Platform.OS === 'android' ? 'any-removed' : 'active-removed',
41
+ false
42
+ )
43
+
44
+ /**
45
+ * Call before a flow confirms a card payment: attaches the flow's own visible
46
+ * field. `reattached` means the field was remounted empty: ask for the card
47
+ * again. `unavailable` when the flow has no visible field.
48
+ */
49
+ export const claimCardField = (owner: string): CardFieldClaim => cardFields.claim(owner)
50
+
51
+ /**
52
+ * The claim `placeOrder` uses when its caller passes no `claimCard` (a host's
53
+ * own Pay): the default ticket field, owner `checkout`. A confirm then never
54
+ * uses a bundle's field, and with no ticket field it refuses.
55
+ */
56
+ export const claimDefaultCardField: CardClaimStep = cardClaimStep('nativeCard', () =>
57
+ claimCardField('checkout')
58
+ )
59
+
60
+ export const CardInput: FC<CardInputProps> = ({
61
+ onComplete,
62
+ owner = 'checkout',
63
+ visible = true,
64
+ }) => {
24
65
  const theme = useTheme()
66
+ const [attachment, setAttachment] = useState(0)
67
+ const handleRef = useRef<CardFieldHandle | null>(null)
68
+ const visibleRef = useRef(visible)
69
+ visibleRef.current = visible
70
+ useEffect(() => {
71
+ const handle = cardFields.mount(
72
+ owner,
73
+ () => setAttachment((n) => n + 1),
74
+ visibleRef.current
75
+ )
76
+ handleRef.current = handle
77
+ return () => {
78
+ handle.unmount()
79
+ handleRef.current = null
80
+ }
81
+ }, [owner])
82
+ useEffect(() => {
83
+ handleRef.current?.setVisible(visible)
84
+ }, [visible])
25
85
  return (
26
86
  <CardField
87
+ key={attachment}
27
88
  postalCodeEnabled={false}
28
89
  // Set explicitly and neutrally: stripe-react-native's own default number
29
90
  // placeholder is a Stripe TEST PAN, which reads to a real buyer as an
@@ -11,30 +11,39 @@
11
11
  *
12
12
  * Native adaptations vs web (commented at each site): no smooth `scrollIntoView`
13
13
  * (RN `ScrollView`); no URL attribution (`readAttributionFromUrl` accepted for
14
- * parity); the seat-map view is a placeholder until the native seat picker
15
- * (WebView + seats.io) lands.
14
+ * parity); the shared seat-selection controller drives the native chart WebView.
16
15
  */
17
- import React, { useEffect, useRef, useState, type FC } from 'react'
16
+ import React, { useEffect, useRef, useState, type FC, type ReactNode } from 'react'
18
17
  import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
19
18
 
20
- import type {
21
- CheckoutBilling,
22
- CheckoutConsent,
23
- CheckoutHolder,
24
- EventCondition,
25
- PlaceOrderResult,
26
- TfProfile,
19
+ import {
20
+ canLeavePayment,
21
+ createTranslator,
22
+ embeddedCheckoutEvent,
23
+ embeddedCheckoutProps,
24
+ embeddedCheckoutView,
25
+ heldOrderNotice,
26
+ hooks,
27
+ type CheckoutBilling,
28
+ type CheckoutConsent,
29
+ type CheckoutHolder,
30
+ type EventCondition,
31
+ type PlaceOrderResult,
32
+ type TfProfile,
27
33
  } from 'tf-checkout-shared'
28
34
 
29
35
  import { AuthModal } from './auth/AuthModal'
30
36
  import { AddOns } from './sections/AddOns'
31
37
  import { BillingInfo, type CheckoutPrivacyPolicy } from './sections/BillingInfo'
38
+ import { Bundles } from './sections/Bundles'
32
39
  import { Confirmation } from './sections/Confirmation'
33
40
  import { IDVerification } from './sections/IDVerification'
34
41
  import { Payment } from './sections/Payment'
35
42
  import { Tickets } from './sections/Tickets'
43
+ import { SeatMap } from './sections/SeatMap'
44
+ import { WaiverSigning } from './sections/WaiverSigning'
36
45
  import { CheckoutProvider, useCheckoutContext, useTheme } from './ui/CheckoutProvider'
37
- import { Card, BodyText, MutedText, Button } from './ui/primitives'
46
+ import { Button } from './ui/primitives'
38
47
  import { TimerBanner } from './ui/TimerBanner'
39
48
  import type { CheckoutTheme } from './ui/theme'
40
49
 
@@ -42,6 +51,7 @@ type SelectionView = 'tickets' | 'seats'
42
51
  export type CheckoutView = SelectionView | 'checkout'
43
52
 
44
53
  export interface EmbeddedCheckoutAuthOptions {
54
+ onCheckoutAuthenticated?: (profile: import('tf-checkout-shared').CheckoutBuyerProfile) => void
45
55
  allowRegistration?: boolean
46
56
  allowPasswordReset?: boolean
47
57
  requireRegistrationAddress?: boolean
@@ -59,6 +69,9 @@ export interface EmbeddedCheckoutProps {
59
69
  * leaves completion routing to `onComplete` and keeps the checkout mounted.
60
70
  */
61
71
  confirmationMode?: 'inline' | 'redirect'
72
+ /** Rendered inside the receipt, below the order details — the seam for the
73
+ * `<Rewards/>` block or a host's own post-purchase content. */
74
+ confirmationChildren?: ReactNode
62
75
  /** Initial screen. `seats` preserves an existing seat-map entry point. */
63
76
  initialView?: SelectionView
64
77
  /** Reports the visible flow view so hosts can coordinate their own UI. */
@@ -104,6 +117,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
104
117
  eventId,
105
118
  onComplete,
106
119
  confirmationMode = 'inline',
120
+ confirmationChildren,
107
121
  initialView = 'tickets',
108
122
  onViewChange,
109
123
  requireEmailConfirmation = true,
@@ -136,17 +150,24 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
136
150
  const requestLogin = onLogin ?? (auth !== false ? openBuiltInAuth : undefined)
137
151
 
138
152
  // Never let state from another event/run select this component's UI view.
153
+ // While the order is placed or charged, Payment stays on screen: it holds
154
+ // the only retry that finalizes a charged order (`embeddedCheckoutView`).
139
155
  const checkoutBelongsToEvent = checkout.eventId === eventId
140
- const checkoutAvailable = checkoutBelongsToEvent && checkout.status !== 'expired'
141
- const visibleView: CheckoutView =
142
- view === 'checkout' && !checkoutAvailable ? 'tickets' : view
156
+ const visibleView: CheckoutView = embeddedCheckoutView(view, {
157
+ belongsToEvent: checkoutBelongsToEvent,
158
+ status: checkout.status,
159
+ orderFixed: checkout.orderFixed,
160
+ })
161
+ const leaveAllowed = canLeavePayment(checkout)
143
162
 
144
163
  useEffect(() => {
145
- if (view === 'checkout' && !checkoutAvailable) {
164
+ if (visibleView === view) return
165
+ if (visibleView === 'checkout') setView('checkout')
166
+ else {
146
167
  setView('tickets')
147
168
  setSelectionView('tickets')
148
169
  }
149
- }, [checkoutAvailable, view])
170
+ }, [view, visibleView])
150
171
 
151
172
  useEffect(() => {
152
173
  onViewChange?.(visibleView)
@@ -165,6 +186,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
165
186
  }
166
187
 
167
188
  const editSelection = () => {
189
+ if (!canLeavePayment(checkout)) return
168
190
  editedDetailsRef.current = {
169
191
  billing: checkout.billing,
170
192
  holders: checkout.holders,
@@ -196,7 +218,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
196
218
  return (
197
219
  <ScrollView contentContainerStyle={styles.content}>
198
220
  <IDVerification />
199
- <Confirmation />
221
+ <Confirmation>{confirmationChildren}</Confirmation>
200
222
  </ScrollView>
201
223
  )
202
224
  }
@@ -206,15 +228,27 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
206
228
  contentContainerStyle={styles.content}
207
229
  keyboardShouldPersistTaps="handled"
208
230
  >
209
- {visibleView === 'tickets' && (
210
- <Tickets
231
+ {/* Bundles stay mounted while hidden, as on web, so a purchase in
232
+ progress keeps its order and key when the buyer goes to checkout. */}
233
+ <View style={visibleView === 'tickets' ? styles.stack : styles.hidden}>
234
+ <Bundles
211
235
  eventId={eventId}
212
- onContinue={() => enterCheckout('tickets')}
213
- onSelectSeats={() => setView('seats')}
214
- initialPromoCode={initialPromoCode}
215
- referrer={referrer}
216
- readAttributionFromUrl={readAttributionFromUrl}
236
+ visible={visibleView === 'tickets'}
237
+ onComplete={onComplete}
238
+ confirmationMode={confirmationMode}
217
239
  />
240
+ </View>
241
+ {visibleView === 'tickets' && (
242
+ <View style={styles.stack}>
243
+ <Tickets
244
+ eventId={eventId}
245
+ onContinue={() => enterCheckout('tickets')}
246
+ onSelectSeats={() => setView('seats')}
247
+ initialPromoCode={initialPromoCode}
248
+ referrer={referrer}
249
+ readAttributionFromUrl={readAttributionFromUrl}
250
+ />
251
+ </View>
218
252
  )}
219
253
 
220
254
  {visibleView === 'seats' && (
@@ -224,15 +258,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
224
258
  ← Back to tickets
225
259
  </Text>
226
260
  </Pressable>
227
- {/* Native seat picker (WebView + seats.io) is a follow-up; the web
228
- `<SeatMap/>` has no RN analogue yet. */}
229
- <Card>
230
- <BodyText>Seat selection isn’t available in the app yet.</BodyText>
231
- <MutedText>
232
- Please pick general-admission tickets, or use the web checkout to choose
233
- seats.
234
- </MutedText>
235
- </Card>
261
+ <SeatMap eventId={eventId} onContinue={() => enterCheckout('seats')} />
236
262
  </View>
237
263
  )}
238
264
 
@@ -244,8 +270,21 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
244
270
  {selectedCount} {selectedCount === 1 ? 'ticket' : 'tickets'} selected
245
271
  {formattedTotal ? ` · ${formattedTotal}` : ''}
246
272
  </Text>
247
- <Pressable onPress={editSelection} accessibilityRole="button">
248
- <Text style={[styles.editText, { color: theme.accent }]}>Edit</Text>
273
+ <Pressable
274
+ onPress={editSelection}
275
+ disabled={!leaveAllowed}
276
+ accessibilityRole="button"
277
+ accessibilityState={{ disabled: !leaveAllowed }}
278
+ >
279
+ <Text
280
+ style={[
281
+ styles.editText,
282
+ { color: theme.accent },
283
+ !leaveAllowed && styles.editTextDisabled,
284
+ ]}
285
+ >
286
+ Edit
287
+ </Text>
249
288
  </Pressable>
250
289
  </View>
251
290
 
@@ -262,6 +301,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
262
301
  privacyPolicy={privacyPolicy}
263
302
  showPrivacyError={privacyAttempted}
264
303
  />
304
+ <WaiverSigning eventId={eventId} />
265
305
  <Payment
266
306
  onComplete={onComplete}
267
307
  eventId={eventId}
@@ -278,6 +318,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
278
318
 
279
319
  {authRequest && auth !== false && !onLogin && (
280
320
  <AuthModal
321
+ checkoutEventId={eventId}
281
322
  key={authRequest.key}
282
323
  open
283
324
  initialEmail={authRequest.email}
@@ -291,6 +332,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
291
332
  requireRegistrationAddress={auth?.requireRegistrationAddress}
292
333
  logoUrl={auth?.logoUrl}
293
334
  onAuthenticated={auth?.onAuthenticated}
335
+ onCheckoutAuthenticated={auth?.onCheckoutAuthenticated}
294
336
  onClose={() => setAuthRequest(null)}
295
337
  />
296
338
  )}
@@ -298,14 +340,55 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
298
340
  )
299
341
  }
300
342
 
343
+ type FlowProps = Omit<EmbeddedCheckoutProps, 'theme'>
344
+
345
+ // The native flow's own copy is English, as the rest of this screen.
346
+ const translate = createTranslator()
347
+
348
+ /**
349
+ * Keeps the checkout on the event whose order is being paid, is charged, or
350
+ * is kept after a payment step that may have moved money, while the host
351
+ * asks for another event: that event's Payment is the only way to finish
352
+ * the order, and the engine starts no other cart until it settles or resets
353
+ * (`embeddedCheckoutEvent`). It keeps the props it had for that event, even
354
+ * across a remount, so nothing the new event adds blocks the retry
355
+ * (`embeddedCheckoutProps`). The
356
+ * buyer reads why.
357
+ */
358
+ const EventFlow: FC<FlowProps> = (flow) => {
359
+ const checkout = useCheckoutContext()
360
+ const theme = useTheme()
361
+ const shown = embeddedCheckoutEvent(flow.eventId, checkout)
362
+ // A held order keeps the props it was shown with, kept with the store.
363
+ const flowProps = embeddedCheckoutProps(checkout.store, flow)
364
+ const heldEvent = hooks.useEvent(shown.waitingFor ? shown.eventId : undefined).data
365
+ const notice = shown.waitingFor ? heldOrderNotice(heldEvent?.name) : null
366
+ return (
367
+ <>
368
+ {notice && (
369
+ <View
370
+ accessibilityLiveRegion="polite"
371
+ style={[styles.notice, { borderColor: theme.accent }]}
372
+ >
373
+ <Text style={[styles.noticeText, { color: theme.text }]}>
374
+ {translate(notice.key, notice.params)}
375
+ </Text>
376
+ </View>
377
+ )}
378
+ <Flow key={flowProps.eventId} {...flowProps} />
379
+ </>
380
+ )
381
+ }
382
+
301
383
  export const EmbeddedCheckout: FC<EmbeddedCheckoutProps> = ({ theme, ...flow }) => (
302
384
  <CheckoutProvider theme={theme}>
303
- <Flow key={flow.eventId} {...flow} />
385
+ <EventFlow {...flow} />
304
386
  </CheckoutProvider>
305
387
  )
306
388
 
307
389
  const styles = StyleSheet.create({
308
390
  content: { padding: 16, gap: 12 },
391
+ hidden: { display: 'none' },
309
392
  stack: { gap: 12 },
310
393
  back: { paddingVertical: 4 },
311
394
  backText: { fontSize: 15, fontWeight: '600' },
@@ -320,4 +403,14 @@ const styles = StyleSheet.create({
320
403
  },
321
404
  selectionText: { fontSize: 14, flex: 1 },
322
405
  editText: { fontSize: 14, fontWeight: '600' },
406
+ editTextDisabled: { opacity: 0.4 },
407
+ notice: {
408
+ marginHorizontal: 16,
409
+ marginTop: 16,
410
+ borderWidth: StyleSheet.hairlineWidth,
411
+ borderRadius: 10,
412
+ paddingVertical: 10,
413
+ paddingHorizontal: 14,
414
+ },
415
+ noticeText: { fontSize: 14, lineHeight: 20 },
323
416
  })
@@ -24,8 +24,14 @@ import { useTheme } from '../ui/CheckoutProvider'
24
24
  import { ForgotPasswordForm } from './ForgotPasswordForm'
25
25
  import { LoginForm } from './LoginForm'
26
26
  import { RegisterForm } from './RegisterForm'
27
+ import { TwoFactorForm } from './TwoFactorForm'
28
+ import { EmailCodeForm } from './EmailCodeForm'
27
29
 
28
30
  export interface AuthModalProps {
31
+ checkoutEventId?: string
32
+ onCheckoutAuthenticated?: (
33
+ profile: import('tf-checkout-shared').CheckoutBuyerProfile
34
+ ) => void
29
35
  open: boolean
30
36
  onClose: () => void
31
37
  allowRegistration?: boolean
@@ -68,6 +74,8 @@ export const AuthModal: FC<AuthModalProps> = ({
68
74
  requireRegistrationAddress = true,
69
75
  logoUrl,
70
76
  onAuthenticated,
77
+ checkoutEventId,
78
+ onCheckoutAuthenticated,
71
79
  initialEmail,
72
80
  message,
73
81
  }) => {
@@ -80,16 +88,27 @@ export const AuthModal: FC<AuthModalProps> = ({
80
88
  [onAuthenticated, onClose]
81
89
  )
82
90
  const auth = hooks.useAuthFlow({
91
+ checkoutEventId,
92
+ onCheckoutAuthenticated: (profile) => {
93
+ onCheckoutAuthenticated?.(profile)
94
+ onClose()
95
+ },
83
96
  initialEmail,
84
97
  requireRegistrationAddress,
85
98
  onAuthenticated: closeAfterAuth,
86
99
  })
87
100
 
88
101
  const isPasswordReset = auth.view === 'forgot-password'
89
- const showTabs = allowRegistration && !isPasswordReset
102
+ const showTabs =
103
+ allowRegistration &&
104
+ !isPasswordReset &&
105
+ auth.view !== 'two-factor' &&
106
+ auth.view !== 'email-code'
90
107
  // Don't let a backdrop / back-button dismiss interrupt an in-flight submit.
91
- const requestClose = () => {
92
- if (!auth.isSubmitting) onClose()
108
+ const requestClose = async () => {
109
+ if (auth.isSubmitting) return
110
+ if (auth.twoFactorChallenge) await auth.cancelTwoFactor()
111
+ onClose()
93
112
  }
94
113
 
95
114
  return (
@@ -102,13 +121,17 @@ export const AuthModal: FC<AuthModalProps> = ({
102
121
  >
103
122
  <View style={styles.header}>
104
123
  <Text style={[styles.title, { color: theme.text }]}>
105
- {isPasswordReset ? 'Password reset' : 'Ticket Fairy account'}
124
+ {isPasswordReset
125
+ ? 'Password reset'
126
+ : auth.view === 'two-factor'
127
+ ? 'Verify your sign in'
128
+ : 'Ticket Fairy account'}
106
129
  </Text>
107
130
  <Pressable
108
131
  accessibilityRole="button"
109
132
  accessibilityLabel="Close"
110
133
  disabled={auth.isSubmitting}
111
- onPress={onClose}
134
+ onPress={() => void requestClose()}
112
135
  hitSlop={12}
113
136
  >
114
137
  <Text style={[styles.close, { color: theme.muted }]}>✕</Text>
@@ -145,6 +168,33 @@ export const AuthModal: FC<AuthModalProps> = ({
145
168
 
146
169
  {message && !isPasswordReset ? <MutedText>{message}</MutedText> : null}
147
170
 
171
+ {auth.view === 'email-code' ? (
172
+ <EmailCodeForm
173
+ initialEmail={auth.suggestedEmail}
174
+ sent={auth.emailCodeSent}
175
+ target={auth.emailCodeTarget}
176
+ retryAfter={auth.emailRetryAfter}
177
+ loading={auth.isSubmitting}
178
+ error={auth.error}
179
+ successMessage={auth.successMessage}
180
+ onRequest={auth.requestEmailCode}
181
+ onVerify={auth.submitEmailCode}
182
+ onBack={() => auth.showLogin(auth.suggestedEmail)}
183
+ />
184
+ ) : null}
185
+ {auth.view === 'two-factor' && auth.twoFactorChallenge ? (
186
+ <TwoFactorForm
187
+ loading={auth.isSubmitting}
188
+ error={auth.error}
189
+ successMessage={auth.successMessage}
190
+ smsAvailable={auth.twoFactorChallenge.smsAvailable}
191
+ smsRetryAfter={auth.smsRetryAfter}
192
+ onSubmit={auth.submitTwoFactor}
193
+ onSendSms={auth.sendTwoFactorSms}
194
+ onCancel={auth.cancelTwoFactor}
195
+ />
196
+ ) : null}
197
+
148
198
  {auth.view === 'login' ? (
149
199
  <LoginForm
150
200
  initialEmail={auth.suggestedEmail}
@@ -158,13 +208,47 @@ export const AuthModal: FC<AuthModalProps> = ({
158
208
  />
159
209
  ) : null}
160
210
 
211
+ {auth.view === 'login' && auth.browserSignInAvailable ? (
212
+ <View style={{ gap: 12 }}>
213
+ {(['google', 'apple', 'facebook', 'passkey'] as const).map((provider) => (
214
+ <Pressable
215
+ key={provider}
216
+ accessibilityRole="button"
217
+ disabled={auth.isSubmitting}
218
+ onPress={() => void auth.submitBrowserSignIn(provider)}
219
+ >
220
+ <Text style={{ color: theme.accent }}>
221
+ {provider === 'passkey'
222
+ ? 'Sign in with a passkey'
223
+ : `Sign in with ${provider === 'google' ? 'Google' : provider === 'apple' ? 'Apple' : 'Facebook'}`}
224
+ </Text>
225
+ </Pressable>
226
+ ))}
227
+ </View>
228
+ ) : null}
229
+ {auth.view === 'login' && auth.emailCodeAvailable ? (
230
+ <Pressable onPress={() => auth.showEmailCode(auth.suggestedEmail)}>
231
+ <Text style={{ color: theme.accent }}>Sign in with an email code</Text>
232
+ </Pressable>
233
+ ) : null}
161
234
  {auth.view === 'register' && allowRegistration ? (
162
235
  <RegisterForm
163
236
  initialEmail={auth.suggestedEmail}
164
237
  errors={auth.fieldErrors}
165
238
  error={auth.error}
166
239
  loading={auth.isSubmitting}
167
- requireAddress={requireRegistrationAddress}
240
+ requireAddress={auth.requireRegistrationAddress}
241
+ requireTerms={Boolean(auth.accountCompletion)}
242
+ completionRefresh={
243
+ auth.accountCompletion
244
+ ? {
245
+ retryAfter: auth.emailRetryAfter,
246
+ onRequest: () =>
247
+ auth.requestEmailCode(auth.accountCompletion!.email),
248
+ onVerify: auth.submitEmailCode,
249
+ }
250
+ : undefined
251
+ }
168
252
  onSubmit={auth.submitRegistration}
169
253
  onFieldChange={auth.clearFieldError}
170
254
  />