@tradejs/app 3.1.29-beta.269 → 3.1.29-beta.271
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.
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
Portal,
|
|
16
16
|
Spinner,
|
|
17
17
|
Stack,
|
|
18
|
+
Switch,
|
|
19
|
+
Tabs,
|
|
18
20
|
Text,
|
|
19
21
|
} from '@chakra-ui/react';
|
|
20
22
|
import { FiEdit2, FiSettings } from 'react-icons/fi';
|
|
@@ -36,9 +38,22 @@ import {
|
|
|
36
38
|
import { toaster } from '#ui';
|
|
37
39
|
import { TradingAccountsPanel } from './TradingAccountsPanel';
|
|
38
40
|
|
|
41
|
+
type NotificationSettings = {
|
|
42
|
+
signals: boolean;
|
|
43
|
+
aiAnalysis: boolean;
|
|
44
|
+
runtimeDebug: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULT_NOTIFICATIONS: NotificationSettings = {
|
|
48
|
+
signals: true,
|
|
49
|
+
aiAnalysis: true,
|
|
50
|
+
runtimeDebug: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
39
53
|
type SettingsResponse = {
|
|
40
54
|
userName: string;
|
|
41
55
|
settings: {
|
|
56
|
+
notifications: NotificationSettings;
|
|
42
57
|
coinalyze: {
|
|
43
58
|
apiKey: string;
|
|
44
59
|
};
|
|
@@ -64,6 +79,7 @@ type SettingsErrorResponse = {
|
|
|
64
79
|
};
|
|
65
80
|
|
|
66
81
|
type SettingsViewState = {
|
|
82
|
+
notifications: NotificationSettings;
|
|
67
83
|
userName: string;
|
|
68
84
|
coinalyzeApiKey: string;
|
|
69
85
|
coinmarketcapApiKey: string;
|
|
@@ -78,7 +94,7 @@ type SettingsViewState = {
|
|
|
78
94
|
tgChatId: string;
|
|
79
95
|
};
|
|
80
96
|
|
|
81
|
-
type SettingsDraftState = Omit<SettingsViewState, 'userName'>;
|
|
97
|
+
type SettingsDraftState = Omit<SettingsViewState, 'userName' | 'notifications'>;
|
|
82
98
|
|
|
83
99
|
type PasswordState = {
|
|
84
100
|
password: string;
|
|
@@ -87,6 +103,7 @@ type PasswordState = {
|
|
|
87
103
|
|
|
88
104
|
type SectionName =
|
|
89
105
|
| 'password'
|
|
106
|
+
| 'notifications'
|
|
90
107
|
| 'coinalyze'
|
|
91
108
|
| 'coinmarketcap'
|
|
92
109
|
| 'jev'
|
|
@@ -106,6 +123,7 @@ type EditableField =
|
|
|
106
123
|
| 'tgChatId';
|
|
107
124
|
|
|
108
125
|
const EMPTY_SETTINGS: SettingsViewState = {
|
|
126
|
+
notifications: DEFAULT_NOTIFICATIONS,
|
|
109
127
|
userName: '',
|
|
110
128
|
coinalyzeApiKey: '',
|
|
111
129
|
coinmarketcapApiKey: '',
|
|
@@ -163,6 +181,7 @@ const MASKED_FIELDS = new Set<EditableField>([
|
|
|
163
181
|
|
|
164
182
|
const toViewState = (payload: SettingsResponse): SettingsViewState => ({
|
|
165
183
|
userName: payload.userName,
|
|
184
|
+
notifications: payload.settings.notifications,
|
|
166
185
|
coinalyzeApiKey: payload.settings.coinalyze.apiKey || '',
|
|
167
186
|
coinmarketcapApiKey: payload.settings.coinmarketcap.apiKey || '',
|
|
168
187
|
jevApiKey: payload.settings.jev?.apiKey || '',
|
|
@@ -249,6 +268,9 @@ export const AccountSettingsDrawer = () => {
|
|
|
249
268
|
const [loading, setLoading] = useState(false);
|
|
250
269
|
const [settings, setSettings] = useState<SettingsViewState>(EMPTY_SETTINGS);
|
|
251
270
|
const [drafts, setDrafts] = useState<SettingsDraftState>(EMPTY_DRAFTS);
|
|
271
|
+
const [notificationDrafts, setNotificationDrafts] = useState(
|
|
272
|
+
DEFAULT_NOTIFICATIONS,
|
|
273
|
+
);
|
|
252
274
|
const [passwords, setPasswords] = useState<PasswordState>(EMPTY_PASSWORDS);
|
|
253
275
|
const [editing, setEditing] =
|
|
254
276
|
useState<Record<EditableField, boolean>>(EMPTY_EDITING);
|
|
@@ -257,6 +279,7 @@ export const AccountSettingsDrawer = () => {
|
|
|
257
279
|
const syncState = useCallback((payload: SettingsResponse) => {
|
|
258
280
|
const next = toViewState(payload);
|
|
259
281
|
setSettings(next);
|
|
282
|
+
setNotificationDrafts(next.notifications);
|
|
260
283
|
setDrafts(toDraftState(next));
|
|
261
284
|
setEditing(EMPTY_EDITING);
|
|
262
285
|
}, []);
|
|
@@ -315,6 +338,14 @@ export const AccountSettingsDrawer = () => {
|
|
|
315
338
|
|
|
316
339
|
const isSectionDirty = useCallback(
|
|
317
340
|
(section: SectionName) => {
|
|
341
|
+
if (section === 'notifications') {
|
|
342
|
+
return Object.keys(DEFAULT_NOTIFICATIONS).some(
|
|
343
|
+
(key) =>
|
|
344
|
+
notificationDrafts[key as keyof NotificationSettings] !==
|
|
345
|
+
settings.notifications[key as keyof NotificationSettings],
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
318
349
|
if (section === 'password') {
|
|
319
350
|
return Boolean(passwords.password || passwords.confirmPassword);
|
|
320
351
|
}
|
|
@@ -353,7 +384,13 @@ export const AccountSettingsDrawer = () => {
|
|
|
353
384
|
|
|
354
385
|
return false;
|
|
355
386
|
},
|
|
356
|
-
[
|
|
387
|
+
[
|
|
388
|
+
drafts,
|
|
389
|
+
notificationDrafts,
|
|
390
|
+
passwords.confirmPassword,
|
|
391
|
+
passwords.password,
|
|
392
|
+
settings,
|
|
393
|
+
],
|
|
357
394
|
);
|
|
358
395
|
|
|
359
396
|
const updateDraft = (field: EditableField, value: string) => {
|
|
@@ -437,57 +474,59 @@ export const AccountSettingsDrawer = () => {
|
|
|
437
474
|
|
|
438
475
|
try {
|
|
439
476
|
const body =
|
|
440
|
-
section === '
|
|
441
|
-
? {
|
|
442
|
-
|
|
443
|
-
data: {
|
|
444
|
-
apiKey: getSecretUpdateValue('jevApiKey'),
|
|
445
|
-
apiEndpoint: drafts.jevApiEndpoint,
|
|
446
|
-
model: drafts.jevModel.trim(),
|
|
447
|
-
},
|
|
448
|
-
}
|
|
449
|
-
: section === 'coinalyze'
|
|
477
|
+
section === 'notifications'
|
|
478
|
+
? { section, data: notificationDrafts }
|
|
479
|
+
: section === 'jev'
|
|
450
480
|
? {
|
|
451
481
|
section,
|
|
452
482
|
data: {
|
|
453
|
-
apiKey: getSecretUpdateValue('
|
|
483
|
+
apiKey: getSecretUpdateValue('jevApiKey'),
|
|
484
|
+
apiEndpoint: drafts.jevApiEndpoint,
|
|
485
|
+
model: drafts.jevModel.trim(),
|
|
454
486
|
},
|
|
455
487
|
}
|
|
456
|
-
: section === '
|
|
488
|
+
: section === 'coinalyze'
|
|
457
489
|
? {
|
|
458
490
|
section,
|
|
459
491
|
data: {
|
|
460
|
-
apiKey: getSecretUpdateValue('
|
|
461
|
-
apiEndpoint: drafts.aiApiEndpoint,
|
|
462
|
-
model: drafts.aiModel.trim(),
|
|
463
|
-
responseLanguage: drafts.aiResponseLanguage,
|
|
492
|
+
apiKey: getSecretUpdateValue('coinalyzeApiKey'),
|
|
464
493
|
},
|
|
465
494
|
}
|
|
466
|
-
: section === '
|
|
495
|
+
: section === 'ai'
|
|
467
496
|
? {
|
|
468
497
|
section,
|
|
469
498
|
data: {
|
|
470
|
-
apiKey: getSecretUpdateValue('
|
|
499
|
+
apiKey: getSecretUpdateValue('aiApiKey'),
|
|
500
|
+
apiEndpoint: drafts.aiApiEndpoint,
|
|
501
|
+
model: drafts.aiModel.trim(),
|
|
502
|
+
responseLanguage: drafts.aiResponseLanguage,
|
|
471
503
|
},
|
|
472
504
|
}
|
|
473
|
-
: section === '
|
|
505
|
+
: section === 'coinmarketcap'
|
|
474
506
|
? {
|
|
475
507
|
section,
|
|
476
508
|
data: {
|
|
477
|
-
|
|
478
|
-
chatId:
|
|
479
|
-
drafts.tgChatId !== settings.tgChatId
|
|
480
|
-
? drafts.tgChatId.trim()
|
|
481
|
-
: undefined,
|
|
509
|
+
apiKey: getSecretUpdateValue('coinmarketcapApiKey'),
|
|
482
510
|
},
|
|
483
511
|
}
|
|
484
|
-
:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
512
|
+
: section === 'telegram'
|
|
513
|
+
? {
|
|
514
|
+
section,
|
|
515
|
+
data: {
|
|
516
|
+
botToken: getSecretUpdateValue('tgBotToken'),
|
|
517
|
+
chatId:
|
|
518
|
+
drafts.tgChatId !== settings.tgChatId
|
|
519
|
+
? drafts.tgChatId.trim()
|
|
520
|
+
: undefined,
|
|
521
|
+
},
|
|
522
|
+
}
|
|
523
|
+
: {
|
|
524
|
+
section,
|
|
525
|
+
data: {
|
|
526
|
+
password: passwords.password,
|
|
527
|
+
confirmPassword: passwords.confirmPassword,
|
|
528
|
+
},
|
|
529
|
+
};
|
|
491
530
|
|
|
492
531
|
const response = await fetch('/api/user/settings', {
|
|
493
532
|
method: 'PATCH',
|
|
@@ -639,21 +678,11 @@ export const AccountSettingsDrawer = () => {
|
|
|
639
678
|
<Portal>
|
|
640
679
|
<Drawer.Backdrop />
|
|
641
680
|
<Drawer.Positioner>
|
|
642
|
-
<
|
|
643
|
-
<Drawer.
|
|
644
|
-
<Drawer.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
</Drawer.CloseTrigger>
|
|
648
|
-
</Drawer.Header>
|
|
649
|
-
|
|
650
|
-
<Drawer.Body overflowY="auto">
|
|
651
|
-
{loading ? (
|
|
652
|
-
<Flex minH="320px" align="center" justify="center">
|
|
653
|
-
<Spinner color="teal.300" size="lg" />
|
|
654
|
-
</Flex>
|
|
655
|
-
) : (
|
|
656
|
-
<Stack gap={5}>
|
|
681
|
+
<Tabs.Root defaultValue="integrations" colorPalette="teal" asChild>
|
|
682
|
+
<Drawer.Content display="flex" flexDirection="column">
|
|
683
|
+
<Drawer.Header display="block">
|
|
684
|
+
<Flex align="center" gap={3} wrap="wrap" pr={8}>
|
|
685
|
+
<Drawer.Title>Account settings</Drawer.Title>
|
|
657
686
|
<Text fontSize="sm" color="gray.400">
|
|
658
687
|
Signed in as{' '}
|
|
659
688
|
<Text
|
|
@@ -665,400 +694,528 @@ export const AccountSettingsDrawer = () => {
|
|
|
665
694
|
{settings.userName || 'Unknown user'}
|
|
666
695
|
</Text>
|
|
667
696
|
</Text>
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
>
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
<
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
Save
|
|
699
|
-
</Button>
|
|
700
|
-
</Flex>
|
|
701
|
-
</Stack>
|
|
702
|
-
</Box>
|
|
703
|
-
|
|
704
|
-
<Box
|
|
705
|
-
borderWidth="1px"
|
|
706
|
-
borderColor="gray.700"
|
|
707
|
-
borderRadius="lg"
|
|
708
|
-
p={4}
|
|
709
|
-
bg="gray.900"
|
|
710
|
-
>
|
|
711
|
-
<Stack gap={4}>
|
|
712
|
-
<Box>
|
|
713
|
-
<Text fontWeight="600">Jev</Text>
|
|
714
|
-
<Text fontSize="sm" color="gray.400">
|
|
715
|
-
Evaluate signal context and geometry in backtests and
|
|
716
|
-
runtime.
|
|
717
|
-
</Text>
|
|
718
|
-
</Box>
|
|
719
|
-
<Field.Root>
|
|
720
|
-
<Field.Label>Jev provider</Field.Label>
|
|
721
|
-
<select
|
|
722
|
-
aria-label="Jev provider"
|
|
723
|
-
style={DRAWER_SELECT_STYLE}
|
|
724
|
-
value={
|
|
725
|
-
JEV_PROVIDERS.find(
|
|
726
|
-
(item) => item.endpoint === drafts.jevApiEndpoint,
|
|
727
|
-
)?.endpoint ?? 'custom'
|
|
728
|
-
}
|
|
729
|
-
onChange={(event) => {
|
|
730
|
-
const provider = JEV_PROVIDERS.find(
|
|
731
|
-
(item) => item.endpoint === event.target.value,
|
|
732
|
-
);
|
|
733
|
-
setDrafts((current) => ({
|
|
734
|
-
...current,
|
|
735
|
-
jevApiEndpoint: provider?.endpoint ?? '',
|
|
736
|
-
jevModel: provider?.model ?? '',
|
|
737
|
-
}));
|
|
738
|
-
}}
|
|
697
|
+
</Flex>
|
|
698
|
+
<Drawer.CloseTrigger asChild>
|
|
699
|
+
<CloseButton position="absolute" right="3" top="3" />
|
|
700
|
+
</Drawer.CloseTrigger>
|
|
701
|
+
<Tabs.List mt={4}>
|
|
702
|
+
<Tabs.Trigger value="integrations">Integrations</Tabs.Trigger>
|
|
703
|
+
<Tabs.Trigger value="notifications">
|
|
704
|
+
Notifications
|
|
705
|
+
</Tabs.Trigger>
|
|
706
|
+
<Tabs.Trigger value="security">Security</Tabs.Trigger>
|
|
707
|
+
</Tabs.List>
|
|
708
|
+
</Drawer.Header>
|
|
709
|
+
|
|
710
|
+
<Drawer.Body overflowY="auto">
|
|
711
|
+
{loading ? (
|
|
712
|
+
<Flex minH="320px" align="center" justify="center">
|
|
713
|
+
<Spinner color="teal.300" size="lg" />
|
|
714
|
+
</Flex>
|
|
715
|
+
) : (
|
|
716
|
+
<Box>
|
|
717
|
+
<Tabs.Content value="integrations" pt={0}>
|
|
718
|
+
<Stack gap={5}>
|
|
719
|
+
<TradingAccountsPanel />
|
|
720
|
+
|
|
721
|
+
<Box
|
|
722
|
+
borderWidth="1px"
|
|
723
|
+
borderColor="gray.700"
|
|
724
|
+
borderRadius="lg"
|
|
725
|
+
p={4}
|
|
726
|
+
bg="gray.900"
|
|
739
727
|
>
|
|
740
|
-
{
|
|
741
|
-
<
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
</
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
updateDraft('jevModel', event.target.value)
|
|
773
|
-
}
|
|
774
|
-
/>
|
|
775
|
-
</Field.Root>
|
|
776
|
-
{renderEditableField({
|
|
777
|
-
label: 'JEV_API_KEY',
|
|
778
|
-
field: 'jevApiKey',
|
|
779
|
-
placeholder: 'Enter a new Jev provider API key',
|
|
780
|
-
})}
|
|
781
|
-
<Flex justify="flex-end">
|
|
782
|
-
<Button
|
|
783
|
-
colorPalette="teal"
|
|
784
|
-
loading={savingSection === 'jev'}
|
|
785
|
-
disabled={!isSectionDirty('jev')}
|
|
786
|
-
onClick={() => saveSection('jev')}
|
|
728
|
+
<Stack gap={4}>
|
|
729
|
+
<Box>
|
|
730
|
+
<Text fontWeight="600">CoinMarketCap</Text>
|
|
731
|
+
<Text fontSize="sm" color="gray.400">
|
|
732
|
+
API key stored in the user profile for
|
|
733
|
+
historical global market context ingestion.
|
|
734
|
+
</Text>
|
|
735
|
+
</Box>
|
|
736
|
+
{renderEditableField({
|
|
737
|
+
label: 'COINMARKETCAP_API_KEY',
|
|
738
|
+
field: 'coinmarketcapApiKey',
|
|
739
|
+
placeholder: 'Enter a new CoinMarketCap API key',
|
|
740
|
+
})}
|
|
741
|
+
<Flex justify="flex-end">
|
|
742
|
+
<Button
|
|
743
|
+
colorPalette="teal"
|
|
744
|
+
loading={savingSection === 'coinmarketcap'}
|
|
745
|
+
disabled={!isSectionDirty('coinmarketcap')}
|
|
746
|
+
onClick={() => saveSection('coinmarketcap')}
|
|
747
|
+
>
|
|
748
|
+
Save
|
|
749
|
+
</Button>
|
|
750
|
+
</Flex>
|
|
751
|
+
</Stack>
|
|
752
|
+
</Box>
|
|
753
|
+
|
|
754
|
+
<Box
|
|
755
|
+
borderWidth="1px"
|
|
756
|
+
borderColor="gray.700"
|
|
757
|
+
borderRadius="lg"
|
|
758
|
+
p={4}
|
|
759
|
+
bg="gray.900"
|
|
787
760
|
>
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
selectedAiModelOption === AI_CUSTOM_MODEL_VALUE ? (
|
|
885
|
-
<Field.Root>
|
|
886
|
-
<Field.Label>Custom AI model name</Field.Label>
|
|
887
|
-
<Input
|
|
888
|
-
value={drafts.aiModel}
|
|
889
|
-
placeholder="Enter an OpenAI-compatible model name"
|
|
890
|
-
onChange={(event) =>
|
|
891
|
-
updateDraft('aiModel', event.target.value)
|
|
892
|
-
}
|
|
893
|
-
/>
|
|
894
|
-
</Field.Root>
|
|
895
|
-
) : null}
|
|
896
|
-
<Field.Root>
|
|
897
|
-
<Field.Label>AI_RESPONSE_LANGUAGE</Field.Label>
|
|
898
|
-
<select
|
|
899
|
-
value={drafts.aiResponseLanguage}
|
|
900
|
-
onChange={(event) =>
|
|
901
|
-
updateDraft(
|
|
902
|
-
'aiResponseLanguage',
|
|
903
|
-
event.target.value,
|
|
904
|
-
)
|
|
905
|
-
}
|
|
906
|
-
style={DRAWER_SELECT_STYLE}
|
|
761
|
+
<Stack gap={4}>
|
|
762
|
+
<Box>
|
|
763
|
+
<Text fontWeight="600">Jev</Text>
|
|
764
|
+
<Text fontSize="sm" color="gray.400">
|
|
765
|
+
Evaluate signal context and geometry in
|
|
766
|
+
backtests and runtime.
|
|
767
|
+
</Text>
|
|
768
|
+
</Box>
|
|
769
|
+
<Field.Root>
|
|
770
|
+
<Field.Label>Jev provider</Field.Label>
|
|
771
|
+
<select
|
|
772
|
+
aria-label="Jev provider"
|
|
773
|
+
style={DRAWER_SELECT_STYLE}
|
|
774
|
+
value={
|
|
775
|
+
JEV_PROVIDERS.find(
|
|
776
|
+
(item) =>
|
|
777
|
+
item.endpoint === drafts.jevApiEndpoint,
|
|
778
|
+
)?.endpoint ?? 'custom'
|
|
779
|
+
}
|
|
780
|
+
onChange={(event) => {
|
|
781
|
+
const provider = JEV_PROVIDERS.find(
|
|
782
|
+
(item) =>
|
|
783
|
+
item.endpoint === event.target.value,
|
|
784
|
+
);
|
|
785
|
+
setDrafts((current) => ({
|
|
786
|
+
...current,
|
|
787
|
+
jevApiEndpoint: provider?.endpoint ?? '',
|
|
788
|
+
jevModel: provider?.model ?? '',
|
|
789
|
+
}));
|
|
790
|
+
}}
|
|
791
|
+
>
|
|
792
|
+
{JEV_PROVIDERS.map((provider) => (
|
|
793
|
+
<option
|
|
794
|
+
key={provider.endpoint}
|
|
795
|
+
value={provider.endpoint}
|
|
796
|
+
>
|
|
797
|
+
{provider.label}
|
|
798
|
+
</option>
|
|
799
|
+
))}
|
|
800
|
+
<option value="custom">
|
|
801
|
+
Custom Decisions API
|
|
802
|
+
</option>
|
|
803
|
+
</select>
|
|
804
|
+
</Field.Root>
|
|
805
|
+
<Field.Root>
|
|
806
|
+
<Field.Label>JEV_API_ENDPOINT</Field.Label>
|
|
807
|
+
<Input
|
|
808
|
+
aria-label="JEV_API_ENDPOINT"
|
|
809
|
+
value={drafts.jevApiEndpoint}
|
|
810
|
+
onChange={(event) =>
|
|
811
|
+
updateDraft(
|
|
812
|
+
'jevApiEndpoint',
|
|
813
|
+
event.target.value,
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
placeholder="Full URL of the decision endpoint"
|
|
817
|
+
/>
|
|
818
|
+
<Text fontSize="sm" color="gray.400">
|
|
819
|
+
Use a TypeSafe-compatible decision endpoint
|
|
820
|
+
accepting state and questions.
|
|
821
|
+
</Text>
|
|
822
|
+
</Field.Root>
|
|
823
|
+
<Field.Root>
|
|
824
|
+
<Field.Label>JEV_MODEL</Field.Label>
|
|
825
|
+
<Input
|
|
826
|
+
aria-label="JEV_MODEL"
|
|
827
|
+
value={drafts.jevModel}
|
|
828
|
+
onChange={(event) =>
|
|
829
|
+
updateDraft('jevModel', event.target.value)
|
|
830
|
+
}
|
|
831
|
+
/>
|
|
832
|
+
</Field.Root>
|
|
833
|
+
{renderEditableField({
|
|
834
|
+
label: 'JEV_API_KEY',
|
|
835
|
+
field: 'jevApiKey',
|
|
836
|
+
placeholder: 'Enter a new Jev provider API key',
|
|
837
|
+
})}
|
|
838
|
+
<Flex justify="flex-end">
|
|
839
|
+
<Button
|
|
840
|
+
colorPalette="teal"
|
|
841
|
+
loading={savingSection === 'jev'}
|
|
842
|
+
disabled={!isSectionDirty('jev')}
|
|
843
|
+
onClick={() => saveSection('jev')}
|
|
844
|
+
>
|
|
845
|
+
Save
|
|
846
|
+
</Button>
|
|
847
|
+
</Flex>
|
|
848
|
+
</Stack>
|
|
849
|
+
</Box>
|
|
850
|
+
|
|
851
|
+
<Box
|
|
852
|
+
borderWidth="1px"
|
|
853
|
+
borderColor="gray.700"
|
|
854
|
+
borderRadius="lg"
|
|
855
|
+
p={4}
|
|
856
|
+
bg="gray.900"
|
|
907
857
|
>
|
|
908
|
-
{
|
|
909
|
-
<
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
858
|
+
<Stack gap={4}>
|
|
859
|
+
<Box>
|
|
860
|
+
<Text fontWeight="600">AI / LLM</Text>
|
|
861
|
+
<Text fontSize="sm" color="gray.400">
|
|
862
|
+
Stored in the user profile and used for AI
|
|
863
|
+
analysis and user-facing AI replies.
|
|
864
|
+
</Text>
|
|
865
|
+
</Box>
|
|
866
|
+
<Field.Root>
|
|
867
|
+
<Field.Label>AI_API_ENDPOINT</Field.Label>
|
|
868
|
+
<select
|
|
869
|
+
value={selectedAiEndpointOption}
|
|
870
|
+
onChange={(event) => {
|
|
871
|
+
const nextValue = event.target.value;
|
|
872
|
+
setDrafts((current) => ({
|
|
873
|
+
...current,
|
|
874
|
+
aiApiEndpoint:
|
|
875
|
+
nextValue === AI_CUSTOM_ENDPOINT_VALUE
|
|
876
|
+
? ''
|
|
877
|
+
: nextValue,
|
|
878
|
+
aiModel:
|
|
879
|
+
nextValue === AI_CUSTOM_ENDPOINT_VALUE
|
|
880
|
+
? ''
|
|
881
|
+
: normalizeAiModel('', nextValue),
|
|
882
|
+
}));
|
|
883
|
+
}}
|
|
884
|
+
style={DRAWER_SELECT_STYLE}
|
|
885
|
+
>
|
|
886
|
+
{AI_ENDPOINT_OPTIONS.map((option) => (
|
|
887
|
+
<option
|
|
888
|
+
key={option.value}
|
|
889
|
+
value={option.value}
|
|
890
|
+
>
|
|
891
|
+
{option.label}
|
|
892
|
+
</option>
|
|
893
|
+
))}
|
|
894
|
+
</select>
|
|
895
|
+
<Text mt="2" fontSize="sm" color="gray.400">
|
|
896
|
+
{effectiveAiEndpoint ||
|
|
897
|
+
'Endpoint is not set yet.'}
|
|
898
|
+
</Text>
|
|
899
|
+
</Field.Root>
|
|
900
|
+
{selectedAiEndpointOption ===
|
|
901
|
+
AI_CUSTOM_ENDPOINT_VALUE ? (
|
|
902
|
+
<Field.Root>
|
|
903
|
+
<Field.Label>
|
|
904
|
+
Custom AI API endpoint URL
|
|
905
|
+
</Field.Label>
|
|
906
|
+
<Input
|
|
907
|
+
value={drafts.aiApiEndpoint}
|
|
908
|
+
placeholder="https://your-openai-compatible-endpoint/v1"
|
|
909
|
+
onChange={(event) =>
|
|
910
|
+
updateDraft(
|
|
911
|
+
'aiApiEndpoint',
|
|
912
|
+
event.target.value,
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
/>
|
|
916
|
+
</Field.Root>
|
|
917
|
+
) : null}
|
|
918
|
+
{renderEditableField({
|
|
919
|
+
label: 'AI_API_KEY',
|
|
920
|
+
field: 'aiApiKey',
|
|
921
|
+
placeholder: 'Enter a new AI API key',
|
|
922
|
+
})}
|
|
923
|
+
{hasPresetAiModels ? (
|
|
924
|
+
<Field.Root>
|
|
925
|
+
<Field.Label>AI_MODEL</Field.Label>
|
|
926
|
+
<select
|
|
927
|
+
value={selectedAiModelOption}
|
|
928
|
+
onChange={(event) => {
|
|
929
|
+
const nextValue = event.target.value;
|
|
930
|
+
if (nextValue === AI_CUSTOM_MODEL_VALUE) {
|
|
931
|
+
updateDraft('aiModel', '');
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
updateDraft('aiModel', nextValue);
|
|
936
|
+
}}
|
|
937
|
+
style={DRAWER_SELECT_STYLE}
|
|
938
|
+
>
|
|
939
|
+
{aiModelOptions.map((option) => (
|
|
940
|
+
<option
|
|
941
|
+
key={option.value}
|
|
942
|
+
value={option.value}
|
|
943
|
+
>
|
|
944
|
+
{option.label}
|
|
945
|
+
</option>
|
|
946
|
+
))}
|
|
947
|
+
<option value={AI_CUSTOM_MODEL_VALUE}>
|
|
948
|
+
Custom model
|
|
949
|
+
</option>
|
|
950
|
+
</select>
|
|
951
|
+
</Field.Root>
|
|
952
|
+
) : null}
|
|
953
|
+
{!hasPresetAiModels ||
|
|
954
|
+
selectedAiModelOption === AI_CUSTOM_MODEL_VALUE ? (
|
|
955
|
+
<Field.Root>
|
|
956
|
+
<Field.Label>Custom AI model name</Field.Label>
|
|
957
|
+
<Input
|
|
958
|
+
value={drafts.aiModel}
|
|
959
|
+
placeholder="Enter an OpenAI-compatible model name"
|
|
960
|
+
onChange={(event) =>
|
|
961
|
+
updateDraft('aiModel', event.target.value)
|
|
962
|
+
}
|
|
963
|
+
/>
|
|
964
|
+
</Field.Root>
|
|
965
|
+
) : null}
|
|
966
|
+
<Field.Root>
|
|
967
|
+
<Field.Label>AI_RESPONSE_LANGUAGE</Field.Label>
|
|
968
|
+
<select
|
|
969
|
+
value={drafts.aiResponseLanguage}
|
|
970
|
+
onChange={(event) =>
|
|
971
|
+
updateDraft(
|
|
972
|
+
'aiResponseLanguage',
|
|
973
|
+
event.target.value,
|
|
974
|
+
)
|
|
975
|
+
}
|
|
976
|
+
style={DRAWER_SELECT_STYLE}
|
|
977
|
+
>
|
|
978
|
+
{AI_RESPONSE_LANGUAGE_OPTIONS.map((option) => (
|
|
979
|
+
<option
|
|
980
|
+
key={option.value}
|
|
981
|
+
value={option.value}
|
|
982
|
+
>
|
|
983
|
+
{option.label}
|
|
984
|
+
</option>
|
|
985
|
+
))}
|
|
986
|
+
</select>
|
|
987
|
+
</Field.Root>
|
|
988
|
+
<Flex justify="flex-end">
|
|
989
|
+
<Button
|
|
990
|
+
colorPalette="teal"
|
|
991
|
+
loading={savingSection === 'ai'}
|
|
992
|
+
disabled={!isSectionDirty('ai')}
|
|
993
|
+
onClick={() => saveSection('ai')}
|
|
994
|
+
>
|
|
995
|
+
Save
|
|
996
|
+
</Button>
|
|
997
|
+
</Flex>
|
|
998
|
+
</Stack>
|
|
999
|
+
</Box>
|
|
1000
|
+
|
|
1001
|
+
<Box
|
|
1002
|
+
borderWidth="1px"
|
|
1003
|
+
borderColor="gray.700"
|
|
1004
|
+
borderRadius="lg"
|
|
1005
|
+
p={4}
|
|
1006
|
+
bg="gray.900"
|
|
921
1007
|
>
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1008
|
+
<Stack gap={4}>
|
|
1009
|
+
<Box>
|
|
1010
|
+
<Text fontWeight="600">Coinalyze</Text>
|
|
1011
|
+
<Text fontSize="sm" color="gray.400">
|
|
1012
|
+
API key stored in the user profile for
|
|
1013
|
+
derivatives data ingestion.
|
|
1014
|
+
</Text>
|
|
1015
|
+
</Box>
|
|
1016
|
+
{renderEditableField({
|
|
1017
|
+
label: 'COINALYZE_API_KEY',
|
|
1018
|
+
field: 'coinalyzeApiKey',
|
|
1019
|
+
placeholder: 'Enter a new Coinalyze API key',
|
|
1020
|
+
})}
|
|
1021
|
+
<Flex justify="flex-end">
|
|
1022
|
+
<Button
|
|
1023
|
+
colorPalette="teal"
|
|
1024
|
+
loading={savingSection === 'coinalyze'}
|
|
1025
|
+
disabled={!isSectionDirty('coinalyze')}
|
|
1026
|
+
onClick={() => saveSection('coinalyze')}
|
|
1027
|
+
>
|
|
1028
|
+
Save
|
|
1029
|
+
</Button>
|
|
1030
|
+
</Flex>
|
|
1031
|
+
</Stack>
|
|
1032
|
+
</Box>
|
|
1033
|
+
</Stack>
|
|
1034
|
+
</Tabs.Content>
|
|
1035
|
+
<Tabs.Content value="notifications" pt={0}>
|
|
1036
|
+
<Stack gap={5}>
|
|
1037
|
+
<Box
|
|
1038
|
+
borderWidth="1px"
|
|
1039
|
+
borderColor="gray.700"
|
|
1040
|
+
borderRadius="lg"
|
|
1041
|
+
p={4}
|
|
1042
|
+
bg="gray.900"
|
|
954
1043
|
>
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1044
|
+
<Stack gap={4}>
|
|
1045
|
+
<Box>
|
|
1046
|
+
<Text fontWeight="600">Telegram</Text>
|
|
1047
|
+
<Text fontSize="sm" color="gray.400">
|
|
1048
|
+
Bot credentials used for signal delivery.
|
|
1049
|
+
</Text>
|
|
1050
|
+
</Box>
|
|
1051
|
+
{renderEditableField({
|
|
1052
|
+
label: 'TG_BOT_TOKEN',
|
|
1053
|
+
field: 'tgBotToken',
|
|
1054
|
+
placeholder: 'Enter a new Telegram bot token',
|
|
1055
|
+
})}
|
|
1056
|
+
{renderEditableField({
|
|
1057
|
+
label: 'TG_CHAT_ID',
|
|
1058
|
+
field: 'tgChatId',
|
|
1059
|
+
placeholder: 'Enter Telegram chat ID',
|
|
1060
|
+
})}
|
|
1061
|
+
<Flex justify="flex-end">
|
|
1062
|
+
<Button
|
|
1063
|
+
colorPalette="teal"
|
|
1064
|
+
loading={savingSection === 'telegram'}
|
|
1065
|
+
disabled={!isSectionDirty('telegram')}
|
|
1066
|
+
onClick={() => saveSection('telegram')}
|
|
1067
|
+
>
|
|
1068
|
+
Save
|
|
1069
|
+
</Button>
|
|
1070
|
+
</Flex>
|
|
1071
|
+
</Stack>
|
|
1072
|
+
</Box>
|
|
1073
|
+
|
|
1074
|
+
<Box
|
|
1075
|
+
borderWidth="1px"
|
|
1076
|
+
borderColor="gray.700"
|
|
1077
|
+
borderRadius="lg"
|
|
1078
|
+
p={4}
|
|
1079
|
+
bg="gray.900"
|
|
991
1080
|
>
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1081
|
+
<Stack gap={4}>
|
|
1082
|
+
<Box>
|
|
1083
|
+
<Text fontWeight="600">Notification types</Text>
|
|
1084
|
+
<Text fontSize="sm" color="gray.400">
|
|
1085
|
+
Choose which notifications Telegram delivers.
|
|
1086
|
+
</Text>
|
|
1087
|
+
</Box>
|
|
1088
|
+
{(
|
|
1089
|
+
[
|
|
1090
|
+
[
|
|
1091
|
+
'signals',
|
|
1092
|
+
'Signals',
|
|
1093
|
+
'Trading signals and order status.',
|
|
1094
|
+
],
|
|
1095
|
+
[
|
|
1096
|
+
'aiAnalysis',
|
|
1097
|
+
'AI signals analyze',
|
|
1098
|
+
'AI analysis messages for signals.',
|
|
1099
|
+
],
|
|
1100
|
+
[
|
|
1101
|
+
'runtimeDebug',
|
|
1102
|
+
'Runtime debug',
|
|
1103
|
+
'TXT and JSON report attachments, including debug and parity.',
|
|
1104
|
+
],
|
|
1105
|
+
] as const
|
|
1106
|
+
).map(([key, label, description]) => (
|
|
1107
|
+
<Switch.Root
|
|
1108
|
+
key={key}
|
|
1109
|
+
checked={notificationDrafts[key]}
|
|
1110
|
+
onCheckedChange={({ checked }) =>
|
|
1111
|
+
setNotificationDrafts((current) => ({
|
|
1112
|
+
...current,
|
|
1113
|
+
[key]: checked,
|
|
1114
|
+
}))
|
|
1115
|
+
}
|
|
1116
|
+
display="flex"
|
|
1117
|
+
width="full"
|
|
1118
|
+
justifyContent="space-between"
|
|
1119
|
+
colorPalette="teal"
|
|
1120
|
+
>
|
|
1121
|
+
<Switch.HiddenInput />
|
|
1122
|
+
<Switch.Label>
|
|
1123
|
+
<Text>{label}</Text>
|
|
1124
|
+
<Text fontSize="sm" color="gray.400">
|
|
1125
|
+
{description}
|
|
1126
|
+
</Text>
|
|
1127
|
+
</Switch.Label>
|
|
1128
|
+
<Switch.Control flexShrink={0}>
|
|
1129
|
+
<Switch.Thumb />
|
|
1130
|
+
</Switch.Control>
|
|
1131
|
+
</Switch.Root>
|
|
1132
|
+
))}
|
|
1133
|
+
<Flex justify="flex-end">
|
|
1134
|
+
<Button
|
|
1135
|
+
colorPalette="teal"
|
|
1136
|
+
loading={savingSection === 'notifications'}
|
|
1137
|
+
disabled={
|
|
1138
|
+
!isSectionDirty('notifications') ||
|
|
1139
|
+
savingSection !== null
|
|
1140
|
+
}
|
|
1141
|
+
onClick={() => saveSection('notifications')}
|
|
1142
|
+
>
|
|
1143
|
+
Save
|
|
1144
|
+
</Button>
|
|
1145
|
+
</Flex>
|
|
1146
|
+
</Stack>
|
|
1147
|
+
</Box>
|
|
1148
|
+
</Stack>
|
|
1149
|
+
</Tabs.Content>
|
|
1150
|
+
<Tabs.Content value="security" pt={0}>
|
|
1151
|
+
<Stack gap={5}>
|
|
1152
|
+
<Box
|
|
1153
|
+
borderWidth="1px"
|
|
1154
|
+
borderColor="gray.700"
|
|
1155
|
+
borderRadius="lg"
|
|
1156
|
+
p={4}
|
|
1157
|
+
bg="gray.900"
|
|
1052
1158
|
>
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1159
|
+
<Stack gap={4}>
|
|
1160
|
+
<Box>
|
|
1161
|
+
<Text fontWeight="600">Password</Text>
|
|
1162
|
+
<Text fontSize="sm" color="gray.400">
|
|
1163
|
+
Update the password used for sign in.
|
|
1164
|
+
</Text>
|
|
1165
|
+
</Box>
|
|
1166
|
+
<Field.Root>
|
|
1167
|
+
<Field.Label>Password</Field.Label>
|
|
1168
|
+
<Input
|
|
1169
|
+
type="password"
|
|
1170
|
+
value={passwords.password}
|
|
1171
|
+
onChange={(event) =>
|
|
1172
|
+
setPasswords((current) => ({
|
|
1173
|
+
...current,
|
|
1174
|
+
password: event.target.value,
|
|
1175
|
+
}))
|
|
1176
|
+
}
|
|
1177
|
+
/>
|
|
1178
|
+
</Field.Root>
|
|
1179
|
+
<Field.Root>
|
|
1180
|
+
<Field.Label>Confirm password</Field.Label>
|
|
1181
|
+
<Input
|
|
1182
|
+
type="password"
|
|
1183
|
+
value={passwords.confirmPassword}
|
|
1184
|
+
onChange={(event) =>
|
|
1185
|
+
setPasswords((current) => ({
|
|
1186
|
+
...current,
|
|
1187
|
+
confirmPassword: event.target.value,
|
|
1188
|
+
}))
|
|
1189
|
+
}
|
|
1190
|
+
/>
|
|
1191
|
+
</Field.Root>
|
|
1192
|
+
{passwordError ? (
|
|
1193
|
+
<Text fontSize="sm" color="red.300">
|
|
1194
|
+
{passwordError}
|
|
1195
|
+
</Text>
|
|
1196
|
+
) : null}
|
|
1197
|
+
<Flex justify="flex-end">
|
|
1198
|
+
<Button
|
|
1199
|
+
colorPalette="teal"
|
|
1200
|
+
loading={savingSection === 'password'}
|
|
1201
|
+
disabled={
|
|
1202
|
+
!isSectionDirty('password') ||
|
|
1203
|
+
Boolean(passwordError)
|
|
1204
|
+
}
|
|
1205
|
+
onClick={() => saveSection('password')}
|
|
1206
|
+
>
|
|
1207
|
+
Save
|
|
1208
|
+
</Button>
|
|
1209
|
+
</Flex>
|
|
1210
|
+
</Stack>
|
|
1211
|
+
</Box>
|
|
1212
|
+
</Stack>
|
|
1213
|
+
</Tabs.Content>
|
|
1057
1214
|
</Box>
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
</Drawer.
|
|
1061
|
-
</
|
|
1215
|
+
)}
|
|
1216
|
+
</Drawer.Body>
|
|
1217
|
+
</Drawer.Content>
|
|
1218
|
+
</Tabs.Root>
|
|
1062
1219
|
</Drawer.Positioner>
|
|
1063
1220
|
</Portal>
|
|
1064
1221
|
</Drawer.Root>
|