tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.5
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 +92 -8
- package/dist/index.d.mts +7374 -288
- package/dist/index.d.ts +7374 -288
- package/dist/index.js +23316 -5980
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23461 -6058
- package/dist/index.mjs.map +1 -1
- package/package.json +22 -10
- package/scripts/build-seat-map.mjs +31 -0
- package/scripts/seat-map-entry.jsx +22 -0
- package/src/CardInput.tsx +64 -3
- package/src/EmbeddedCheckout.tsx +129 -36
- package/src/auth/AuthModal.tsx +90 -6
- 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 +8 -1
- 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/AccountRewards.tsx +108 -0
- package/src/sections/AddOns.tsx +99 -148
- package/src/sections/BillingInfo.tsx +63 -64
- package/src/sections/Bundles.tsx +467 -0
- package/src/sections/EventDiscussionView.tsx +633 -0
- package/src/sections/Passes.tsx +177 -0
- package/src/sections/Payment.tsx +169 -52
- package/src/sections/PostReaction.tsx +122 -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/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/discussion/CommentImages.tsx +342 -0
- package/src/sections/discussion/SwipeReply.tsx +88 -0
- package/src/sections/index.ts +20 -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 +25 -13
- 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 +134 -0
- package/src/ui/QuantitySelect.tsx +26 -2
- package/src/ui/Select.tsx +4 -1
- package/src/ui/SignaturePad.tsx +218 -0
- package/src/ui/index.ts +1 -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.5",
|
|
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",
|
|
@@ -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-
|
|
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
|
|
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
|
-
|
|
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,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-
|
|
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 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 {
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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 (
|
|
164
|
+
if (visibleView === view) return
|
|
165
|
+
if (visibleView === 'checkout') setView('checkout')
|
|
166
|
+
else {
|
|
146
167
|
setView('tickets')
|
|
147
168
|
setSelectionView('tickets')
|
|
148
169
|
}
|
|
149
|
-
}, [
|
|
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
|
-
{
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
{
|
|
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
|
|
248
|
-
|
|
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
|
-
<
|
|
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
|
})
|
package/src/auth/AuthModal.tsx
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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
|
|
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={
|
|
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
|
/>
|