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.
Files changed (71) hide show
  1. package/README.md +115 -24
  2. package/dist/index.d.mts +8195 -282
  3. package/dist/index.d.ts +8195 -282
  4. package/dist/index.js +32346 -7001
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32586 -7128
  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
@@ -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 token/session store.
2
+ * React Native storage adapter for the SDK's persisted session credentials.
3
3
  *
4
- * The shared transport reads/writes tokens through a SYNCHRONOUS `StorageAdapter`
5
- * (see tf-checkout-shared `client/storage.ts`). Web hands it a `localStorage`
6
- * adapter; the native side needs a synchronous, persisted store so the buyer's
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
- * Unlike web, native has no session cookie: the bearer token IS the session, so
11
- * `configureCheckout` defaults to this persisted adapter (never in-memory) so a
12
- * signed-in buyer stays signed in between launches.
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 type { UseCheckout } from 'tf-checkout-shared'
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
- <ValidationCtx.Provider value={validation}>{children}</ValidationCtx.Provider>
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 checkout = useCheckout({ ports: { onInstallmentOffers } })
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
- // `value` present ⇒ don't call `useCheckout()` (no second binding); absent ⇒
111
- // the self-providing branch owns it. The choice is static per mount.
112
- <QueryClientProvider client={queryClient ?? getQueryClient()}>
113
- {value ? (
114
- <CheckoutRoot checkout={value} {...rest} />
115
- ) : (
116
- <SelfProvidedCheckout {...rest} />
117
- )}
118
- </QueryClientProvider>
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 = () => {
@@ -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
- // File capture needs an upload rail the SDK doesn't own yet — surface it
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 (including its "image uploads are server-authoritative" carve-out). */
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, { useCallback, useMemo, useState } from 'react'
20
+ import React, { useEffect, useMemo, useState } from 'react'
21
21
  import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
22
22
 
23
- import type { InstallmentOffer } from 'tf-checkout-shared'
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
- offers: InstallmentOffer[]
32
- resolve: (offer: InstallmentOffer | null) => void
33
- } | null>(null)
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
- // Stable across renders (useCheckout reads ports once) — resolves when the
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
- onPress={() => onSelect(option.value)}
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 }