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

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.5",
3
+ "version": "2.0.0-beta.6",
4
4
  "description": "TheTicketFairy ReactNative Checkout library",
5
5
  "homepage": "https://github.com/theticketfairy/tf-shared-libraries#readme",
6
6
  "author": "The Ticket Fairy",
@@ -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
+ })
@@ -59,6 +59,16 @@ const LoadedImage = ({
59
59
  return (
60
60
  <Image
61
61
  source={{ uri: image.data }}
62
+ accessible={full}
63
+ accessibilityLabel={
64
+ full
65
+ ? t('Image {{number}} of {{total}}', {
66
+ number: index + 1,
67
+ total: comment.images.length,
68
+ })
69
+ : undefined
70
+ }
71
+ accessibilityLiveRegion={full ? 'polite' : undefined}
62
72
  style={s.image}
63
73
  resizeMode={full ? 'contain' : 'cover'}
64
74
  />
@@ -72,12 +82,14 @@ export const CommentImages = ({
72
82
  previewKey,
73
83
  onPreviewChange,
74
84
  locale,
85
+ renderCloseIcon,
75
86
  }: {
76
87
  eventId: string
77
88
  comment: EventComment
78
89
  previewKey?: string
79
90
  onPreviewChange: (open: boolean) => void
80
91
  locale?: string
92
+ renderCloseIcon?: (color: string) => React.ReactNode
81
93
  }) => {
82
94
  const t = useAccountT(locale)
83
95
  const theme = useTheme()
@@ -140,19 +152,23 @@ export const CommentImages = ({
140
152
  >
141
153
  <View style={s.preview}>
142
154
  <View style={s.toolbar}>
143
- <Text accessibilityLiveRegion="polite" style={white}>
144
- {t('Image {{number}} of {{total}}', {
145
- number: position + 1,
146
- total: images.length,
147
- })}
148
- </Text>
149
155
  <Pressable
150
156
  accessibilityRole="button"
151
157
  accessibilityLabel={t('Close image')}
152
- style={s.navButton}
158
+ style={s.closeButton}
153
159
  onPress={() => setSelected(null)}
154
160
  >
155
- <Text style={white}>{t('Close')}</Text>
161
+ <View
162
+ pointerEvents="none"
163
+ accessible={false}
164
+ importantForAccessibility="no-hide-descendants"
165
+ >
166
+ {renderCloseIcon ? (
167
+ renderCloseIcon('#9F9CB0')
168
+ ) : (
169
+ <Text style={[white, s.closeGlyph]}>×</Text>
170
+ )}
171
+ </View>
156
172
  </Pressable>
157
173
  </View>
158
174
  <View style={s.full}>
@@ -306,11 +322,19 @@ const s = StyleSheet.create({
306
322
  preview: {
307
323
  flex: 1,
308
324
  backgroundColor: 'rgba(11,10,21,0.96)',
309
- paddingTop: 60,
325
+ paddingTop: 53,
310
326
  paddingBottom: 40,
311
327
  paddingHorizontal: 16,
312
328
  },
313
- toolbar: { flexDirection: 'row', justifyContent: 'space-between', padding: 12 },
329
+ toolbar: { flexDirection: 'row', justifyContent: 'flex-end' },
330
+ closeButton: {
331
+ width: 44,
332
+ height: 44,
333
+ marginRight: -10,
334
+ alignItems: 'center',
335
+ justifyContent: 'center',
336
+ },
337
+ closeGlyph: { fontSize: 28, lineHeight: 28, color: '#9F9CB0', textAlign: 'center' },
314
338
  full: { flex: 1, width: '100%' },
315
339
  white: { color: '#EBEBEE', fontSize: 16 },
316
340
  navButton: {
@@ -26,3 +26,9 @@ export * from './TicketGift'
26
26
 
27
27
  export { AccountRewardsSection } from './AccountRewards'
28
28
  export * from './PostReaction'
29
+ export {
30
+ Streaming,
31
+ StreamingView,
32
+ type StreamingProps,
33
+ type StreamingProvider,
34
+ } from './Streaming'