tf-checkout-react-native 2.0.0-beta.6 → 2.0.0-beta.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tf-checkout-react-native",
3
- "version": "2.0.0-beta.6",
3
+ "version": "2.0.0-beta.8",
4
4
  "description": "TheTicketFairy ReactNative Checkout library",
5
5
  "homepage": "https://github.com/theticketfairy/tf-shared-libraries#readme",
6
6
  "author": "The Ticket Fairy",
@@ -37,7 +37,7 @@
37
37
  "!**/.*"
38
38
  ],
39
39
  "scripts": {
40
- "test": "jest --passWithNoTests",
40
+ "test": "node scripts/verify-discussion-support.cjs && jest --passWithNoTests",
41
41
  "typecheck": "tsc -p tsconfig.build.json",
42
42
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
43
43
  "lint:fix": "eslint \"**/*.{js,ts,tsx}\" --fix",
@@ -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
+ })
@@ -0,0 +1,329 @@
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Linking,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ View,
11
+ type StyleProp,
12
+ type ViewStyle,
13
+ } from 'react-native'
14
+ import { hooks, type AccountDeletionFlow } from 'tf-checkout-shared'
15
+ import { useTheme } from '../ui/CheckoutProvider'
16
+ import { resolveTheme, type CheckoutTheme } from '../ui/theme'
17
+ import { useAccountT } from '../ui/useAccountT'
18
+
19
+ export interface AccountDeletionProps {
20
+ showHeader?: boolean
21
+ showExplanation?: boolean
22
+ onSignIn?: () => void
23
+ onDone?: () => void
24
+ onOpenTeam?: (url: string) => void | Promise<void>
25
+ locale?: string
26
+ translate?: (english: string, values?: Record<string, string | number>) => string
27
+ theme?: CheckoutTheme
28
+ style?: StyleProp<ViewStyle>
29
+ }
30
+
31
+ export const AccountDeletion = (props: AccountDeletionProps) => {
32
+ const deletion = hooks.useAccountDeletion()
33
+ return <AccountDeletionView {...props} deletion={deletion} />
34
+ }
35
+
36
+ /** Native presentation only: the common flow owns proof, retries and deletion. */
37
+ export const AccountDeletionView = ({
38
+ deletion: flow,
39
+ showHeader = true,
40
+ showExplanation = true,
41
+ onSignIn,
42
+ onDone,
43
+ onOpenTeam,
44
+ locale,
45
+ translate,
46
+ theme: override,
47
+ style,
48
+ }: AccountDeletionProps & { deletion: AccountDeletionFlow }) => {
49
+ const defaultT = useAccountT(locale)
50
+ const t = translate ?? defaultT
51
+ const inherited = useTheme()
52
+ const theme = useMemo(
53
+ () => (override ? resolveTheme({ ...inherited, ...override }) : inherited),
54
+ [inherited, override]
55
+ )
56
+ const assertOwner = hooks.useSessionAction()
57
+ const text = { color: theme.text, fontFamily: theme.fontFamily }
58
+ const muted = { color: theme.muted, fontFamily: theme.fontFamily }
59
+ const action = (
60
+ label: string,
61
+ execute: () => void,
62
+ danger = false,
63
+ disabled = flow.busy
64
+ ) => (
65
+ <Pressable
66
+ accessibilityRole="button"
67
+ accessibilityState={{ disabled, busy: flow.busy }}
68
+ disabled={disabled}
69
+ onPress={() => {
70
+ try {
71
+ assertOwner()
72
+ execute()
73
+ } catch {
74
+ /* A retained control belongs to its original account. */
75
+ }
76
+ }}
77
+ style={[
78
+ s.button,
79
+ { borderColor: danger ? theme.error : theme.accent },
80
+ disabled && s.disabled,
81
+ ]}
82
+ >
83
+ <Text style={[text, { color: danger ? theme.error : theme.accent }]}>
84
+ {t(label)}
85
+ </Text>
86
+ </Pressable>
87
+ )
88
+ const openTeam = async (url: string) => {
89
+ try {
90
+ assertOwner()
91
+ await (onOpenTeam ? onOpenTeam(url) : Linking.openURL(url))
92
+ } catch {
93
+ try {
94
+ assertOwner()
95
+ Alert.alert(
96
+ t('Team settings'),
97
+ t('Unable to open team settings. Please try again.')
98
+ )
99
+ } catch {
100
+ /* A retired link cannot display feedback for another account. */
101
+ }
102
+ }
103
+ }
104
+ if (flow.deleted)
105
+ return (
106
+ <View style={[s.container, style]}>
107
+ <Text style={[s.title, text]}>{t('Account deleted')}</Text>
108
+ <Text accessibilityLiveRegion="polite" style={[s.body, text]}>
109
+ {t('Your Ticket Fairy account has been closed. You are signed out.')}
110
+ </Text>
111
+ <Text style={[s.body, muted]}>
112
+ {t('Financial and transaction records are retained where required.')}
113
+ </Text>
114
+ {onDone && action('Continue browsing', onDone, false, false)}
115
+ </View>
116
+ )
117
+ if (flow.confirmingAccount)
118
+ return (
119
+ <ActivityIndicator
120
+ accessibilityLabel={t('Loading account deletion options…')}
121
+ color={theme.accent}
122
+ />
123
+ )
124
+ if (!flow.signedIn)
125
+ return (
126
+ <View style={[s.container, style]}>
127
+ {flow.error && (
128
+ <>
129
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
130
+ {t(flow.error)}
131
+ </Text>
132
+ {action('Try again', () => void flow.retry())}
133
+ </>
134
+ )}
135
+ <Text style={[s.body, text]}>
136
+ {t('Sign in to review and delete your Ticket Fairy account.')}
137
+ </Text>
138
+ {onSignIn && action('Sign in', onSignIn, false, false)}
139
+ </View>
140
+ )
141
+ if (flow.query.isPending)
142
+ return (
143
+ <ActivityIndicator
144
+ accessibilityLabel={t('Loading account deletion options…')}
145
+ color={theme.accent}
146
+ />
147
+ )
148
+ if (!flow.status)
149
+ return (
150
+ <View style={[s.container, style]}>
151
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
152
+ {t(flow.error || 'Account deletion options could not load.')}
153
+ </Text>
154
+ {action('Try again', () => void flow.retry(), false, false)}
155
+ </View>
156
+ )
157
+ const status = flow.status
158
+ const password = status.verification === 'password'
159
+ return (
160
+ <View style={[s.container, style]}>
161
+ {showHeader && <Text style={[s.title, text]}>{t('Delete account')}</Text>}
162
+ {showExplanation && (
163
+ <>
164
+ <Text style={[s.body, text]}>
165
+ {t(
166
+ 'This closes your Ticket Fairy account across the app, website and organiser dashboard.'
167
+ )}
168
+ </Text>
169
+ <Text style={[s.heading, text]}>{t('What is removed')}</Text>
170
+ <Text style={[s.body, muted]}>
171
+ {t(
172
+ 'Your personal profile, sign-in access, friends, saved preferences and the content you posted are removed or anonymised.'
173
+ )}
174
+ </Text>
175
+ <Text style={[s.heading, text]}>{t('What is kept')}</Text>
176
+ <Text style={[s.body, muted]}>
177
+ {t(
178
+ 'Financial and transaction records are retained where required. Deleting your account does not refund or cancel orders, and does not delete other ticket holders’ details.'
179
+ )}
180
+ </Text>
181
+ </>
182
+ )}
183
+ {status.appleLinked && (
184
+ <Text style={[s.body, muted]}>
185
+ {t(
186
+ 'If Ticket Fairy still appears in your Apple Account after deletion, remove its access there too.'
187
+ )}
188
+ </Text>
189
+ )}
190
+ {status.blockers.length > 0 && (
191
+ <View style={[s.panel, { borderColor: theme.border }]}>
192
+ <Text style={[s.heading, text]}>{t('Before you delete your account')}</Text>
193
+ {status.blockers.map((blocker) => (
194
+ <View key={blocker.code} style={s.container}>
195
+ <Text style={[s.body, text]}>{t(blocker.message)}</Text>
196
+ {blocker.brands?.map((brand) =>
197
+ brand.teamUrl ? (
198
+ <Pressable
199
+ key={brand.id}
200
+ accessibilityRole="link"
201
+ disabled={flow.busy}
202
+ onPress={() => {
203
+ if (brand.teamUrl) void openTeam(brand.teamUrl)
204
+ }}
205
+ style={s.link}
206
+ >
207
+ <Text style={{ color: theme.accent, fontFamily: theme.fontFamily }}>
208
+ {t('Open {{name}} team settings', { name: brand.name })}
209
+ </Text>
210
+ </Pressable>
211
+ ) : null
212
+ )}
213
+ </View>
214
+ ))}
215
+ {action('Check again', () => void flow.retry())}
216
+ </View>
217
+ )}
218
+ {!flow.isOpen && status.canDelete && action('Delete account', flow.open, true)}
219
+ {flow.isOpen && (
220
+ <View
221
+ accessibilityLabel={t('Confirm account deletion')}
222
+ style={[s.panel, { borderColor: theme.border }]}
223
+ >
224
+ <Text style={[s.heading, text]}>{t('Delete your account permanently?')}</Text>
225
+ <Text style={[s.body, text]}>
226
+ {t('This cannot be undone. Confirm that you want to close this account.')}
227
+ </Text>
228
+ {!password && (
229
+ <>
230
+ <Text style={[s.body, muted]}>
231
+ {t('We will email a deletion code to {{email}}.', {
232
+ email: status.email,
233
+ })}
234
+ </Text>
235
+ {action(
236
+ flow.codeSent ? 'Send a new code' : 'Email deletion code',
237
+ () => void flow.sendCode(locale),
238
+ false,
239
+ flow.busy || flow.retryAfter > 0
240
+ )}
241
+ {flow.codeSent && (
242
+ <Text accessibilityLiveRegion="polite" style={[s.body, muted]}>
243
+ {t('A code was sent to {{email}}. It expires in {{minutes}} minutes.', {
244
+ email: flow.codeSent.email,
245
+ minutes: flow.codeSent.expiresInMinutes,
246
+ })}
247
+ </Text>
248
+ )}
249
+ </>
250
+ )}
251
+ {flow.retryAfter > 0 && (
252
+ <Text accessibilityLiveRegion="polite" style={[s.body, muted]}>
253
+ {t('Try again in {{seconds}} seconds.', { seconds: flow.retryAfter })}
254
+ </Text>
255
+ )}
256
+ <Text style={[s.heading, text]}>
257
+ {t(password ? 'Current password' : 'Deletion code')}
258
+ </Text>
259
+ <TextInput
260
+ accessibilityLabel={t(password ? 'Current password' : 'Deletion code')}
261
+ value={flow.proof}
262
+ onChangeText={flow.setProof}
263
+ secureTextEntry={password}
264
+ textContentType={password ? 'password' : 'oneTimeCode'}
265
+ autoComplete={password ? 'current-password' : 'one-time-code'}
266
+ keyboardType={password ? 'default' : 'number-pad'}
267
+ maxLength={password ? undefined : 6}
268
+ autoCapitalize="none"
269
+ autoCorrect={false}
270
+ editable={!flow.busy}
271
+ style={[
272
+ s.input,
273
+ text,
274
+ { borderColor: theme.border, backgroundColor: theme.background },
275
+ ]}
276
+ />
277
+ {flow.error && (
278
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
279
+ {t(flow.error)}
280
+ </Text>
281
+ )}
282
+ {flow.busy && (
283
+ <ActivityIndicator
284
+ color={theme.accent}
285
+ accessibilityLabel={t('Please wait…')}
286
+ />
287
+ )}
288
+ {action('Keep account', flow.cancel)}
289
+ {action(
290
+ 'Permanently delete account',
291
+ () => void flow.confirm(),
292
+ true,
293
+ !flow.canConfirm
294
+ )}
295
+ </View>
296
+ )}
297
+ {!flow.isOpen && flow.error && (
298
+ <Text accessibilityRole="alert" style={[s.body, { color: theme.error }]}>
299
+ {t(flow.error)}
300
+ </Text>
301
+ )}
302
+ </View>
303
+ )
304
+ }
305
+
306
+ const s = StyleSheet.create({
307
+ container: { gap: 12 },
308
+ title: { fontSize: 22, fontWeight: '600', lineHeight: 28 },
309
+ heading: { fontSize: 16, fontWeight: '600', lineHeight: 23 },
310
+ body: { fontSize: 15, lineHeight: 22 },
311
+ panel: {
312
+ borderWidth: StyleSheet.hairlineWidth,
313
+ borderRadius: 16,
314
+ padding: 16,
315
+ gap: 12,
316
+ },
317
+ button: {
318
+ minHeight: 48,
319
+ borderWidth: 1,
320
+ borderRadius: 12,
321
+ paddingVertical: 12,
322
+ paddingHorizontal: 16,
323
+ alignItems: 'center',
324
+ justifyContent: 'center',
325
+ },
326
+ input: { minHeight: 48, borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
327
+ link: { minHeight: 44, justifyContent: 'center' },
328
+ disabled: { opacity: 0.5 },
329
+ })
@@ -423,7 +423,7 @@ const EventDiscussionBody = ({
423
423
  accessibilityRole="link"
424
424
  style={s.support}
425
425
  onPress={() => {
426
- if (/^https:\/\//.test(d.supportUrl))
426
+ if (/^(https:\/\/|mailto:)/i.test(d.supportUrl))
427
427
  void Linking.openURL(d.supportUrl).catch(() => undefined)
428
428
  }}
429
429
  >
@@ -32,3 +32,9 @@ export {
32
32
  type StreamingProps,
33
33
  type StreamingProvider,
34
34
  } from './Streaming'
35
+
36
+ export {
37
+ AccountDeletion,
38
+ AccountDeletionView,
39
+ type AccountDeletionProps,
40
+ } from './AccountDeletion'