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

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 +8194 -282
  3. package/dist/index.d.ts +8194 -282
  4. package/dist/index.js +32308 -7001
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32547 -7127
  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
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.10",
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",
@@ -36,12 +37,12 @@
36
37
  "!**/.*"
37
38
  ],
38
39
  "scripts": {
39
- "test": "jest --passWithNoTests",
40
+ "test": "node scripts/verify-discussion-support.cjs && jest --passWithNoTests",
40
41
  "typecheck": "tsc -p tsconfig.build.json",
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 — 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
+ }
@@ -0,0 +1,131 @@
1
+ /* Executes the real native support Pressable; no native OS/mail account required. */
2
+ /* eslint-disable @typescript-eslint/no-require-imports -- Node CommonJS verifier, matching the package's script entry. */
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('node:fs')
5
+ const path = require('node:path')
6
+ const vm = require('node:vm')
7
+ const ts = require('typescript')
8
+ const React = require('react')
9
+ const opened = []
10
+ let rejectOpen = false
11
+ const source = fs.readFileSync(
12
+ path.join(__dirname, '../src/sections/EventDiscussionView.tsx'),
13
+ 'utf8'
14
+ )
15
+ const compiled = ts.transpileModule(source, {
16
+ compilerOptions: {
17
+ module: ts.ModuleKind.CommonJS,
18
+ target: ts.ScriptTarget.ES2022,
19
+ jsx: ts.JsxEmit.React,
20
+ esModuleInterop: true,
21
+ },
22
+ }).outputText
23
+ const moduleExports = {}
24
+ vm.runInNewContext(compiled, {
25
+ exports: moduleExports,
26
+ require: (name) => {
27
+ if (name === 'react')
28
+ return {
29
+ ...React,
30
+ useRef: (value) => ({ current: value }),
31
+ useState: (value) => [value, () => {}],
32
+ }
33
+ if (name === 'react-native')
34
+ return {
35
+ ...Object.fromEntries(
36
+ [
37
+ 'ActivityIndicator',
38
+ 'FlatList',
39
+ 'Image',
40
+ 'KeyboardAvoidingView',
41
+ 'Modal',
42
+ 'Pressable',
43
+ 'Text',
44
+ 'TextInput',
45
+ 'View',
46
+ ].map((name) => [name, name])
47
+ ),
48
+ Platform: { OS: 'ios' },
49
+ StyleSheet: { create: (styles) => styles },
50
+ Linking: {
51
+ openURL: (url) => {
52
+ opened.push(url)
53
+ return rejectOpen
54
+ ? Promise.reject(Error('No mail client'))
55
+ : Promise.resolve()
56
+ },
57
+ },
58
+ }
59
+ if (name === 'tf-checkout-shared') return { EVENT_COMMENT_IMAGE_MAX_COUNT: 5 }
60
+ if (name === '../ui/CheckoutProvider') return { useTheme: () => ({}) }
61
+ if (name === '../ui/useAccountT') return { useAccountT: () => (value) => value }
62
+ if (name === './discussion/CommentImages')
63
+ return { CommentImages: 'CommentImages', CommentImageDrafts: 'CommentImageDrafts' }
64
+ if (name === './discussion/SwipeReply') return { SwipeReply: 'SwipeReply' }
65
+ throw Error('Unexpected dependency: ' + name)
66
+ },
67
+ })
68
+ const links = (node) =>
69
+ !node || typeof node !== 'object'
70
+ ? []
71
+ : [
72
+ ...(node.type === 'Pressable' && node.props.accessibilityRole === 'link'
73
+ ? [node]
74
+ : []),
75
+ ...React.Children.toArray(node.props?.children).flatMap(links),
76
+ ]
77
+ const render = (url) => {
78
+ const element = moduleExports.EventDiscussionView({
79
+ eventId: '42',
80
+ discussion: {
81
+ scopeKey: 'synthetic-actor-7',
82
+ comments: [],
83
+ blockedAuthors: [],
84
+ imagesBase64: [],
85
+ body: '',
86
+ canPost: false,
87
+ supportUrl: url,
88
+ },
89
+ })
90
+ const tree = element.type(element.props)
91
+ const support = links(tree)
92
+ assert.equal(support.length, 1)
93
+ return support[0]
94
+ }
95
+ ;(async () => {
96
+ // This exact URI comes from EventDiscussionService::page, including its subject.
97
+ const canonical = 'mailto:support@theticketfairy.com?subject=Event%20comment%20report'
98
+ for (const url of [canonical, 'https://www.ticketfairy.com/support']) {
99
+ opened.length = 0
100
+ const support = render(url)
101
+ assert.deepEqual(opened, [], 'rendering must never open or send anything')
102
+ support.props.onPress()
103
+ await new Promise((resolve) => setImmediate(resolve))
104
+ assert.deepEqual(opened, [url], 'the user press must reach the native Linking port')
105
+ }
106
+ for (const url of [
107
+ 'javascript:alert(1)',
108
+ 'file:///private',
109
+ 'http://example.invalid',
110
+ 'tel:123',
111
+ ]) {
112
+ opened.length = 0
113
+ render(url).props.onPress()
114
+ assert.deepEqual(opened, [], 'unsupported schemes must not reach the OS')
115
+ }
116
+ rejectOpen = true
117
+ opened.length = 0
118
+ render(canonical).props.onPress()
119
+ await new Promise((resolve) => setImmediate(resolve))
120
+ assert.deepEqual(
121
+ opened,
122
+ [canonical],
123
+ 'an unavailable mail client must be handled quietly'
124
+ )
125
+ process.stdout.write(
126
+ 'Native discussion support PASS: user press, canonical mailto, HTTPS, unsafe schemes and unavailable client.\n'
127
+ )
128
+ })().catch((error) => {
129
+ console.error(error)
130
+ process.exitCode = 1
131
+ })
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,40 @@
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 CheckoutImagePicker,
31
+ type EventCondition,
32
+ type PlaceOrderResult,
33
+ type TfProfile,
27
34
  } from 'tf-checkout-shared'
28
35
 
29
36
  import { AuthModal } from './auth/AuthModal'
30
37
  import { AddOns } from './sections/AddOns'
31
38
  import { BillingInfo, type CheckoutPrivacyPolicy } from './sections/BillingInfo'
39
+ import { Bundles } from './sections/Bundles'
32
40
  import { Confirmation } from './sections/Confirmation'
33
41
  import { IDVerification } from './sections/IDVerification'
34
42
  import { Payment } from './sections/Payment'
35
43
  import { Tickets } from './sections/Tickets'
44
+ import { SeatMap } from './sections/SeatMap'
45
+ import { WaiverSigning } from './sections/WaiverSigning'
36
46
  import { CheckoutProvider, useCheckoutContext, useTheme } from './ui/CheckoutProvider'
37
- import { Card, BodyText, MutedText, Button } from './ui/primitives'
47
+ import { Button } from './ui/primitives'
38
48
  import { TimerBanner } from './ui/TimerBanner'
39
49
  import type { CheckoutTheme } from './ui/theme'
40
50
 
@@ -42,6 +52,9 @@ type SelectionView = 'tickets' | 'seats'
42
52
  export type CheckoutView = SelectionView | 'checkout'
43
53
 
44
54
  export interface EmbeddedCheckoutAuthOptions {
55
+ onCheckoutAuthenticated?: (
56
+ profile: import('tf-checkout-shared').CheckoutBuyerProfile
57
+ ) => void
45
58
  allowRegistration?: boolean
46
59
  allowPasswordReset?: boolean
47
60
  requireRegistrationAddress?: boolean
@@ -50,6 +63,8 @@ export interface EmbeddedCheckoutAuthOptions {
50
63
  }
51
64
 
52
65
  export interface EmbeddedCheckoutProps {
66
+ /** OS picker used for order-, holder- and add-on image questions. */
67
+ pickImage?: CheckoutImagePicker
53
68
  /** The event whose tickets to sell. */
54
69
  eventId: string
55
70
  /** Called with the placed order once payment settles. */
@@ -59,6 +74,9 @@ export interface EmbeddedCheckoutProps {
59
74
  * leaves completion routing to `onComplete` and keeps the checkout mounted.
60
75
  */
61
76
  confirmationMode?: 'inline' | 'redirect'
77
+ /** Rendered inside the receipt, below the order details — the seam for the
78
+ * `<Rewards/>` block or a host's own post-purchase content. */
79
+ confirmationChildren?: ReactNode
62
80
  /** Initial screen. `seats` preserves an existing seat-map entry point. */
63
81
  initialView?: SelectionView
64
82
  /** Reports the visible flow view so hosts can coordinate their own UI. */
@@ -104,6 +122,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
104
122
  eventId,
105
123
  onComplete,
106
124
  confirmationMode = 'inline',
125
+ confirmationChildren,
107
126
  initialView = 'tickets',
108
127
  onViewChange,
109
128
  requireEmailConfirmation = true,
@@ -136,17 +155,24 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
136
155
  const requestLogin = onLogin ?? (auth !== false ? openBuiltInAuth : undefined)
137
156
 
138
157
  // Never let state from another event/run select this component's UI view.
158
+ // While the order is placed or charged, Payment stays on screen: it holds
159
+ // the only retry that finalizes a charged order (`embeddedCheckoutView`).
139
160
  const checkoutBelongsToEvent = checkout.eventId === eventId
140
- const checkoutAvailable = checkoutBelongsToEvent && checkout.status !== 'expired'
141
- const visibleView: CheckoutView =
142
- view === 'checkout' && !checkoutAvailable ? 'tickets' : view
161
+ const visibleView: CheckoutView = embeddedCheckoutView(view, {
162
+ belongsToEvent: checkoutBelongsToEvent,
163
+ status: checkout.status,
164
+ orderFixed: checkout.orderFixed,
165
+ })
166
+ const leaveAllowed = canLeavePayment(checkout)
143
167
 
144
168
  useEffect(() => {
145
- if (view === 'checkout' && !checkoutAvailable) {
169
+ if (visibleView === view) return
170
+ if (visibleView === 'checkout') setView('checkout')
171
+ else {
146
172
  setView('tickets')
147
173
  setSelectionView('tickets')
148
174
  }
149
- }, [checkoutAvailable, view])
175
+ }, [view, visibleView])
150
176
 
151
177
  useEffect(() => {
152
178
  onViewChange?.(visibleView)
@@ -165,6 +191,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
165
191
  }
166
192
 
167
193
  const editSelection = () => {
194
+ if (!canLeavePayment(checkout)) return
168
195
  editedDetailsRef.current = {
169
196
  billing: checkout.billing,
170
197
  holders: checkout.holders,
@@ -196,7 +223,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
196
223
  return (
197
224
  <ScrollView contentContainerStyle={styles.content}>
198
225
  <IDVerification />
199
- <Confirmation />
226
+ <Confirmation>{confirmationChildren}</Confirmation>
200
227
  </ScrollView>
201
228
  )
202
229
  }
@@ -206,15 +233,27 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
206
233
  contentContainerStyle={styles.content}
207
234
  keyboardShouldPersistTaps="handled"
208
235
  >
209
- {visibleView === 'tickets' && (
210
- <Tickets
236
+ {/* Bundles stay mounted while hidden, as on web, so a purchase in
237
+ progress keeps its order and key when the buyer goes to checkout. */}
238
+ <View style={visibleView === 'tickets' ? styles.stack : styles.hidden}>
239
+ <Bundles
211
240
  eventId={eventId}
212
- onContinue={() => enterCheckout('tickets')}
213
- onSelectSeats={() => setView('seats')}
214
- initialPromoCode={initialPromoCode}
215
- referrer={referrer}
216
- readAttributionFromUrl={readAttributionFromUrl}
241
+ visible={visibleView === 'tickets'}
242
+ onComplete={onComplete}
243
+ confirmationMode={confirmationMode}
217
244
  />
245
+ </View>
246
+ {visibleView === 'tickets' && (
247
+ <View style={styles.stack}>
248
+ <Tickets
249
+ eventId={eventId}
250
+ onContinue={() => enterCheckout('tickets')}
251
+ onSelectSeats={() => setView('seats')}
252
+ initialPromoCode={initialPromoCode}
253
+ referrer={referrer}
254
+ readAttributionFromUrl={readAttributionFromUrl}
255
+ />
256
+ </View>
218
257
  )}
219
258
 
220
259
  {visibleView === 'seats' && (
@@ -224,15 +263,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
224
263
  ← Back to tickets
225
264
  </Text>
226
265
  </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>
266
+ <SeatMap eventId={eventId} onContinue={() => enterCheckout('seats')} />
236
267
  </View>
237
268
  )}
238
269
 
@@ -244,8 +275,21 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
244
275
  {selectedCount} {selectedCount === 1 ? 'ticket' : 'tickets'} selected
245
276
  {formattedTotal ? ` · ${formattedTotal}` : ''}
246
277
  </Text>
247
- <Pressable onPress={editSelection} accessibilityRole="button">
248
- <Text style={[styles.editText, { color: theme.accent }]}>Edit</Text>
278
+ <Pressable
279
+ onPress={editSelection}
280
+ disabled={!leaveAllowed}
281
+ accessibilityRole="button"
282
+ accessibilityState={{ disabled: !leaveAllowed }}
283
+ >
284
+ <Text
285
+ style={[
286
+ styles.editText,
287
+ { color: theme.accent },
288
+ !leaveAllowed && styles.editTextDisabled,
289
+ ]}
290
+ >
291
+ Edit
292
+ </Text>
249
293
  </Pressable>
250
294
  </View>
251
295
 
@@ -262,6 +306,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
262
306
  privacyPolicy={privacyPolicy}
263
307
  showPrivacyError={privacyAttempted}
264
308
  />
309
+ <WaiverSigning eventId={eventId} />
265
310
  <Payment
266
311
  onComplete={onComplete}
267
312
  eventId={eventId}
@@ -278,6 +323,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
278
323
 
279
324
  {authRequest && auth !== false && !onLogin && (
280
325
  <AuthModal
326
+ checkoutEventId={eventId}
281
327
  key={authRequest.key}
282
328
  open
283
329
  initialEmail={authRequest.email}
@@ -291,6 +337,7 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
291
337
  requireRegistrationAddress={auth?.requireRegistrationAddress}
292
338
  logoUrl={auth?.logoUrl}
293
339
  onAuthenticated={auth?.onAuthenticated}
340
+ onCheckoutAuthenticated={auth?.onCheckoutAuthenticated}
294
341
  onClose={() => setAuthRequest(null)}
295
342
  />
296
343
  )}
@@ -298,14 +345,59 @@ const Flow: FC<Omit<EmbeddedCheckoutProps, 'theme'>> = ({
298
345
  )
299
346
  }
300
347
 
301
- export const EmbeddedCheckout: FC<EmbeddedCheckoutProps> = ({ theme, ...flow }) => (
302
- <CheckoutProvider theme={theme}>
303
- <Flow key={flow.eventId} {...flow} />
348
+ type FlowProps = Omit<EmbeddedCheckoutProps, 'theme'>
349
+
350
+ // The native flow's own copy is English, as the rest of this screen.
351
+ const translate = createTranslator()
352
+
353
+ /**
354
+ * Keeps the checkout on the event whose order is being paid, is charged, or
355
+ * is kept after a payment step that may have moved money, while the host
356
+ * asks for another event: that event's Payment is the only way to finish
357
+ * the order, and the engine starts no other cart until it settles or resets
358
+ * (`embeddedCheckoutEvent`). It keeps the props it had for that event, even
359
+ * across a remount, so nothing the new event adds blocks the retry
360
+ * (`embeddedCheckoutProps`). The
361
+ * buyer reads why.
362
+ */
363
+ const EventFlow: FC<FlowProps> = (flow) => {
364
+ const checkout = useCheckoutContext()
365
+ const theme = useTheme()
366
+ const shown = embeddedCheckoutEvent(flow.eventId, checkout)
367
+ // A held order keeps the props it was shown with, kept with the store.
368
+ const flowProps = embeddedCheckoutProps(checkout.store, flow)
369
+ const heldEvent = hooks.useEvent(shown.waitingFor ? shown.eventId : undefined).data
370
+ const notice = shown.waitingFor ? heldOrderNotice(heldEvent?.name) : null
371
+ return (
372
+ <>
373
+ {notice && (
374
+ <View
375
+ accessibilityLiveRegion="polite"
376
+ style={[styles.notice, { borderColor: theme.accent }]}
377
+ >
378
+ <Text style={[styles.noticeText, { color: theme.text }]}>
379
+ {translate(notice.key, notice.params)}
380
+ </Text>
381
+ </View>
382
+ )}
383
+ <Flow key={flowProps.eventId} {...flowProps} />
384
+ </>
385
+ )
386
+ }
387
+
388
+ export const EmbeddedCheckout: FC<EmbeddedCheckoutProps> = ({
389
+ theme,
390
+ pickImage,
391
+ ...flow
392
+ }) => (
393
+ <CheckoutProvider theme={theme} pickImage={pickImage}>
394
+ <EventFlow {...flow} />
304
395
  </CheckoutProvider>
305
396
  )
306
397
 
307
398
  const styles = StyleSheet.create({
308
399
  content: { padding: 16, gap: 12 },
400
+ hidden: { display: 'none' },
309
401
  stack: { gap: 12 },
310
402
  back: { paddingVertical: 4 },
311
403
  backText: { fontSize: 15, fontWeight: '600' },
@@ -320,4 +412,14 @@ const styles = StyleSheet.create({
320
412
  },
321
413
  selectionText: { fontSize: 14, flex: 1 },
322
414
  editText: { fontSize: 14, fontWeight: '600' },
415
+ editTextDisabled: { opacity: 0.4 },
416
+ notice: {
417
+ marginHorizontal: 16,
418
+ marginTop: 16,
419
+ borderWidth: StyleSheet.hairlineWidth,
420
+ borderRadius: 10,
421
+ paddingVertical: 10,
422
+ paddingHorizontal: 14,
423
+ },
424
+ noticeText: { fontSize: 14, lineHeight: 20 },
323
425
  })