tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -24
- package/dist/index.d.mts +8195 -282
- package/dist/index.d.ts +8195 -282
- package/dist/index.js +32346 -7001
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32586 -7128
- 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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The referral tree, drawn with positioned `View`s.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately no SVG: adding `react-native-svg` would be a new native peer for
|
|
5
|
+
* every consumer app, and a dot-and-line picture does not need one. Each dot is
|
|
6
|
+
* an absolutely-positioned circle and each link a thin, rotated view — the
|
|
7
|
+
* positions themselves are the same shared radial layout the web SVG uses, so
|
|
8
|
+
* both platforms draw the identical picture.
|
|
9
|
+
*/
|
|
10
|
+
import React, { useMemo, type FC } from 'react'
|
|
11
|
+
import { StyleSheet, View } from 'react-native'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
computeRadialTreeLayout,
|
|
15
|
+
isTreeRoot,
|
|
16
|
+
radialTreeEdges,
|
|
17
|
+
treeNodeId,
|
|
18
|
+
treeRootId,
|
|
19
|
+
type RewardsReferralTree,
|
|
20
|
+
} from 'tf-checkout-shared'
|
|
21
|
+
|
|
22
|
+
import { useTheme } from '../../ui/CheckoutProvider'
|
|
23
|
+
|
|
24
|
+
export interface RewardsTreeProps {
|
|
25
|
+
tree: RewardsReferralTree
|
|
26
|
+
/** Edge length of the square the tree is drawn in. */
|
|
27
|
+
size?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Above this the dots overlap into a smudge on a phone screen. */
|
|
31
|
+
const MAX_NODES = 120
|
|
32
|
+
const DOT = 10
|
|
33
|
+
const ROOT_DOT = 16
|
|
34
|
+
|
|
35
|
+
export const RewardsTree: FC<RewardsTreeProps> = ({ tree, size = 260 }) => {
|
|
36
|
+
const theme = useTheme()
|
|
37
|
+
const nodes = useMemo(() => (tree?.nodes ?? []).slice(0, MAX_NODES), [tree])
|
|
38
|
+
|
|
39
|
+
const { layout, edges, rootId } = useMemo(() => {
|
|
40
|
+
// The buyer is not a node of their own tree — they are the parent the people
|
|
41
|
+
// they referred name. Looking for a parentless node finds nobody and draws a
|
|
42
|
+
// friend in the buyer's place.
|
|
43
|
+
const root = treeRootId(nodes)
|
|
44
|
+
const computed = computeRadialTreeLayout(nodes, root)
|
|
45
|
+
return { layout: computed, edges: radialTreeEdges(nodes, computed), rootId: root }
|
|
46
|
+
}, [nodes])
|
|
47
|
+
|
|
48
|
+
if (nodes.length === 0) return null
|
|
49
|
+
|
|
50
|
+
// Inset by the largest dot's radius so the outermost ring stays on canvas.
|
|
51
|
+
const inset = ROOT_DOT / 2
|
|
52
|
+
const span = size - ROOT_DOT
|
|
53
|
+
const at = (value: number) => inset + value * span
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View style={[styles.canvas, { width: size, height: size }]}>
|
|
57
|
+
{edges.map((edge) => {
|
|
58
|
+
const x1 = at(edge.from.x)
|
|
59
|
+
const y1 = at(edge.from.y)
|
|
60
|
+
const x2 = at(edge.to.x)
|
|
61
|
+
const y2 = at(edge.to.y)
|
|
62
|
+
const length = Math.hypot(x2 - x1, y2 - y1)
|
|
63
|
+
const angle = Math.atan2(y2 - y1, x2 - x1)
|
|
64
|
+
return (
|
|
65
|
+
<View
|
|
66
|
+
key={`${edge.fromId}-${edge.toId}`}
|
|
67
|
+
style={[
|
|
68
|
+
styles.link,
|
|
69
|
+
{
|
|
70
|
+
backgroundColor: theme.border,
|
|
71
|
+
width: length,
|
|
72
|
+
left: x1,
|
|
73
|
+
top: y1,
|
|
74
|
+
// Rotate about the start point so the line runs to the child.
|
|
75
|
+
transform: [
|
|
76
|
+
{ translateX: length / 2 },
|
|
77
|
+
{ rotate: `${angle}rad` },
|
|
78
|
+
{ translateX: -length / 2 },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
]}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
})}
|
|
85
|
+
{nodes.map((node) => {
|
|
86
|
+
const point = layout[treeNodeId(node)]
|
|
87
|
+
if (!point) return null
|
|
88
|
+
const isRoot = isTreeRoot(node, rootId)
|
|
89
|
+
const diameter = isRoot ? ROOT_DOT : DOT
|
|
90
|
+
return (
|
|
91
|
+
<View
|
|
92
|
+
key={treeNodeId(node)}
|
|
93
|
+
accessibilityLabel={node.public_name}
|
|
94
|
+
style={[
|
|
95
|
+
styles.dot,
|
|
96
|
+
{
|
|
97
|
+
width: diameter,
|
|
98
|
+
height: diameter,
|
|
99
|
+
borderRadius: diameter / 2,
|
|
100
|
+
backgroundColor: isRoot ? theme.text : theme.accent,
|
|
101
|
+
opacity: isRoot ? 1 : 0.75,
|
|
102
|
+
left: at(point.x) - diameter / 2,
|
|
103
|
+
top: at(point.y) - diameter / 2,
|
|
104
|
+
},
|
|
105
|
+
]}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</View>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const styles = StyleSheet.create({
|
|
114
|
+
canvas: { alignSelf: 'center' },
|
|
115
|
+
link: { position: 'absolute', height: 1 },
|
|
116
|
+
dot: { position: 'absolute' },
|
|
117
|
+
})
|
package/src/storage.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React Native storage adapter for the SDK's
|
|
2
|
+
* React Native storage adapter for the SDK's persisted session credentials.
|
|
3
3
|
*
|
|
4
|
-
* The shared transport
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* bearer session survives an app restart. `react-native-mmkv` is exactly that —
|
|
8
|
-
* synchronous get/set/delete backed by native MMKV — so we wrap it here.
|
|
4
|
+
* The shared transport uses a SYNCHRONOUS `StorageAdapter` (see shared
|
|
5
|
+
* `client/storage.ts`). MMKV 3 provides persisted get/set/delete operations,
|
|
6
|
+
* keeping the SDK's session credentials available across app restarts.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Default native cookie auth stores its ecommerce ticket here and sends it in
|
|
9
|
+
* request headers; the ambient OS cookie jar stays disabled. Other configured
|
|
10
|
+
* SDK token credentials use this same adapter. Hosts may inject another
|
|
11
|
+
* synchronous adapter when configuring checkout.
|
|
13
12
|
*/
|
|
14
13
|
import { MMKV } from 'react-native-mmkv'
|
|
15
14
|
|
|
@@ -24,14 +24,21 @@ import React, {
|
|
|
24
24
|
|
|
25
25
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
26
26
|
|
|
27
|
-
import
|
|
27
|
+
import {
|
|
28
|
+
hooks,
|
|
29
|
+
type UseCheckout,
|
|
30
|
+
type CheckoutOptions,
|
|
31
|
+
type CheckoutImagePicker,
|
|
32
|
+
} from 'tf-checkout-shared'
|
|
28
33
|
|
|
29
34
|
import { useCheckout } from '../useCheckout'
|
|
30
35
|
import { useInstallmentPicker } from './InstallmentPicker'
|
|
36
|
+
import { useProviderPages } from './ProviderPages'
|
|
31
37
|
import { resolveTheme, type CheckoutTheme, type ResolvedTheme } from './theme'
|
|
32
38
|
|
|
33
39
|
const CheckoutCtx = createContext<UseCheckout | null>(null)
|
|
34
40
|
const ThemeCtx = createContext<ResolvedTheme>(resolveTheme())
|
|
41
|
+
const ImagePickerCtx = createContext<CheckoutImagePicker | undefined>(undefined)
|
|
35
42
|
const ValidationCtx = createContext<{
|
|
36
43
|
attempt: number
|
|
37
44
|
requestValidation: () => void
|
|
@@ -50,6 +57,10 @@ const getQueryClient = (): QueryClient => {
|
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export interface CheckoutProviderProps {
|
|
60
|
+
/** Host-owned OS photo picker. Return raw base64; the shared field validates it. */
|
|
61
|
+
pickImage?: CheckoutImagePicker
|
|
62
|
+
/** Wallet/redirect settings for the provider-owned native engine. */
|
|
63
|
+
options?: CheckoutOptions
|
|
53
64
|
/**
|
|
54
65
|
* Bring your own checkout instance (from a top-level `useCheckout()`). Omit
|
|
55
66
|
* and the provider runs `useCheckout()` itself. Either way the sections read
|
|
@@ -63,12 +74,25 @@ export interface CheckoutProviderProps {
|
|
|
63
74
|
children: ReactNode
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
export interface CheckoutThemeProviderProps {
|
|
78
|
+
theme?: CheckoutTheme
|
|
79
|
+
children: ReactNode
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Retheme standalone SDK screens without binding checkout or query state. */
|
|
83
|
+
export const CheckoutThemeProvider: FC<CheckoutThemeProviderProps> = ({ theme, children }) => {
|
|
84
|
+
const resolved = useMemo(() => resolveTheme(theme), [theme])
|
|
85
|
+
return <ThemeCtx.Provider value={resolved}>{children}</ThemeCtx.Provider>
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
/** The context boundary — given a resolved checkout instance + theme. */
|
|
67
89
|
const CheckoutRoot: FC<{
|
|
68
90
|
checkout: UseCheckout
|
|
91
|
+
pickImage?: CheckoutImagePicker
|
|
69
92
|
theme?: CheckoutTheme
|
|
70
93
|
children: ReactNode
|
|
71
|
-
}> = ({ checkout, theme, children }) => {
|
|
94
|
+
}> = ({ checkout, theme, pickImage, children }) => {
|
|
95
|
+
const inheritedPicker = useContext(ImagePickerCtx)
|
|
72
96
|
const [attempt, setAttempt] = useState(0)
|
|
73
97
|
const requestValidation = useCallback(() => setAttempt((n) => n + 1), [])
|
|
74
98
|
const validation = useMemo(
|
|
@@ -80,7 +104,9 @@ const CheckoutRoot: FC<{
|
|
|
80
104
|
return (
|
|
81
105
|
<CheckoutCtx.Provider value={checkout}>
|
|
82
106
|
<ThemeCtx.Provider value={resolved}>
|
|
83
|
-
<
|
|
107
|
+
<ImagePickerCtx.Provider value={pickImage ?? inheritedPicker}>
|
|
108
|
+
<ValidationCtx.Provider value={validation}>{children}</ValidationCtx.Provider>
|
|
109
|
+
</ImagePickerCtx.Provider>
|
|
84
110
|
</ThemeCtx.Provider>
|
|
85
111
|
</CheckoutCtx.Provider>
|
|
86
112
|
)
|
|
@@ -93,11 +119,16 @@ const SelfProvidedCheckout: FC<Omit<CheckoutProviderProps, 'value' | 'queryClien
|
|
|
93
119
|
}) => {
|
|
94
120
|
// Bridge the engine's mid-payment MSI (installments) callback to a native modal.
|
|
95
121
|
const { onInstallmentOffers, picker } = useInstallmentPicker()
|
|
96
|
-
const
|
|
122
|
+
const { providerPage, page } = useProviderPages()
|
|
123
|
+
const checkout = useCheckout({
|
|
124
|
+
ports: { onInstallmentOffers, providerPage },
|
|
125
|
+
options: rest.options,
|
|
126
|
+
})
|
|
97
127
|
return (
|
|
98
128
|
<CheckoutRoot checkout={checkout} {...rest}>
|
|
99
129
|
{children}
|
|
100
130
|
{picker}
|
|
131
|
+
{page}
|
|
101
132
|
</CheckoutRoot>
|
|
102
133
|
)
|
|
103
134
|
}
|
|
@@ -106,17 +137,21 @@ export const CheckoutProvider: FC<CheckoutProviderProps> = ({
|
|
|
106
137
|
value,
|
|
107
138
|
queryClient,
|
|
108
139
|
...rest
|
|
109
|
-
}) =>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
140
|
+
}) => {
|
|
141
|
+
const session = hooks.useAppCheckoutSession()
|
|
142
|
+
const sessionKey = hooks.ticketHolderFor(value?.store ?? session.store).id
|
|
143
|
+
return (
|
|
144
|
+
// `value` present ⇒ don't call `useCheckout()` (no second binding); absent ⇒
|
|
145
|
+
// the self-providing branch owns it. The choice is static per mount.
|
|
146
|
+
<QueryClientProvider client={queryClient ?? getQueryClient()}>
|
|
147
|
+
{value ? (
|
|
148
|
+
<CheckoutRoot key={sessionKey} checkout={value} {...rest} />
|
|
149
|
+
) : (
|
|
150
|
+
<SelfProvidedCheckout key={sessionKey} {...rest} />
|
|
151
|
+
)}
|
|
152
|
+
</QueryClientProvider>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
120
155
|
|
|
121
156
|
/** Read the shared checkout inside any component under `<CheckoutProvider>`. */
|
|
122
157
|
export const useCheckoutContext = (): UseCheckout => {
|
|
@@ -129,6 +164,7 @@ export const useCheckoutContext = (): UseCheckout => {
|
|
|
129
164
|
|
|
130
165
|
/** Read the resolved theme tokens. */
|
|
131
166
|
export const useTheme = (): ResolvedTheme => useContext(ThemeCtx)
|
|
167
|
+
export const useCheckoutImagePicker = () => useContext(ImagePickerCtx)
|
|
132
168
|
|
|
133
169
|
/** Coordinate submit-attempt validation across separately composed sections. */
|
|
134
170
|
export const useCheckoutValidation = () => {
|
package/src/ui/CustomFields.tsx
CHANGED
|
@@ -12,17 +12,19 @@
|
|
|
12
12
|
* single-line `Input` (value parity preserved).
|
|
13
13
|
*/
|
|
14
14
|
import React, { type FC } from 'react'
|
|
15
|
-
import { View } from 'react-native'
|
|
15
|
+
import { Image, View } from 'react-native'
|
|
16
16
|
|
|
17
17
|
import type {
|
|
18
18
|
CustomFieldValues,
|
|
19
19
|
EventCustomField,
|
|
20
20
|
EventCustomFieldGroup,
|
|
21
21
|
} from 'tf-checkout-shared'
|
|
22
|
-
import { ticketFieldsFor } from 'tf-checkout-shared'
|
|
22
|
+
import { hooks, ticketFieldsFor } from 'tf-checkout-shared'
|
|
23
23
|
|
|
24
24
|
import { Checkbox, Field, Input } from './Field'
|
|
25
|
-
import { ErrorText, MutedText } from './primitives'
|
|
25
|
+
import { Button, ErrorText, MutedText } from './primitives'
|
|
26
|
+
import { useCheckoutImagePicker } from './CheckoutProvider'
|
|
27
|
+
import { useFrozen } from './Frozen'
|
|
26
28
|
import { Select, type SelectOption } from './Select'
|
|
27
29
|
|
|
28
30
|
type FieldValue = string | string[]
|
|
@@ -39,6 +41,47 @@ export interface CustomFieldInputProps {
|
|
|
39
41
|
error?: string
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
const ImageField: FC<CustomFieldInputProps> = ({ field, value, onChange, error }) => {
|
|
45
|
+
const pick = useCheckoutImagePicker()
|
|
46
|
+
const frozen = useFrozen()
|
|
47
|
+
const image = hooks.useCheckoutImageField({
|
|
48
|
+
value: asString(value),
|
|
49
|
+
onChange,
|
|
50
|
+
pick,
|
|
51
|
+
identity: String(field.id),
|
|
52
|
+
disabled: frozen,
|
|
53
|
+
})
|
|
54
|
+
return (
|
|
55
|
+
<Field label={field.label} required={field.required} error={image.error ?? error}>
|
|
56
|
+
{field.description ? <MutedText>{field.description}</MutedText> : null}
|
|
57
|
+
{image.preview ? (
|
|
58
|
+
<Image
|
|
59
|
+
source={{ uri: image.preview }}
|
|
60
|
+
accessibilityLabel={`${field.label} selected image`}
|
|
61
|
+
style={{ width: 160, height: 120, resizeMode: 'contain' }}
|
|
62
|
+
/>
|
|
63
|
+
) : null}
|
|
64
|
+
{image.hasImage && !image.preview ? (
|
|
65
|
+
<MutedText>Image already provided.</MutedText>
|
|
66
|
+
) : null}
|
|
67
|
+
<Button onPress={() => void image.select()} loading={image.busy} disabled={!pick}>
|
|
68
|
+
{image.hasImage ? 'Replace image' : 'Choose image'}
|
|
69
|
+
</Button>
|
|
70
|
+
{!pick ? (
|
|
71
|
+
<ErrorText>
|
|
72
|
+
Photo selection is unavailable. Please contact the checkout host.
|
|
73
|
+
</ErrorText>
|
|
74
|
+
) : null}
|
|
75
|
+
{image.hasImage ? (
|
|
76
|
+
<Button variant="ghost" onPress={image.remove}>
|
|
77
|
+
Remove image
|
|
78
|
+
</Button>
|
|
79
|
+
) : null}
|
|
80
|
+
<MutedText>JPEG, PNG, GIF or WebP. Up to 2 MiB and 4096 pixels per side.</MutedText>
|
|
81
|
+
</Field>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
42
85
|
/** Render one custom field by its declared `type`. */
|
|
43
86
|
export const CustomFieldInput: FC<CustomFieldInputProps> = ({
|
|
44
87
|
field,
|
|
@@ -112,14 +155,7 @@ export const CustomFieldInput: FC<CustomFieldInputProps> = ({
|
|
|
112
155
|
</View>
|
|
113
156
|
)
|
|
114
157
|
case 'image':
|
|
115
|
-
|
|
116
|
-
// rather than silently dropping a required field.
|
|
117
|
-
return (
|
|
118
|
-
<Field label={field.label} required={field.required} error={error}>
|
|
119
|
-
{desc}
|
|
120
|
-
<MutedText>Image upload isn’t supported in this build.</MutedText>
|
|
121
|
-
</Field>
|
|
122
|
-
)
|
|
158
|
+
return <ImageField field={field} value={value} onChange={onChange} error={error} />
|
|
123
159
|
default:
|
|
124
160
|
// text / phone / email
|
|
125
161
|
return (
|
|
@@ -172,5 +208,5 @@ export const CustomFieldGroupFields: FC<CustomFieldGroupFieldsProps> = ({
|
|
|
172
208
|
|
|
173
209
|
/** True when every required field in the group has a non-empty value.
|
|
174
210
|
* Re-exported from the SDK so web, native and the engine preflight all apply
|
|
175
|
-
* one rule
|
|
211
|
+
* one rule; conditional requiredness remains server-authoritative. */
|
|
176
212
|
export { customFieldsComplete } from 'tf-checkout-shared'
|
package/src/ui/Field.tsx
CHANGED
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
TextInput,
|
|
12
12
|
View,
|
|
13
13
|
type KeyboardTypeOptions,
|
|
14
|
+
type TextInputProps,
|
|
14
15
|
} from 'react-native'
|
|
15
16
|
|
|
16
17
|
import { useTheme } from './CheckoutProvider'
|
|
17
18
|
import type { ResolvedTheme } from './theme'
|
|
19
|
+
import { useFrozen } from './Frozen'
|
|
18
20
|
|
|
19
21
|
export const Field: FC<{
|
|
20
22
|
label?: string
|
|
@@ -38,7 +40,17 @@ export const Field: FC<{
|
|
|
38
40
|
)
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
export interface InputProps
|
|
43
|
+
export interface InputProps
|
|
44
|
+
extends Pick<
|
|
45
|
+
TextInputProps,
|
|
46
|
+
| 'textContentType'
|
|
47
|
+
| 'autoComplete'
|
|
48
|
+
| 'autoFocus'
|
|
49
|
+
| 'maxLength'
|
|
50
|
+
| 'onSubmitEditing'
|
|
51
|
+
| 'accessibilityLabel'
|
|
52
|
+
| 'multiline'
|
|
53
|
+
> {
|
|
42
54
|
value: string
|
|
43
55
|
onChangeText: (text: string) => void
|
|
44
56
|
placeholder?: string
|
|
@@ -57,15 +69,19 @@ export const Input: FC<InputProps> = ({
|
|
|
57
69
|
keyboardType,
|
|
58
70
|
autoCapitalize = 'sentences',
|
|
59
71
|
secureTextEntry,
|
|
60
|
-
editable = true,
|
|
72
|
+
editable: editableProp = true,
|
|
61
73
|
invalid = false,
|
|
62
74
|
onBlur,
|
|
75
|
+
...nativeProps
|
|
63
76
|
}) => {
|
|
77
|
+
const frozen = useFrozen()
|
|
78
|
+
const editable = editableProp && !frozen
|
|
64
79
|
const theme = useTheme()
|
|
65
80
|
const s = useFieldStyles(theme)
|
|
66
81
|
const [focused, setFocused] = useState(false)
|
|
67
82
|
return (
|
|
68
83
|
<TextInput
|
|
84
|
+
{...nativeProps}
|
|
69
85
|
style={[
|
|
70
86
|
s.input,
|
|
71
87
|
focused && s.inputFocused,
|
|
@@ -98,10 +114,12 @@ export const Checkbox: FC<{
|
|
|
98
114
|
}> = ({ checked, onChange, label, invalid = false }) => {
|
|
99
115
|
const theme = useTheme()
|
|
100
116
|
const s = useFieldStyles(theme)
|
|
117
|
+
const frozen = useFrozen()
|
|
101
118
|
return (
|
|
102
119
|
<Pressable
|
|
103
120
|
accessibilityRole="checkbox"
|
|
104
|
-
accessibilityState={{ checked }}
|
|
121
|
+
accessibilityState={{ checked, disabled: frozen }}
|
|
122
|
+
disabled={frozen}
|
|
105
123
|
onPress={() => onChange(!checked)}
|
|
106
124
|
style={s.checkboxRow}
|
|
107
125
|
>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FrozenFields` — the native form of a disabled `<fieldset>`: every Input,
|
|
3
|
+
* Checkbox, Select, QuantitySelect and Button inside it stops taking input
|
|
4
|
+
* while `frozen`. A section wraps its form in it while the order is placed
|
|
5
|
+
* or charged (`orderFixed`), when the engine ignores a commit.
|
|
6
|
+
*/
|
|
7
|
+
import React, { createContext, useContext, type FC, type ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
const FrozenContext = createContext(false)
|
|
10
|
+
|
|
11
|
+
/** True inside a frozen `FrozenFields`. */
|
|
12
|
+
export const useFrozen = (): boolean => useContext(FrozenContext)
|
|
13
|
+
|
|
14
|
+
export const FrozenFields: FC<{ frozen?: boolean; children: ReactNode }> = ({
|
|
15
|
+
frozen = false,
|
|
16
|
+
children,
|
|
17
|
+
}) => {
|
|
18
|
+
const outer = useFrozen()
|
|
19
|
+
return (
|
|
20
|
+
<FrozenContext.Provider value={outer || frozen}>{children}</FrozenContext.Provider>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -17,33 +17,30 @@
|
|
|
17
17
|
* provider hook-up. Left as a provider follow-up so this slice stays scoped to
|
|
18
18
|
* the payment section + this self-contained hook/modal.
|
|
19
19
|
*/
|
|
20
|
-
import React, {
|
|
20
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
21
21
|
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
22
22
|
|
|
23
|
-
import
|
|
23
|
+
import {
|
|
24
|
+
createInstallmentChoices,
|
|
25
|
+
type InstallmentOffer,
|
|
26
|
+
type PendingInstallmentChoice,
|
|
27
|
+
} from 'tf-checkout-shared'
|
|
24
28
|
|
|
25
29
|
import { useTheme } from './CheckoutProvider'
|
|
26
30
|
import { Button } from './primitives'
|
|
27
31
|
import type { ResolvedTheme } from './theme'
|
|
28
32
|
|
|
29
33
|
export const useInstallmentPicker = () => {
|
|
30
|
-
const [pending, setPending] = useState<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const [pending, setPending] = useState<PendingInstallmentChoice | null>(null)
|
|
35
|
+
// Created once, so the port is stable (useCheckout reads ports once). It
|
|
36
|
+
// resolves when the buyer picks a plan (or declines) below.
|
|
37
|
+
const [choices] = useState(() => createInstallmentChoices(setPending))
|
|
38
|
+
// A picker that unmounts while open ends the choice, so the payment stops
|
|
39
|
+
// instead of waiting for an answer that cannot come.
|
|
40
|
+
useEffect(() => () => choices.cancel(), [choices])
|
|
41
|
+
const onInstallmentOffers = choices.ask
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
// buyer picks a plan (or declines) below.
|
|
37
|
-
const onInstallmentOffers = useCallback(
|
|
38
|
-
(offers: InstallmentOffer[]) =>
|
|
39
|
-
new Promise<InstallmentOffer | null>((resolve) => setPending({ offers, resolve })),
|
|
40
|
-
[]
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
const choose = (offer: InstallmentOffer | null) => {
|
|
44
|
-
pending?.resolve(offer)
|
|
45
|
-
setPending(null)
|
|
46
|
-
}
|
|
43
|
+
const choose = (offer: InstallmentOffer | null) => pending?.choose(offer)
|
|
47
44
|
|
|
48
45
|
const picker = (
|
|
49
46
|
<InstallmentModal
|
|
@@ -11,6 +11,7 @@ import React, { useMemo, type FC, type ReactNode } from 'react'
|
|
|
11
11
|
import { Image, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
12
12
|
|
|
13
13
|
import { useTheme } from './CheckoutProvider'
|
|
14
|
+
import { useFrozen } from './Frozen'
|
|
14
15
|
import { Field, Input } from './Field'
|
|
15
16
|
import type { ResolvedTheme } from './theme'
|
|
16
17
|
|
|
@@ -58,6 +59,7 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
|
|
|
58
59
|
children,
|
|
59
60
|
}) => {
|
|
60
61
|
const theme = useTheme()
|
|
62
|
+
const frozen = useFrozen()
|
|
61
63
|
const s = useStyles(theme)
|
|
62
64
|
return (
|
|
63
65
|
<View style={s.list} accessibilityRole="radiogroup">
|
|
@@ -75,8 +77,9 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
|
|
|
75
77
|
<View key={option.value} style={[s.row, active && s.rowActive]}>
|
|
76
78
|
<Pressable
|
|
77
79
|
accessibilityRole="radio"
|
|
78
|
-
accessibilityState={{ selected: active }}
|
|
79
|
-
|
|
80
|
+
accessibilityState={{ selected: active, disabled: frozen }}
|
|
81
|
+
disabled={frozen}
|
|
82
|
+
onPress={() => { if (!frozen) onSelect(option.value) }}
|
|
80
83
|
style={s.rowHead}
|
|
81
84
|
>
|
|
82
85
|
<View style={[s.radio, active && s.radioActive]}>
|
|
@@ -104,7 +107,7 @@ export const PaymentMethods: FC<PaymentMethodsProps> = ({
|
|
|
104
107
|
<Input
|
|
105
108
|
value={extraValue ?? ''}
|
|
106
109
|
onChangeText={(text) =>
|
|
107
|
-
onExtraChange?.(
|
|
110
|
+
!frozen && onExtraChange?.(
|
|
108
111
|
option.value,
|
|
109
112
|
option.extra === 'phoneNumber'
|
|
110
113
|
? { phoneNumber: text }
|