tf-checkout-react-native 2.0.0-beta.5 → 2.0.0-beta.7
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/dist/index.d.mts +124 -4
- package/dist/index.d.ts +124 -4
- package/dist/index.js +1702 -749
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1657 -688
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/sections/AccountDeletion.tsx +329 -0
- package/src/sections/EventDiscussionView.tsx +4 -0
- package/src/sections/Streaming.tsx +278 -0
- package/src/sections/discussion/CommentImages.tsx +34 -10
- package/src/sections/index.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tf-checkout-react-native",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.7",
|
|
4
4
|
"description": "TheTicketFairy ReactNative Checkout library",
|
|
5
5
|
"homepage": "https://github.com/theticketfairy/tf-shared-libraries#readme",
|
|
6
6
|
"author": "The Ticket Fairy",
|
|
@@ -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
|
+
})
|
|
@@ -35,6 +35,8 @@ export interface EventDiscussionViewProps {
|
|
|
35
35
|
previewKey?: string
|
|
36
36
|
locale?: string
|
|
37
37
|
renderReplyIcon?: (color: string) => React.ReactNode
|
|
38
|
+
/** Optional host artwork; the SDK owns the accessible close button and behavior. */
|
|
39
|
+
renderImageCloseIcon?: (color: string) => React.ReactNode
|
|
38
40
|
onModerationFilter?: (filter: 'pending' | 'reported' | undefined) => void
|
|
39
41
|
renderReactionIcon?: (
|
|
40
42
|
reaction: 'up' | 'down',
|
|
@@ -54,6 +56,7 @@ const EventDiscussionBody = ({
|
|
|
54
56
|
previewKey,
|
|
55
57
|
locale,
|
|
56
58
|
renderReplyIcon,
|
|
59
|
+
renderImageCloseIcon,
|
|
57
60
|
onModerationFilter,
|
|
58
61
|
renderReactionIcon,
|
|
59
62
|
}: EventDiscussionViewProps) => {
|
|
@@ -171,6 +174,7 @@ const EventDiscussionBody = ({
|
|
|
171
174
|
previewKey={previewKey}
|
|
172
175
|
locale={locale}
|
|
173
176
|
onPreviewChange={setImageOpen}
|
|
177
|
+
renderCloseIcon={renderImageCloseIcon}
|
|
174
178
|
/>
|
|
175
179
|
<Text selectable style={[text, s.body]}>
|
|
176
180
|
{comment.status === 'deleted' ? 'Comment removed' : comment.body}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import React, { useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Alert,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
type StyleProp,
|
|
10
|
+
type ViewStyle,
|
|
11
|
+
} from 'react-native'
|
|
12
|
+
import { hooks, type AccountPreferences } from 'tf-checkout-shared'
|
|
13
|
+
import { useTheme } from '../ui/CheckoutProvider'
|
|
14
|
+
import { resolveTheme, type CheckoutTheme } from '../ui/theme'
|
|
15
|
+
import { useAccountT } from '../ui/useAccountT'
|
|
16
|
+
|
|
17
|
+
type Flow = ReturnType<typeof hooks.useAccountPreferences>
|
|
18
|
+
export type StreamingProvider = keyof AccountPreferences['music']
|
|
19
|
+
type SupportedProvider = Exclude<StreamingProvider, 'youtubeMusic'>
|
|
20
|
+
export interface StreamingProps {
|
|
21
|
+
/** Open the first-party review browser, resolving when the person returns. */
|
|
22
|
+
onOpenMusic: (url: string, provider: SupportedProvider) => void | Promise<void>
|
|
23
|
+
onSignIn?: () => void
|
|
24
|
+
locale?: string
|
|
25
|
+
translate?: (english: string, vars?: Record<string, string | number>) => string
|
|
26
|
+
renderProviderIcon?: (provider: StreamingProvider) => ReactNode
|
|
27
|
+
showHeader?: boolean
|
|
28
|
+
theme?: CheckoutTheme
|
|
29
|
+
style?: StyleProp<ViewStyle>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Standalone native presentation; account rules and state stay in the common hook. */
|
|
33
|
+
export const Streaming = (props: StreamingProps) => {
|
|
34
|
+
const preferences = hooks.useAccountPreferences()
|
|
35
|
+
return <StreamingView {...props} preferences={preferences} />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compose an existing shared flow, for example to refetch on native navigation focus. */
|
|
39
|
+
export const StreamingView = ({
|
|
40
|
+
preferences: flow,
|
|
41
|
+
onOpenMusic,
|
|
42
|
+
onSignIn,
|
|
43
|
+
locale,
|
|
44
|
+
translate,
|
|
45
|
+
renderProviderIcon,
|
|
46
|
+
showHeader = true,
|
|
47
|
+
theme: override,
|
|
48
|
+
style,
|
|
49
|
+
}: StreamingProps & { preferences: Flow }) => {
|
|
50
|
+
const defaultT = useAccountT(locale)
|
|
51
|
+
const t = translate ?? defaultT
|
|
52
|
+
const inherited = useTheme()
|
|
53
|
+
const theme = useMemo(
|
|
54
|
+
() => (override ? resolveTheme({ ...inherited, ...override }) : inherited),
|
|
55
|
+
[inherited, override]
|
|
56
|
+
)
|
|
57
|
+
const session = hooks.useAuthSession()
|
|
58
|
+
const assertOwner = hooks.useSessionAction()
|
|
59
|
+
const pendingBrowser = useRef({ scope: session.sessionScope, locked: false })
|
|
60
|
+
const [stored, setStored] = useState({
|
|
61
|
+
scope: session.sessionScope,
|
|
62
|
+
busy: false,
|
|
63
|
+
error: '',
|
|
64
|
+
})
|
|
65
|
+
const local =
|
|
66
|
+
stored.scope === session.sessionScope
|
|
67
|
+
? stored
|
|
68
|
+
: { scope: session.sessionScope, busy: false, error: '' }
|
|
69
|
+
const busy = flow.busy || local.busy
|
|
70
|
+
const text = { color: theme.text, fontFamily: theme.fontFamily }
|
|
71
|
+
const muted = { color: theme.muted, fontFamily: theme.fontFamily }
|
|
72
|
+
const connect = async (provider: SupportedProvider) => {
|
|
73
|
+
try {
|
|
74
|
+
assertOwner()
|
|
75
|
+
} catch {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
pendingBrowser.current.scope === session.sessionScope &&
|
|
80
|
+
pendingBrowser.current.locked
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
pendingBrowser.current = { scope: session.sessionScope, locked: true }
|
|
84
|
+
setStored({ scope: session.sessionScope, busy: true, error: '' })
|
|
85
|
+
try {
|
|
86
|
+
const link = await (provider === 'spotify'
|
|
87
|
+
? flow.connectSpotify()
|
|
88
|
+
: flow.connectAppleMusic())
|
|
89
|
+
assertOwner()
|
|
90
|
+
if (!link) return
|
|
91
|
+
await onOpenMusic(link.url, provider)
|
|
92
|
+
assertOwner()
|
|
93
|
+
await flow.query.refetch()
|
|
94
|
+
assertOwner()
|
|
95
|
+
} catch {
|
|
96
|
+
try {
|
|
97
|
+
assertOwner()
|
|
98
|
+
setStored({
|
|
99
|
+
scope: session.sessionScope,
|
|
100
|
+
busy: false,
|
|
101
|
+
error: 'Unable to open the connection page. Please try again.',
|
|
102
|
+
})
|
|
103
|
+
} catch {
|
|
104
|
+
/* A retired browser callback cannot publish into another buyer. */
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
try {
|
|
108
|
+
assertOwner()
|
|
109
|
+
if (pendingBrowser.current.scope === session.sessionScope)
|
|
110
|
+
pendingBrowser.current.locked = false
|
|
111
|
+
setStored((state) => ({ ...state, scope: session.sessionScope, busy: false }))
|
|
112
|
+
} catch {
|
|
113
|
+
/* The new owner keeps its own presentation state. */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const disconnect = (provider: SupportedProvider) => {
|
|
118
|
+
try {
|
|
119
|
+
assertOwner()
|
|
120
|
+
} catch {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
Alert.alert(
|
|
124
|
+
t(provider === 'spotify' ? 'Disconnect Spotify?' : 'Disconnect Apple Music?'),
|
|
125
|
+
t(
|
|
126
|
+
provider === 'spotify'
|
|
127
|
+
? 'Your imported Spotify data and approved names will be removed. Your saved interests stay.'
|
|
128
|
+
: 'Your imported artists and genres will be removed. Your saved interests will stay.'
|
|
129
|
+
),
|
|
130
|
+
[
|
|
131
|
+
{ text: t('Cancel'), style: 'cancel' },
|
|
132
|
+
{
|
|
133
|
+
text: t('Disconnect'),
|
|
134
|
+
style: 'destructive',
|
|
135
|
+
onPress: () => {
|
|
136
|
+
try {
|
|
137
|
+
assertOwner()
|
|
138
|
+
void (provider === 'spotify'
|
|
139
|
+
? flow.disconnectSpotify()
|
|
140
|
+
: flow.disconnectAppleMusic())
|
|
141
|
+
} catch {
|
|
142
|
+
/* Confirmation belongs to the buyer who opened it. */
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
const action = (
|
|
150
|
+
label: string,
|
|
151
|
+
provider: string,
|
|
152
|
+
onPress: () => void,
|
|
153
|
+
disabled = busy
|
|
154
|
+
) => (
|
|
155
|
+
<Pressable
|
|
156
|
+
accessibilityRole="button"
|
|
157
|
+
accessibilityLabel={
|
|
158
|
+
provider ? t('{{action}} {{provider}}', { action: t(label), provider }) : t(label)
|
|
159
|
+
}
|
|
160
|
+
accessibilityState={{ disabled, busy }}
|
|
161
|
+
disabled={disabled}
|
|
162
|
+
onPress={onPress}
|
|
163
|
+
style={[s.button, { borderColor: theme.accent }, disabled && s.disabled]}
|
|
164
|
+
>
|
|
165
|
+
<Text style={[text, { color: theme.accent, fontSize: theme.fontSize }]}>
|
|
166
|
+
{t(label)}
|
|
167
|
+
</Text>
|
|
168
|
+
</Pressable>
|
|
169
|
+
)
|
|
170
|
+
const music = flow.query.data?.music
|
|
171
|
+
return (
|
|
172
|
+
<View style={[s.container, style]}>
|
|
173
|
+
{showHeader && <Text style={[s.title, text]}>{t('Streaming')}</Text>}
|
|
174
|
+
<Text style={[s.intro, text]}>
|
|
175
|
+
{t('Choose which artist and genre names to use for event suggestions.')}
|
|
176
|
+
</Text>
|
|
177
|
+
{flow.query.isPending && (
|
|
178
|
+
<ActivityIndicator
|
|
179
|
+
accessibilityLabel={t('Loading streaming connections…')}
|
|
180
|
+
color={theme.accent}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
{music &&
|
|
184
|
+
(
|
|
185
|
+
[
|
|
186
|
+
{ key: 'spotify', name: 'Spotify', connection: music.spotify },
|
|
187
|
+
{ key: 'appleMusic', name: 'Apple Music', connection: music.appleMusic },
|
|
188
|
+
{
|
|
189
|
+
key: 'youtubeMusic',
|
|
190
|
+
name: 'YouTube Music',
|
|
191
|
+
connection: music.youtubeMusic,
|
|
192
|
+
},
|
|
193
|
+
] as const
|
|
194
|
+
).map(({ key, name, connection }) => (
|
|
195
|
+
<View key={key} style={[s.provider, { borderBottomColor: theme.border }]}>
|
|
196
|
+
<View style={s.identity}>
|
|
197
|
+
{renderProviderIcon?.(key)}
|
|
198
|
+
<View style={s.names}>
|
|
199
|
+
<Text style={[s.name, text]}>{name}</Text>
|
|
200
|
+
<Text style={[s.status, muted]}>
|
|
201
|
+
{connection.connected
|
|
202
|
+
? t('Connected')
|
|
203
|
+
: connection.available
|
|
204
|
+
? t('Not connected')
|
|
205
|
+
: t(connection.reason || 'Not available yet')}
|
|
206
|
+
</Text>
|
|
207
|
+
</View>
|
|
208
|
+
</View>
|
|
209
|
+
{key !== 'youtubeMusic' && (
|
|
210
|
+
<View style={s.actions}>
|
|
211
|
+
{connection.available &&
|
|
212
|
+
action(
|
|
213
|
+
connection.connected ? 'Review choices' : 'Connect',
|
|
214
|
+
name,
|
|
215
|
+
() => void connect(key)
|
|
216
|
+
)}
|
|
217
|
+
{connection.connected &&
|
|
218
|
+
action('Disconnect', name, () => disconnect(key))}
|
|
219
|
+
</View>
|
|
220
|
+
)}
|
|
221
|
+
{connection.connected && (
|
|
222
|
+
<Text style={[s.approved, muted]}>
|
|
223
|
+
{connection.artists.length || connection.genres.length
|
|
224
|
+
? [...connection.artists, ...connection.genres].join(', ')
|
|
225
|
+
: t('No artist or genre names approved yet.')}
|
|
226
|
+
</Text>
|
|
227
|
+
)}
|
|
228
|
+
</View>
|
|
229
|
+
))}
|
|
230
|
+
{(local.error || flow.error || flow.query.error) && (
|
|
231
|
+
<Text accessibilityRole="alert" style={[s.note, { color: theme.error }]}>
|
|
232
|
+
{t(local.error || flow.error || flow.query.error?.message || '')}
|
|
233
|
+
</Text>
|
|
234
|
+
)}
|
|
235
|
+
{flow.message && (
|
|
236
|
+
<Text accessibilityLiveRegion="polite" style={[s.note, muted]}>
|
|
237
|
+
{t(flow.message)}
|
|
238
|
+
</Text>
|
|
239
|
+
)}
|
|
240
|
+
{action('Refresh connections', '', () => void flow.query.refetch())}
|
|
241
|
+
{!music && onSignIn && action('Sign in', '', onSignIn)}
|
|
242
|
+
<Text style={[s.note, muted]}>
|
|
243
|
+
{t(
|
|
244
|
+
'Only the artist and genre names you approve are used for these event suggestions.'
|
|
245
|
+
)}
|
|
246
|
+
</Text>
|
|
247
|
+
</View>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
const s = StyleSheet.create({
|
|
251
|
+
container: { width: '100%' },
|
|
252
|
+
title: { fontSize: 20, fontWeight: '500', marginBottom: 16 },
|
|
253
|
+
intro: { fontSize: 20, lineHeight: 28, marginVertical: 24 },
|
|
254
|
+
provider: { borderBottomWidth: StyleSheet.hairlineWidth, paddingVertical: 20, gap: 16 },
|
|
255
|
+
identity: { flexDirection: 'row', alignItems: 'center', gap: 16 },
|
|
256
|
+
names: { flex: 1 },
|
|
257
|
+
name: { fontSize: 16, lineHeight: 24 },
|
|
258
|
+
status: { fontSize: 14, lineHeight: 20, marginTop: 4 },
|
|
259
|
+
actions: {
|
|
260
|
+
flexDirection: 'row',
|
|
261
|
+
flexWrap: 'wrap',
|
|
262
|
+
justifyContent: 'flex-end',
|
|
263
|
+
gap: 10,
|
|
264
|
+
},
|
|
265
|
+
button: {
|
|
266
|
+
alignSelf: 'flex-end',
|
|
267
|
+
borderWidth: 1,
|
|
268
|
+
borderRadius: 24,
|
|
269
|
+
paddingVertical: 10,
|
|
270
|
+
paddingHorizontal: 20,
|
|
271
|
+
minHeight: 44,
|
|
272
|
+
justifyContent: 'center',
|
|
273
|
+
marginTop: 4,
|
|
274
|
+
},
|
|
275
|
+
disabled: { opacity: 0.5 },
|
|
276
|
+
approved: { fontSize: 14, lineHeight: 20 },
|
|
277
|
+
note: { fontSize: 14, lineHeight: 20, marginTop: 24 },
|
|
278
|
+
})
|