be-components 7.4.3 → 7.4.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.
Files changed (45) hide show
  1. package/lib/commonjs/ApiOverrides/index.js +0 -2
  2. package/lib/commonjs/ApiOverrides/index.js.map +1 -1
  3. package/lib/commonjs/NotificationManager/api/index.js +448 -0
  4. package/lib/commonjs/NotificationManager/api/index.js.map +1 -0
  5. package/lib/commonjs/NotificationManager/index.js +1177 -0
  6. package/lib/commonjs/NotificationManager/index.js.map +1 -0
  7. package/lib/commonjs/index.js +20 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/types.d.js.map +1 -1
  10. package/lib/module/ApiOverrides/index.js +0 -2
  11. package/lib/module/ApiOverrides/index.js.map +1 -1
  12. package/lib/module/NotificationManager/api/index.js +441 -0
  13. package/lib/module/NotificationManager/api/index.js.map +1 -0
  14. package/lib/module/NotificationManager/index.js +1158 -0
  15. package/lib/module/NotificationManager/index.js.map +1 -0
  16. package/lib/module/index.js +3 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/types.d.js.map +1 -1
  19. package/lib/typescript/lib/commonjs/ApiOverrides/index.d.ts.map +1 -1
  20. package/lib/typescript/lib/commonjs/NotificationManager/api/index.d.ts +804 -0
  21. package/lib/typescript/lib/commonjs/NotificationManager/api/index.d.ts.map +1 -0
  22. package/lib/typescript/lib/commonjs/NotificationManager/index.d.ts +818 -0
  23. package/lib/typescript/lib/commonjs/NotificationManager/index.d.ts.map +1 -0
  24. package/lib/typescript/lib/commonjs/index.d.ts +803 -0
  25. package/lib/typescript/lib/commonjs/index.d.ts.map +1 -1
  26. package/lib/typescript/lib/module/ApiOverrides/index.d.ts.map +1 -1
  27. package/lib/typescript/lib/module/NotificationManager/api/index.d.ts +803 -0
  28. package/lib/typescript/lib/module/NotificationManager/api/index.d.ts.map +1 -0
  29. package/lib/typescript/lib/module/NotificationManager/index.d.ts +19 -0
  30. package/lib/typescript/lib/module/NotificationManager/index.d.ts.map +1 -0
  31. package/lib/typescript/lib/module/index.d.ts +4 -1
  32. package/lib/typescript/lib/module/index.d.ts.map +1 -1
  33. package/lib/typescript/src/ApiOverrides/index.d.ts.map +1 -1
  34. package/lib/typescript/src/NotificationManager/api/index.d.ts +822 -0
  35. package/lib/typescript/src/NotificationManager/api/index.d.ts.map +1 -0
  36. package/lib/typescript/src/NotificationManager/index.d.ts +21 -0
  37. package/lib/typescript/src/NotificationManager/index.d.ts.map +1 -0
  38. package/lib/typescript/src/index.d.ts +3 -1
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/ApiOverrides/index.ts +0 -2
  42. package/src/NotificationManager/api/index.ts +134 -0
  43. package/src/NotificationManager/index.tsx +917 -0
  44. package/src/index.tsx +6 -1
  45. package/src/types.d.ts +70 -0
@@ -0,0 +1,917 @@
1
+ import React, { useEffect, useState, useMemo } from 'react';
2
+ import { FlatList, TouchableOpacity, ActivityIndicator, type ViewStyle } from 'react-native';
3
+ import { View, Text, Button, TextInput } from '../Components/Themed';
4
+ import { useColors } from '../constants/useColors';
5
+ import { NotificationApi, NotificationHelpers } from './api';
6
+ import type { PlayerNotificationProps, MyPlayerProps, NotificationGroupProps, FocusPositionProps } from '../types';
7
+ import { Icons } from '../Components';
8
+ import DropDown from '../Components/Dropdown';
9
+ import { showConfirmAlert } from '../Components/ConfirmAlert';
10
+
11
+ type NotificationManagerProps = {
12
+ player_ids: string[];
13
+ me: MyPlayerProps;
14
+ header_style?:ViewStyle,
15
+ footer_style?:ViewStyle,
16
+ float?:boolean,
17
+ notification_type?: 'order_notifications' | 'competition_notifications' | 'social_notifications';
18
+ default_path_name?: string;
19
+ onFocusPosition?:(pos:FocusPositionProps) => void,
20
+ default_params?: Record<string, string>;
21
+ onComplete?: (notification: PlayerNotificationProps) => void;
22
+ onClose?: () => void;
23
+ };
24
+
25
+ const notification_types = [
26
+ { label: 'Order Notifications', value: 'order_notifications' },
27
+ { label: 'Competition Notifications', value: 'competition_notifications' },
28
+ { label: 'Social Notifications', value: 'social_notifications' }
29
+ ];
30
+
31
+ const sections = ['header','top_row', 'preview', 'second_row']
32
+
33
+ const NotificationManager = ({
34
+ player_ids,
35
+ me,
36
+ float,
37
+ header_style,
38
+ footer_style,
39
+ notification_type,
40
+ default_path_name,
41
+ onFocusPosition,
42
+ default_params,
43
+ onComplete,
44
+ onClose
45
+ }: NotificationManagerProps) => {
46
+ const Colors = useColors();
47
+ const [loading, setLoading] = useState(false);
48
+ const [loadingGroups, setLoadingGroups] = useState(false);
49
+ const [notificationGroups, setNotificationGroups] = useState<NotificationGroupProps[]>([]);
50
+ const [selectedGroup, setSelectedGroup] = useState<NotificationGroupProps | null>(null);
51
+ const [showGroupsModal, setShowGroupsModal] = useState(false);
52
+ const [groupMemberIds, setGroupMemberIds] = useState<string[]>([]);
53
+ const [showEditCustomGroupModal, setShowEditCustomGroupModal] = useState(false);
54
+ const [customGroupCsv, setCustomGroupCsv] = useState('');
55
+ const [sendingProgress, setSendingProgress] = useState<{ current: number; total: number } | null>(null);
56
+
57
+ // Create a custom group (always available, even if player_ids is empty)
58
+ const customGroup: NotificationGroupProps = {
59
+ notification_group_id: 'custom_group',
60
+ name: 'Custom Recipients',
61
+ description: player_ids.length > 0 ? `${player_ids.length} selected recipients` : 'Create custom recipient list',
62
+ status: 'active',
63
+ grouping_function:'',
64
+ player_count: player_ids.length,
65
+ create_datetime: new Date().toISOString(),
66
+ last_update_datetime: new Date().toISOString()
67
+ };
68
+
69
+ // Initialize selectedPath based on default_path_name if provided
70
+ const initialPath = default_path_name
71
+ ? NotificationHelpers.app_paths.find(p => p.path_name === default_path_name) || NotificationHelpers.app_paths[0]
72
+ : NotificationHelpers.app_paths[0];
73
+
74
+ const [selectedPath, setSelectedPath] = useState<typeof NotificationHelpers.app_paths[0] | undefined>(initialPath);
75
+ const [customPathName, setCustomPathName] = useState('');
76
+ const [pathParams, setPathParams] = useState<Record<string, string>>(default_params || {});
77
+ const [pathSearch, setPathSearch] = useState('');
78
+ const [notification, setNotification] = useState<Partial<PlayerNotificationProps>>({
79
+ type: notification_type || 'order_notifications',
80
+ notify_body_type: 'custom',
81
+ title: '',
82
+ body: '',
83
+ status: 'pending',
84
+ expire_time: Date.now() + (7 * 24 * 60 * 60 * 1000), // 7 days from now
85
+ });
86
+
87
+ useEffect(() => {
88
+ startUp();
89
+ // Auto-select custom group if initial player_ids provided
90
+ if (player_ids.length > 0) {
91
+ setSelectedGroup(customGroup);
92
+ setGroupMemberIds(player_ids);
93
+ }
94
+ }, []);
95
+
96
+ useEffect(() => {
97
+ // Initialize pathParams when selectedPath changes, merging with default_params
98
+ const mergedParams = { ...selectedPath?.params, ...(default_params || {}) };
99
+ setPathParams(mergedParams as any);
100
+ }, [selectedPath]);
101
+
102
+ const startUp = () => {
103
+ NotificationApi.setEnvironment();
104
+ };
105
+
106
+ const loadNotificationGroups = async () => {
107
+ setLoadingGroups(true);
108
+ const groups = await NotificationApi.getActiveNotificationGroups();
109
+ setNotificationGroups(groups);
110
+ setLoadingGroups(false);
111
+ };
112
+
113
+ const handleSelectGroup = async (group_id: string) => {
114
+ setLoadingGroups(true);
115
+
116
+ // Check if it's the custom group
117
+ if (group_id === 'custom_group') {
118
+ // If custom group already selected with edited IDs, keep those IDs
119
+ // Otherwise use the initial player_ids
120
+ if (selectedGroup?.notification_group_id === 'custom_group' && groupMemberIds.length > 0) {
121
+ // Keep existing edited IDs
122
+ setSelectedGroup({
123
+ ...customGroup,
124
+ description: `${groupMemberIds.length} selected recipients`,
125
+ player_count: groupMemberIds.length
126
+ });
127
+ } else {
128
+ setSelectedGroup(customGroup);
129
+ setGroupMemberIds(player_ids);
130
+ }
131
+ setLoadingGroups(false);
132
+ setShowGroupsModal(false);
133
+ return;
134
+ }
135
+
136
+ const group = notificationGroups.find(g => g.notification_group_id === group_id);
137
+ setSelectedGroup(group || null);
138
+
139
+ // Fetch all members for this group
140
+ const members = await NotificationApi.getNotificationGroupMembersByGroup(group_id);
141
+
142
+ // Flatten all player_ids arrays from all member objects
143
+ const memberPlayerIds = members.flatMap(m => m.player_ids);
144
+ setGroupMemberIds(memberPlayerIds);
145
+
146
+ setLoadingGroups(false);
147
+ setShowGroupsModal(false);
148
+ };
149
+
150
+ const handleRemoveGroup = () => {
151
+ setSelectedGroup(null);
152
+ setGroupMemberIds([]);
153
+ };
154
+
155
+ const handleOpenEditCustomGroup = () => {
156
+ // Populate CSV with current groupMemberIds
157
+ setCustomGroupCsv(groupMemberIds.join(','));
158
+ setShowEditCustomGroupModal(true);
159
+ };
160
+
161
+ const handleSaveCustomGroup = () => {
162
+ // Parse CSV and update groupMemberIds
163
+ const ids = customGroupCsv
164
+ .split(',')
165
+ .map(id => id.trim())
166
+ .filter(id => id.length > 0);
167
+
168
+ setGroupMemberIds(ids);
169
+
170
+ // Update the custom group description
171
+ if (customGroup && selectedGroup?.notification_group_id === 'custom_group') {
172
+ const updatedGroup = {
173
+ ...customGroup,
174
+ description: `${ids.length} selected recipients`,
175
+ player_count: ids.length
176
+ };
177
+ setSelectedGroup(updatedGroup);
178
+ }
179
+
180
+ setShowEditCustomGroupModal(false);
181
+ };
182
+
183
+ // Filter and sort paths: primary first (alphabetically), then others (alphabetically), limited by search
184
+ const filteredPaths = useMemo(() => {
185
+ let filtered = NotificationHelpers.app_paths;
186
+
187
+ // Apply search filter
188
+ if (pathSearch) {
189
+ filtered = filtered.filter(path =>
190
+ path.label.toLowerCase().includes(pathSearch.toLowerCase()) ||
191
+ path.path_name.toLowerCase().includes(pathSearch.toLowerCase())
192
+ );
193
+ }
194
+
195
+ // Sort: primary paths first (alphabetically), then non-primary (alphabetically)
196
+ const sorted = [...filtered].sort((a, b) => {
197
+ if (a.primary && !b.primary) return -1;
198
+ if (!a.primary && b.primary) return 1;
199
+ return a.label.localeCompare(b.label);
200
+ });
201
+
202
+ // Limit to 15 results
203
+ return sorted.slice(0, 15);
204
+ }, [pathSearch]);
205
+
206
+ const isValid = (): string[] => {
207
+ const errors: string[] = [];
208
+
209
+ if (!notification.title || notification.title.trim() === '') {
210
+ errors.push('Notification title is required');
211
+ }
212
+
213
+ if (!notification.body || notification.body.trim() === '') {
214
+ errors.push('Notification body is required');
215
+ }
216
+
217
+ if (groupMemberIds.length === 0) {
218
+ errors.push('At least one recipient is required');
219
+ }
220
+
221
+ // Check required params
222
+ if (selectedPath?.requiredParams && selectedPath.requiredParams.length > 0) {
223
+ selectedPath.requiredParams.forEach(paramKey => {
224
+ if (!pathParams[paramKey] || pathParams[paramKey].trim() === '') {
225
+ errors.push(`Required parameter "${paramKey}" is missing`);
226
+ }
227
+ });
228
+ }
229
+
230
+ // Check custom path name
231
+ if (selectedPath?.path_name === 'custom' && (!customPathName || customPathName.trim() === '')) {
232
+ errors.push('Custom path name is required');
233
+ }
234
+
235
+ return errors;
236
+ };
237
+
238
+ const confirmSendNotification = (options?:{ test:boolean }) => {
239
+ const errors = isValid();
240
+
241
+ if (errors.length > 0) {
242
+ const errorMessage = 'Please fix the following errors:\n\n' + errors.map((err, idx) => `${idx + 1}. ${err}`).join('\n');
243
+ alert(errorMessage);
244
+ return;
245
+ }
246
+
247
+ showConfirmAlert('Are you sure?', options?.test ? 'This will send a test notification to you only':'This will send a notification to all users selected',
248
+ () => handleSendNotification(options),
249
+ () => console.log('Done')
250
+ )
251
+ };
252
+
253
+ const handleSendNotification = async (options?:{ test:boolean }) => {
254
+ // Use only group member IDs (no individual player_ids)
255
+ let player_ids_to_send = [...groupMemberIds];
256
+
257
+ if(options?.test){
258
+ //Change to the admin
259
+ player_ids_to_send = [me.player_id]
260
+ }
261
+
262
+ setLoading(true);
263
+ try {
264
+ // Determine final path_name
265
+ const finalPathName = selectedPath?.path_name === 'custom' ? customPathName : selectedPath?.path_name;
266
+
267
+ // Construct the complete notification object with all required fields
268
+ const completeNotification: PlayerNotificationProps = {
269
+ player_notification_id: '', // Will be generated by backend
270
+ player_id: me.player_id,
271
+ type: notification.type as 'order_notifications' | 'competition_notifications' | 'social_notifications',
272
+ notify_body_type: notification.notify_body_type || 'custom',
273
+ title: notification.title!,
274
+ body: notification.body!,
275
+ options: {
276
+ id: Date.now().toString(),
277
+ body: notification.body!,
278
+ icon: 'default',
279
+ type: notification.type || 'order_notifications',
280
+ data: {
281
+ id: Date.now().toString(),
282
+ player_id: me.player_id,
283
+ pageStack: 'MainStack',
284
+ page: 'Notifications',
285
+ pageParams: {},
286
+ url: notification.link_override,
287
+ path_name: finalPathName,
288
+ params: pathParams
289
+ }
290
+ },
291
+ status: 'unread',
292
+ link_override: notification.link_override,
293
+ expire_time: 0,
294
+ create_datetime: new Date().toISOString(),
295
+ last_update_datetime: new Date().toISOString(),
296
+ };
297
+
298
+ // Split into batches of 500
299
+ const BATCH_SIZE = 500;
300
+ const batches: string[][] = [];
301
+ for (let i = 0; i < player_ids_to_send.length; i += BATCH_SIZE) {
302
+ batches.push(player_ids_to_send.slice(i, i + BATCH_SIZE));
303
+ }
304
+
305
+ // Send notifications in batches
306
+ for (let i = 0; i < batches.length; i++) {
307
+ const batch = batches[i];
308
+ if (!batch) continue;
309
+
310
+ setSendingProgress({ current: i + 1, total: batches.length });
311
+ await NotificationApi.broadcastNotifications(
312
+ completeNotification,
313
+ batch
314
+ );
315
+ }
316
+
317
+ setSendingProgress(null);
318
+
319
+ if (onComplete) {
320
+ onComplete(completeNotification);
321
+ }
322
+
323
+ // Only reset form if not a test send
324
+ if (!options?.test) {
325
+ setNotification({
326
+ type: 'order_notifications',
327
+ notify_body_type: 'custom',
328
+ title: '',
329
+ body: '',
330
+ status: 'pending',
331
+ expire_time: Date.now() + (7 * 24 * 60 * 60 * 1000),
332
+ });
333
+ }
334
+
335
+ const recipientCount = options?.test ? 1 : groupMemberIds.length;
336
+ const recipientMessage = options?.test ? 'Test notification sent to you!' : `Notification sent successfully to ${recipientCount} user${recipientCount !== 1 ? 's' : ''}!`;
337
+ alert(recipientMessage);
338
+ } catch (error) {
339
+ console.error('Error sending notification:', error);
340
+ alert('Failed to send notification. Please try again.');
341
+ }
342
+ setLoading(false);
343
+ };
344
+
345
+
346
+ const renderSections = (data:{ item:string, index:number }) => {
347
+ switch(data.item){
348
+ case 'header':
349
+ const headerTotalRecipients = groupMemberIds.length;
350
+ return (
351
+ <View type='header' style={{ flexDirection:'row', alignItems:'center', padding:10, ...header_style }}>
352
+ <View transparent style={{ flex:1 }}>
353
+ <Text theme='h1'>Send Notification</Text>
354
+ <Text theme='description' style={{ marginTop:3 }}>Sending to {headerTotalRecipients} users</Text>
355
+ </View>
356
+ {onClose && (
357
+ <Button transparent onPress={onClose}>
358
+ <Icons.CloseIcon size={16} color={Colors.text.h1} />
359
+ </Button>
360
+ )}
361
+ </View>
362
+ )
363
+ case 'preview':
364
+ return (
365
+ <View float style={{ margin: 5 }}>
366
+ <View type='header' style={{ flexDirection:'row', alignItems:'center', padding:10, borderTopRightRadius:8, borderTopLeftRadius:8 }}>
367
+ <View transparent style={{ flex:1 }}>
368
+ <Text theme='h1'>Preview</Text>
369
+ <Text theme='description' style={{ marginTop:3 }}>What will this notification look like?</Text>
370
+ </View>
371
+ </View>
372
+ <View
373
+ float
374
+ style={{
375
+ padding: 15,
376
+ margin:10,
377
+ borderRadius: 8,
378
+ borderWidth: 1,
379
+ borderColor: Colors.borders.light,
380
+ }}
381
+ >
382
+ <View transparent style={{ flexDirection: 'row', alignItems: 'center' }}>
383
+ <View
384
+ style={{
385
+ width: 40,
386
+ height: 40,
387
+ borderRadius: 8,
388
+ justifyContent: 'center',
389
+ alignItems: 'center',
390
+ marginRight: 12,
391
+ }}
392
+ >
393
+ <Icons.NotificationIcon size={20} color={Colors.text.h1} />
394
+ </View>
395
+ <View transparent style={{ flex: 1 }}>
396
+ <Text theme="h1">
397
+ {notification.title || 'Notification Title'}
398
+ </Text>
399
+ <Text theme="description" style={{ marginTop: 4 }}>
400
+ {notification.body || 'Notification message will appear here'}
401
+ </Text>
402
+ </View>
403
+ </View>
404
+ </View>
405
+ </View>
406
+ )
407
+ case 'top_row':
408
+ const totalRecipients = groupMemberIds.length;
409
+ return (
410
+ <View style={{ flexDirection:'row', flexWrap:'wrap' }}>
411
+ <View
412
+ float
413
+ style={{
414
+ flexGrow:1,
415
+ minWidth:300,
416
+ padding:10,
417
+ margin:5
418
+ }}
419
+ >
420
+ <View transparent style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
421
+ <View transparent style={{ flex:1 }}>
422
+ <Text theme="h2">Recipients ({totalRecipients})</Text>
423
+ <Text theme="description" style={{ marginTop: 4 }}>
424
+ {totalRecipients === 0 ? 'No recipients selected' : 'Selected group'}
425
+ </Text>
426
+ </View>
427
+ <Button
428
+ type="action"
429
+ onPress={() => {
430
+ loadNotificationGroups();
431
+ setShowGroupsModal(true);
432
+ }}
433
+ style={{ padding: 8 }}
434
+ >
435
+ <Text theme="h1" color={Colors.text.white}>
436
+ Select Group
437
+ </Text>
438
+ </Button>
439
+ </View>
440
+
441
+ {/* Display Selected Group */}
442
+ {selectedGroup && (
443
+ <View
444
+ float
445
+ style={{
446
+ flexDirection: 'row',
447
+ alignItems: 'center',
448
+ justifyContent: 'space-between',
449
+ padding: 12,
450
+ borderRadius: 8,
451
+ marginTop: 5
452
+ }}
453
+ >
454
+ <View transparent style={{ flex: 1 }}>
455
+ <Text theme="h1">{selectedGroup.name}</Text>
456
+ <Text theme="description" style={{ marginTop: 2 }}>
457
+ {groupMemberIds.length} members
458
+ </Text>
459
+ </View>
460
+ <View transparent style={{ flexDirection: 'row' }}>
461
+ {selectedGroup.notification_group_id === 'custom_group' && (
462
+ <Button
463
+ type="action"
464
+ onPress={handleOpenEditCustomGroup}
465
+ style={{ padding: 8, marginRight: 8 }}
466
+ >
467
+ <Icons.EditIcon size={16} color={Colors.text.white} />
468
+ </Button>
469
+ )}
470
+ <Button
471
+ type="error"
472
+ onPress={handleRemoveGroup}
473
+ style={{ padding: 8 }}
474
+ >
475
+ <Icons.CloseIcon size={16} color={Colors.text.white} />
476
+ </Button>
477
+ </View>
478
+ </View>
479
+ )}
480
+ </View>
481
+ </View>
482
+ )
483
+
484
+ case 'second_row':
485
+ return (
486
+ <View style={{ flexDirection:'row', flexWrap:'wrap' }}>
487
+ <View float style={{ minWidth:300, flexGrow :1, margin:5 }}>
488
+ <View type='header' style={{ flexDirection:'row', alignItems:'center', padding:10, borderTopRightRadius:8, borderTopLeftRadius:8 }}>
489
+ <View transparent style={{ flex:1 }}>
490
+ <Text theme='h1'>Notification Message</Text>
491
+ <Text theme='description' style={{ marginTop:3 }}>Update notification message below</Text>
492
+ </View>
493
+ </View>
494
+ <View transparent type='body'>
495
+ <View transparent style={{ padding:10 }}>
496
+ <Text theme="h2" style={{ marginBottom:10 }}>
497
+ Notification Title
498
+ </Text>
499
+ <TextInput
500
+ value={notification.title}
501
+ onFocusPosition={onFocusPosition}
502
+ placeholder="Enter notification title"
503
+ onChangeText={(title) => setNotification({ ...notification, title })}
504
+
505
+ />
506
+ </View>
507
+ <View transparent style={{ padding:10 }}>
508
+ <Text theme="h2" style={{ marginBottom:10 }}>
509
+ Notification Body
510
+ </Text>
511
+ <TextInput
512
+ value={notification.body}
513
+ onFocusPosition={onFocusPosition}
514
+ placeholder="Enter notification message"
515
+ onChangeText={(body) => setNotification({ ...notification, body })}
516
+ multiline
517
+ numberOfLines={6}
518
+ style={{ padding: 12, borderRadius: 8, minHeight: 120 }}
519
+ />
520
+ </View>
521
+ </View>
522
+ </View>
523
+ <View float style={{ flexGrow:1, minWidth:300, margin:5 }}>
524
+ <View type='header' style={{ flexDirection:'row', alignItems:'center', padding:10, borderTopRightRadius:8, borderTopLeftRadius:8 }}>
525
+ <View transparent style={{ flex:1 }}>
526
+ <Text theme='h1'>Notification Settings</Text>
527
+ <Text theme='description' style={{ marginTop:3 }}>Manage settigns below</Text>
528
+ </View>
529
+ </View>
530
+ <View transparent type='body'>
531
+ <View type='row' style={{ padding:10 }}>
532
+ <Text theme="h2" style={{ flex:1 }}>
533
+ Notification Type
534
+ </Text>
535
+ <DropDown
536
+ selected_value={
537
+ notification_types.find((t) => t.value === notification.type)?.label ?? ''
538
+ }
539
+ dropdown_options={[
540
+ { value: 'type', eligible_options: notification_types.map((t) => t.label) },
541
+ ]}
542
+ onOptionSelect={(selected) => {
543
+ const type = notification_types.find((t) => t.label === selected)?.value;
544
+ setNotification({ ...notification, type: type as any });
545
+ }}
546
+ />
547
+ </View>
548
+ <View transparent style={{ padding:10 }}>
549
+ <Text theme="h2" style={{ marginBottom:10 }}>
550
+ Link Override (Optional)
551
+ </Text>
552
+ <TextInput
553
+ value={notification.link_override || ''}
554
+ onFocusPosition={onFocusPosition}
555
+ placeholder="https://example.com"
556
+ onChangeText={(link_override) =>
557
+ setNotification({ ...notification, link_override })
558
+ }
559
+ style={{ padding: 12, borderRadius: 8 }}
560
+ />
561
+ </View>
562
+ <View transparent style={{ padding:10 }}>
563
+ <Text theme="h2" style={{ marginBottom: 10 }}>
564
+ Deep Link Path
565
+ </Text>
566
+
567
+ {/* Selected Path Display */}
568
+ <View
569
+ type="header"
570
+ style={{
571
+ padding: 12,
572
+ borderRadius: 8,
573
+ marginBottom: 8,
574
+ flexDirection: 'row',
575
+ alignItems: 'center',
576
+ justifyContent: 'space-between'
577
+ }}
578
+ >
579
+ <View transparent style={{ flex: 1 }}>
580
+ <Text theme="h1" style={{ marginBottom: 2 }}>
581
+ {selectedPath?.label}
582
+ </Text>
583
+ <Text theme="description" style={{ fontSize: 12 }}>
584
+ {selectedPath?.path_name}
585
+ </Text>
586
+ </View>
587
+ {selectedPath?.primary && (
588
+ <View transparent style={{ paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 }}>
589
+ <Text theme="description" color={Colors.text.white} style={{ fontSize: 10 }}>
590
+ Primary
591
+ </Text>
592
+ </View>
593
+ )}
594
+ </View>
595
+
596
+ {/* Search Box */}
597
+ <TextInput
598
+ value={pathSearch}
599
+ placeholder="Search paths to change..."
600
+ onFocusPosition={onFocusPosition}
601
+ onChangeText={setPathSearch}
602
+ style={{ padding: 12, borderRadius: 8, marginBottom: 8 }}
603
+ />
604
+
605
+ {/* Path Results List */}
606
+ {pathSearch && (
607
+ <View
608
+ float
609
+ style={{
610
+ maxHeight: 300,
611
+ borderRadius: 8,
612
+ borderWidth: 1,
613
+ borderColor: Colors.borders.light,
614
+ }}
615
+ >
616
+ <FlatList
617
+ data={filteredPaths}
618
+ keyExtractor={(item) => item.path_name}
619
+ renderItem={({ item }) => (
620
+ <TouchableOpacity
621
+ onPress={() => {
622
+ setSelectedPath(item);
623
+ setPathSearch('');
624
+ }}
625
+ style={{
626
+ padding: 12,
627
+ borderBottomWidth: 1,
628
+ borderColor: Colors.borders.light,
629
+ }}
630
+ >
631
+ <View transparent style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
632
+ <View transparent style={{ flex: 1 }}>
633
+ <Text theme="h2" style={{ marginBottom: 2 }}>
634
+ {item.label}
635
+ </Text>
636
+ <Text theme="description" style={{ fontSize: 11 }}>
637
+ {item.path_name}
638
+ </Text>
639
+ </View>
640
+ {item.primary && (
641
+ <View transparent style={{ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, marginLeft: 8 }}>
642
+ <Text theme="description" color={Colors.text.white} style={{ fontSize: 9 }}>
643
+ Primary
644
+ </Text>
645
+ </View>
646
+ )}
647
+ </View>
648
+ </TouchableOpacity>
649
+ )}
650
+ ListEmptyComponent={
651
+ <View style={{ padding: 20, alignItems: 'center' }}>
652
+ <Text theme="description">No paths found</Text>
653
+ </View>
654
+ }
655
+ />
656
+ </View>
657
+ )}
658
+ </View>
659
+ {Object.keys(selectedPath?.params ?? {}).length > 0 && (
660
+ <View style={{ padding:10 }}>
661
+ <Text theme="h2" style={{ marginBottom: 8 }}>
662
+ Path Parameters
663
+ </Text>
664
+ <View type="header" style={{ padding: 12, borderRadius: 8 }}>
665
+ {Object.keys(selectedPath?.params ?? {}).map((paramKey) => {
666
+ const isRequired = selectedPath?.requiredParams.includes(paramKey);
667
+ return (
668
+ <View key={paramKey} transparent style={{ marginBottom: 12 }}>
669
+ <View transparent style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
670
+ <Text theme="description">
671
+ {paramKey}
672
+ </Text>
673
+ {isRequired ? (
674
+ <View transparent style={{ marginLeft: 4, paddingHorizontal: 6, paddingVertical: 2, backgroundColor: Colors.text.error, borderRadius: 4 }}>
675
+ <Text theme="description" color={Colors.text.white} style={{ fontSize: 10 }}>
676
+ Required
677
+ </Text>
678
+ </View>
679
+ ) : (
680
+ <View transparent style={{ marginLeft: 4, paddingHorizontal: 6, paddingVertical: 2, backgroundColor: Colors.borders.light, borderRadius: 4 }}>
681
+ <Text theme="description" style={{ fontSize: 10 }}>
682
+ Optional
683
+ </Text>
684
+ </View>
685
+ )}
686
+ </View>
687
+ <TextInput
688
+ value={pathParams[paramKey] || ''}
689
+ onFocusPosition={onFocusPosition}
690
+ placeholder={`Enter ${paramKey}${isRequired ? ' (required)' : ' (optional)'}`}
691
+ onChangeText={(value) =>
692
+ setPathParams({ ...pathParams, [paramKey]: value })
693
+ }
694
+ style={{
695
+ padding: 10,
696
+ borderRadius: 8,
697
+ borderWidth: isRequired ? 2 : 1,
698
+ borderColor: isRequired && !pathParams[paramKey] ? Colors.text.error : Colors.borders.light
699
+ }}
700
+ />
701
+ </View>
702
+ );
703
+ })}
704
+ </View>
705
+ </View>
706
+ )}
707
+ </View>
708
+ {selectedPath?.path_name === 'custom' && (
709
+ <View style={{ marginBottom: 20 }}>
710
+ <Text theme="h2" style={{ marginBottom: 8 }}>
711
+ Custom Path Name
712
+ </Text>
713
+ <TextInput
714
+ value={customPathName}
715
+ onFocusPosition={onFocusPosition}
716
+ placeholder="/your/custom/path"
717
+ onChangeText={setCustomPathName}
718
+ style={{ padding: 12, borderRadius: 8 }}
719
+ />
720
+ </View>
721
+ )}
722
+ </View>
723
+
724
+ </View>
725
+ )
726
+ default: return <></>
727
+ }
728
+ }
729
+
730
+ return (
731
+ <View
732
+ float={float}
733
+ style={{ flex: 1 }}
734
+ >
735
+ <View transparent style={{ flex:1 }}>
736
+ <FlatList
737
+ data={sections}
738
+ keyExtractor={item => item}
739
+ key={'notification_manager_list'}
740
+ renderItem={renderSections}
741
+ />
742
+ </View>
743
+
744
+ {/* Footer with Send Button */}
745
+ <View type='footer' style={{ flexDirection:'row', alignItems:'center', padding:10, ...footer_style }}>
746
+ <Button
747
+ type="action"
748
+ disabled={loading}
749
+ loading={loading}
750
+ onPress={() => confirmSendNotification({ test: true })}
751
+ style={{
752
+ flex:1,
753
+ opacity: loading || isValid().length > 0 ? 0.5 : 1,
754
+ }}
755
+ >
756
+ <Text textAlign='center' theme="h1" color={Colors.text.white}>
757
+ {loading
758
+ ? (sendingProgress ? `Sending ${sendingProgress.current}/${sendingProgress.total}` : 'Sending...')
759
+ : 'Send Test'}
760
+ </Text>
761
+ </Button>
762
+ <Button
763
+ type="success"
764
+ disabled={loading}
765
+ loading={loading}
766
+ onPress={() => confirmSendNotification()}
767
+ style={{
768
+ flex:2,
769
+ marginLeft:5,
770
+ opacity: loading || isValid().length > 0 ? 0.5 : 1,
771
+ }}
772
+ >
773
+ <Text textAlign='center' theme="h1" color={Colors.text.white}>
774
+ {loading
775
+ ? (sendingProgress ? `Sending batch ${sendingProgress.current} of ${sendingProgress.total}` : 'Sending...')
776
+ : 'Send Notification'}
777
+ </Text>
778
+ </Button>
779
+ </View>
780
+
781
+ {/* Notification Groups Modal */}
782
+ {showGroupsModal && (
783
+ <View type='blur' style={{ position:'absolute', top:0, left:0, right:0, bottom:0, padding:20 }}>
784
+ <View float style={{ flex:1 }}>
785
+ <View type='header' style={{ flexDirection:'row', alignItems:'center', padding:10, borderTopRightRadius:8, borderTopLeftRadius:8 }}>
786
+ <View transparent style={{ flex:1 }}>
787
+ <Text theme='h1'>Notification Groups</Text>
788
+ <Text theme='description' style={{ marginTop:3 }}>Select a group to send to</Text>
789
+ </View>
790
+ </View>
791
+ <View style={{flex:1}}>
792
+ {loadingGroups ? (
793
+ <View style={{ padding: 40, alignItems: 'center' }}>
794
+ <ActivityIndicator size="large" color={Colors.text.action} />
795
+ </View>
796
+ ) : (
797
+ <FlatList
798
+ data={[
799
+ // Show custom group with current member count
800
+ selectedGroup?.notification_group_id === 'custom_group' && groupMemberIds.length > 0
801
+ ? { ...customGroup, description: `${groupMemberIds.length} selected recipients`, player_count: groupMemberIds.length }
802
+ : customGroup,
803
+ ...notificationGroups
804
+ ]}
805
+ keyExtractor={(item) => item.notification_group_id}
806
+ renderItem={({ item }) => (
807
+ <TouchableOpacity
808
+ onPress={() => handleSelectGroup(item.notification_group_id)}
809
+ style={{
810
+ padding: 15,
811
+ borderBottomWidth: 1,
812
+ borderColor: Colors.borders.light,
813
+ }}
814
+ >
815
+ <View transparent>
816
+ <View transparent style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
817
+ <View transparent style={{ flexDirection: 'row', alignItems: 'center', flex: 1 }}>
818
+ <Text theme="h1">{item.name}</Text>
819
+ {item.notification_group_id === 'custom_group' && (
820
+ <View transparent style={{ marginLeft: 8, paddingHorizontal: 6, paddingVertical: 2, backgroundColor: Colors.buttons.background.action, borderRadius: 4 }}>
821
+ <Text theme="description" color={Colors.text.white} style={{ fontSize: 10 }}>
822
+ CUSTOM
823
+ </Text>
824
+ </View>
825
+ )}
826
+ </View>
827
+ {item.player_count !== undefined && (
828
+ <View transparent style={{ marginLeft: 8, paddingHorizontal: 8, paddingVertical: 4, backgroundColor: Colors.views.header, borderRadius: 4 }}>
829
+ <Text theme="description" style={{ fontSize: 12, fontWeight: '600' }}>
830
+ {item.player_count.toLocaleString()} users
831
+ </Text>
832
+ </View>
833
+ )}
834
+ </View>
835
+ {item.description && (
836
+ <Text theme="description" style={{ marginTop: 4 }}>
837
+ {item.description}
838
+ </Text>
839
+ )}
840
+ </View>
841
+ </TouchableOpacity>
842
+ )}
843
+ ListEmptyComponent={
844
+ <View style={{ padding: 40, alignItems: 'center' }}>
845
+ <Text theme="description">No groups found</Text>
846
+ </View>
847
+ }
848
+ />
849
+ )}
850
+ </View>
851
+ <View type='footer' style={{ flexDirection:'row', alignItems:'center', padding:10, borderBottomRightRadius:8, borderBottomLeftRadius:8 }}>
852
+ <Button
853
+ style={{ flex:1 }}
854
+ type='close'
855
+ title='CLOSE'
856
+ onPress={() => setShowGroupsModal(false)}
857
+ />
858
+ </View>
859
+ </View>
860
+ </View>
861
+ )}
862
+
863
+ {/* Edit Custom Group Modal */}
864
+ {showEditCustomGroupModal && (
865
+ <View type='blur' style={{ position:'absolute', top:0, left:0, right:0, bottom:0, padding:20 }}>
866
+ <View float style={{ maxHeight: '80%', minHeight: 400 }}>
867
+ <View type='header' style={{ flexDirection:'row', alignItems:'center', padding:10, borderTopRightRadius:8, borderTopLeftRadius:8 }}>
868
+ <View transparent style={{ flex:1 }}>
869
+ <Text theme='h1'>Edit Custom Recipients</Text>
870
+ <Text theme='description' style={{ marginTop:3 }}>Paste comma-separated player IDs</Text>
871
+ </View>
872
+ </View>
873
+ <View style={{ flex: 1, padding: 15 }}>
874
+ <Text theme="h2" style={{ marginBottom: 10 }}>
875
+ Player IDs (CSV format)
876
+ </Text>
877
+ <TextInput
878
+ value={customGroupCsv}
879
+ onFocusPosition={onFocusPosition}
880
+ placeholder="98,100,2485,etc"
881
+ onChangeText={setCustomGroupCsv}
882
+ multiline
883
+ numberOfLines={10}
884
+ style={{
885
+ padding: 12,
886
+ borderRadius: 8,
887
+ minHeight: 200,
888
+ textAlignVertical: 'top'
889
+ }}
890
+ />
891
+ <Text theme="description" style={{ marginTop: 8 }}>
892
+ Current count: {customGroupCsv.split(',').filter(id => id.trim().length > 0).length} IDs
893
+ </Text>
894
+ </View>
895
+ <View type='footer' style={{ flexDirection:'row', alignItems:'center', padding:10, borderBottomRightRadius:8, borderBottomLeftRadius:8 }}>
896
+ <Button
897
+ style={{ flex:1, marginRight: 5 }}
898
+ type='close'
899
+ title='CANCEL'
900
+ onPress={() => setShowEditCustomGroupModal(false)}
901
+ />
902
+ <Button
903
+ style={{ flex:1 }}
904
+ type='success'
905
+ title='SAVE'
906
+ onPress={handleSaveCustomGroup}
907
+ />
908
+ </View>
909
+ </View>
910
+ </View>
911
+ )}
912
+ </View>
913
+ );
914
+ };
915
+
916
+ export default NotificationManager;
917
+ export { NotificationApi, NotificationHelpers };