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.
- package/README.md +115 -24
- package/dist/index.d.mts +8194 -282
- package/dist/index.d.ts +8194 -282
- package/dist/index.js +32308 -7001
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32547 -7127
- package/dist/index.mjs.map +1 -1
- package/package.json +23 -11
- package/scripts/build-seat-map.mjs +31 -0
- package/scripts/seat-map-entry.jsx +22 -0
- package/scripts/verify-discussion-support.cjs +131 -0
- package/src/CardInput.tsx +64 -3
- package/src/EmbeddedCheckout.tsx +140 -38
- package/src/auth/AuthModal.tsx +186 -9
- package/src/auth/EmailCodeForm.tsx +91 -0
- package/src/auth/RegisterForm.tsx +91 -5
- package/src/auth/TwoFactorForm.tsx +74 -0
- package/src/auth/index.ts +4 -0
- package/src/configure.ts +19 -14
- package/src/confirmer.ts +112 -20
- package/src/generated/seatMapWidget.ts +3 -0
- package/src/index.tsx +1 -1
- package/src/sections/Accommodation.tsx +57 -0
- package/src/sections/AccountDeletion.tsx +347 -0
- package/src/sections/AccountEmailEnrichment.tsx +122 -0
- package/src/sections/AccountInformation.tsx +139 -0
- package/src/sections/AccountPaymentPlan.tsx +105 -0
- package/src/sections/AccountRewards.tsx +108 -0
- package/src/sections/AddOns.tsx +99 -148
- package/src/sections/BillingInfo.tsx +80 -78
- package/src/sections/Bundles.tsx +467 -0
- package/src/sections/EventDiscussionView.tsx +655 -0
- package/src/sections/NotificationPreferences.tsx +244 -0
- package/src/sections/OrderSummary.tsx +29 -2
- package/src/sections/Passes.tsx +177 -0
- package/src/sections/Payment.tsx +170 -53
- package/src/sections/PhoneAuthentication.tsx +371 -0
- package/src/sections/PostReaction.tsx +122 -0
- package/src/sections/ProfileSettings.tsx +446 -0
- package/src/sections/Rewards.tsx +320 -0
- package/src/sections/SeatMap.tsx +225 -0
- package/src/sections/SeatsIoMap.tsx +110 -0
- package/src/sections/Streaming.tsx +278 -0
- package/src/sections/TicketChange.tsx +379 -0
- package/src/sections/TicketGift.tsx +206 -0
- package/src/sections/TicketRefund.tsx +104 -0
- package/src/sections/Tickets.tsx +289 -208
- package/src/sections/Trips.tsx +87 -0
- package/src/sections/WaiverSigning.tsx +429 -0
- package/src/sections/billing/PhoneIdentity.tsx +169 -0
- package/src/sections/billing/TicketHoldersCard.tsx +4 -0
- package/src/sections/discussion/CommentImages.tsx +366 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +46 -0
- package/src/sections/rewards/RewardsLadder.tsx +167 -0
- package/src/sections/rewards/RewardsTree.tsx +117 -0
- package/src/storage.ts +8 -9
- package/src/ui/CheckoutProvider.tsx +51 -15
- package/src/ui/CustomFields.tsx +48 -12
- package/src/ui/Field.tsx +21 -3
- package/src/ui/Frozen.tsx +22 -0
- package/src/ui/InstallmentPicker.tsx +15 -18
- package/src/ui/PaymentMethods.tsx +6 -3
- package/src/ui/ProviderPages.tsx +214 -0
- package/src/ui/QuantitySelect.tsx +28 -3
- package/src/ui/Select.tsx +4 -1
- package/src/ui/SignaturePad.tsx +218 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/primitives.tsx +13 -2
- package/src/ui/useAccountT.ts +16 -0
- 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.
|
|
3
|
+
"version": "2.0.0-beta.10",
|
|
4
4
|
"description": "TheTicketFairy ReactNative Checkout library",
|
|
5
|
-
"homepage": "https://github.com/theticketfairy/tf-
|
|
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-
|
|
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-
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
package/src/EmbeddedCheckout.tsx
CHANGED
|
@@ -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-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 {
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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 (
|
|
169
|
+
if (visibleView === view) return
|
|
170
|
+
if (visibleView === 'checkout') setView('checkout')
|
|
171
|
+
else {
|
|
146
172
|
setView('tickets')
|
|
147
173
|
setSelectionView('tickets')
|
|
148
174
|
}
|
|
149
|
-
}, [
|
|
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
|
-
{
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
{
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
})
|