@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
- [drafts, passwords.confirmPassword, passwords.password, settings],
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 === 'jev'
441
- ? {
442
- section,
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('coinalyzeApiKey'),
483
+ apiKey: getSecretUpdateValue('jevApiKey'),
484
+ apiEndpoint: drafts.jevApiEndpoint,
485
+ model: drafts.jevModel.trim(),
454
486
  },
455
487
  }
456
- : section === 'ai'
488
+ : section === 'coinalyze'
457
489
  ? {
458
490
  section,
459
491
  data: {
460
- apiKey: getSecretUpdateValue('aiApiKey'),
461
- apiEndpoint: drafts.aiApiEndpoint,
462
- model: drafts.aiModel.trim(),
463
- responseLanguage: drafts.aiResponseLanguage,
492
+ apiKey: getSecretUpdateValue('coinalyzeApiKey'),
464
493
  },
465
494
  }
466
- : section === 'coinmarketcap'
495
+ : section === 'ai'
467
496
  ? {
468
497
  section,
469
498
  data: {
470
- apiKey: getSecretUpdateValue('coinmarketcapApiKey'),
499
+ apiKey: getSecretUpdateValue('aiApiKey'),
500
+ apiEndpoint: drafts.aiApiEndpoint,
501
+ model: drafts.aiModel.trim(),
502
+ responseLanguage: drafts.aiResponseLanguage,
471
503
  },
472
504
  }
473
- : section === 'telegram'
505
+ : section === 'coinmarketcap'
474
506
  ? {
475
507
  section,
476
508
  data: {
477
- botToken: getSecretUpdateValue('tgBotToken'),
478
- chatId:
479
- drafts.tgChatId !== settings.tgChatId
480
- ? drafts.tgChatId.trim()
481
- : undefined,
509
+ apiKey: getSecretUpdateValue('coinmarketcapApiKey'),
482
510
  },
483
511
  }
484
- : {
485
- section,
486
- data: {
487
- password: passwords.password,
488
- confirmPassword: passwords.confirmPassword,
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
- <Drawer.Content display="flex" flexDirection="column">
643
- <Drawer.Header>
644
- <Drawer.Title>Account settings</Drawer.Title>
645
- <Drawer.CloseTrigger asChild>
646
- <CloseButton position="absolute" right="3" top="3" />
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
- <TradingAccountsPanel />
670
-
671
- <Box
672
- borderWidth="1px"
673
- borderColor="gray.700"
674
- borderRadius="lg"
675
- p={4}
676
- bg="gray.900"
677
- >
678
- <Stack gap={4}>
679
- <Box>
680
- <Text fontWeight="600">CoinMarketCap</Text>
681
- <Text fontSize="sm" color="gray.400">
682
- API key stored in the user profile for historical
683
- global market context ingestion.
684
- </Text>
685
- </Box>
686
- {renderEditableField({
687
- label: 'COINMARKETCAP_API_KEY',
688
- field: 'coinmarketcapApiKey',
689
- placeholder: 'Enter a new CoinMarketCap API key',
690
- })}
691
- <Flex justify="flex-end">
692
- <Button
693
- colorPalette="teal"
694
- loading={savingSection === 'coinmarketcap'}
695
- disabled={!isSectionDirty('coinmarketcap')}
696
- onClick={() => saveSection('coinmarketcap')}
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
- {JEV_PROVIDERS.map((provider) => (
741
- <option
742
- key={provider.endpoint}
743
- value={provider.endpoint}
744
- >
745
- {provider.label}
746
- </option>
747
- ))}
748
- <option value="custom">Custom Decisions API</option>
749
- </select>
750
- </Field.Root>
751
- <Field.Root>
752
- <Field.Label>JEV_API_ENDPOINT</Field.Label>
753
- <Input
754
- aria-label="JEV_API_ENDPOINT"
755
- value={drafts.jevApiEndpoint}
756
- onChange={(event) =>
757
- updateDraft('jevApiEndpoint', event.target.value)
758
- }
759
- placeholder="Full URL of the decision endpoint"
760
- />
761
- <Text fontSize="sm" color="gray.400">
762
- Use a TypeSafe-compatible decision endpoint accepting
763
- state and questions.
764
- </Text>
765
- </Field.Root>
766
- <Field.Root>
767
- <Field.Label>JEV_MODEL</Field.Label>
768
- <Input
769
- aria-label="JEV_MODEL"
770
- value={drafts.jevModel}
771
- onChange={(event) =>
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
- Save
789
- </Button>
790
- </Flex>
791
- </Stack>
792
- </Box>
793
-
794
- <Box
795
- borderWidth="1px"
796
- borderColor="gray.700"
797
- borderRadius="lg"
798
- p={4}
799
- bg="gray.900"
800
- >
801
- <Stack gap={4}>
802
- <Box>
803
- <Text fontWeight="600">AI / LLM</Text>
804
- <Text fontSize="sm" color="gray.400">
805
- Stored in the user profile and used for AI analysis
806
- and user-facing AI replies.
807
- </Text>
808
- </Box>
809
- <Field.Root>
810
- <Field.Label>AI_API_ENDPOINT</Field.Label>
811
- <select
812
- value={selectedAiEndpointOption}
813
- onChange={(event) => {
814
- const nextValue = event.target.value;
815
- setDrafts((current) => ({
816
- ...current,
817
- aiApiEndpoint:
818
- nextValue === AI_CUSTOM_ENDPOINT_VALUE
819
- ? ''
820
- : nextValue,
821
- aiModel:
822
- nextValue === AI_CUSTOM_ENDPOINT_VALUE
823
- ? ''
824
- : normalizeAiModel('', nextValue),
825
- }));
826
- }}
827
- style={DRAWER_SELECT_STYLE}
828
- >
829
- {AI_ENDPOINT_OPTIONS.map((option) => (
830
- <option key={option.value} value={option.value}>
831
- {option.label}
832
- </option>
833
- ))}
834
- </select>
835
- <Text mt="2" fontSize="sm" color="gray.400">
836
- {effectiveAiEndpoint || 'Endpoint is not set yet.'}
837
- </Text>
838
- </Field.Root>
839
- {selectedAiEndpointOption === AI_CUSTOM_ENDPOINT_VALUE ? (
840
- <Field.Root>
841
- <Field.Label>Custom AI API endpoint URL</Field.Label>
842
- <Input
843
- value={drafts.aiApiEndpoint}
844
- placeholder="https://your-openai-compatible-endpoint/v1"
845
- onChange={(event) =>
846
- updateDraft('aiApiEndpoint', event.target.value)
847
- }
848
- />
849
- </Field.Root>
850
- ) : null}
851
- {renderEditableField({
852
- label: 'AI_API_KEY',
853
- field: 'aiApiKey',
854
- placeholder: 'Enter a new AI API key',
855
- })}
856
- {hasPresetAiModels ? (
857
- <Field.Root>
858
- <Field.Label>AI_MODEL</Field.Label>
859
- <select
860
- value={selectedAiModelOption}
861
- onChange={(event) => {
862
- const nextValue = event.target.value;
863
- if (nextValue === AI_CUSTOM_MODEL_VALUE) {
864
- updateDraft('aiModel', '');
865
- return;
866
- }
867
-
868
- updateDraft('aiModel', nextValue);
869
- }}
870
- style={DRAWER_SELECT_STYLE}
871
- >
872
- {aiModelOptions.map((option) => (
873
- <option key={option.value} value={option.value}>
874
- {option.label}
875
- </option>
876
- ))}
877
- <option value={AI_CUSTOM_MODEL_VALUE}>
878
- Custom model
879
- </option>
880
- </select>
881
- </Field.Root>
882
- ) : null}
883
- {!hasPresetAiModels ||
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
- {AI_RESPONSE_LANGUAGE_OPTIONS.map((option) => (
909
- <option key={option.value} value={option.value}>
910
- {option.label}
911
- </option>
912
- ))}
913
- </select>
914
- </Field.Root>
915
- <Flex justify="flex-end">
916
- <Button
917
- colorPalette="teal"
918
- loading={savingSection === 'ai'}
919
- disabled={!isSectionDirty('ai')}
920
- onClick={() => saveSection('ai')}
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
- Save
923
- </Button>
924
- </Flex>
925
- </Stack>
926
- </Box>
927
-
928
- <Box
929
- borderWidth="1px"
930
- borderColor="gray.700"
931
- borderRadius="lg"
932
- p={4}
933
- bg="gray.900"
934
- >
935
- <Stack gap={4}>
936
- <Box>
937
- <Text fontWeight="600">Coinalyze</Text>
938
- <Text fontSize="sm" color="gray.400">
939
- API key stored in the user profile for derivatives
940
- data ingestion.
941
- </Text>
942
- </Box>
943
- {renderEditableField({
944
- label: 'COINALYZE_API_KEY',
945
- field: 'coinalyzeApiKey',
946
- placeholder: 'Enter a new Coinalyze API key',
947
- })}
948
- <Flex justify="flex-end">
949
- <Button
950
- colorPalette="teal"
951
- loading={savingSection === 'coinalyze'}
952
- disabled={!isSectionDirty('coinalyze')}
953
- onClick={() => saveSection('coinalyze')}
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
- Save
956
- </Button>
957
- </Flex>
958
- </Stack>
959
- </Box>
960
-
961
- <Box
962
- borderWidth="1px"
963
- borderColor="gray.700"
964
- borderRadius="lg"
965
- p={4}
966
- bg="gray.900"
967
- >
968
- <Stack gap={4}>
969
- <Box>
970
- <Text fontWeight="600">Telegram</Text>
971
- <Text fontSize="sm" color="gray.400">
972
- Bot credentials used for signal delivery.
973
- </Text>
974
- </Box>
975
- {renderEditableField({
976
- label: 'TG_BOT_TOKEN',
977
- field: 'tgBotToken',
978
- placeholder: 'Enter a new Telegram bot token',
979
- })}
980
- {renderEditableField({
981
- label: 'TG_CHAT_ID',
982
- field: 'tgChatId',
983
- placeholder: 'Enter Telegram chat ID',
984
- })}
985
- <Flex justify="flex-end">
986
- <Button
987
- colorPalette="teal"
988
- loading={savingSection === 'telegram'}
989
- disabled={!isSectionDirty('telegram')}
990
- onClick={() => saveSection('telegram')}
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
- Save
993
- </Button>
994
- </Flex>
995
- </Stack>
996
- </Box>
997
-
998
- <Box
999
- borderWidth="1px"
1000
- borderColor="gray.700"
1001
- borderRadius="lg"
1002
- p={4}
1003
- bg="gray.900"
1004
- >
1005
- <Stack gap={4}>
1006
- <Box>
1007
- <Text fontWeight="600">Password</Text>
1008
- <Text fontSize="sm" color="gray.400">
1009
- Update the password used for sign in.
1010
- </Text>
1011
- </Box>
1012
- <Field.Root>
1013
- <Field.Label>Password</Field.Label>
1014
- <Input
1015
- type="password"
1016
- value={passwords.password}
1017
- onChange={(event) =>
1018
- setPasswords((current) => ({
1019
- ...current,
1020
- password: event.target.value,
1021
- }))
1022
- }
1023
- />
1024
- </Field.Root>
1025
- <Field.Root>
1026
- <Field.Label>Confirm password</Field.Label>
1027
- <Input
1028
- type="password"
1029
- value={passwords.confirmPassword}
1030
- onChange={(event) =>
1031
- setPasswords((current) => ({
1032
- ...current,
1033
- confirmPassword: event.target.value,
1034
- }))
1035
- }
1036
- />
1037
- </Field.Root>
1038
- {passwordError ? (
1039
- <Text fontSize="sm" color="red.300">
1040
- {passwordError}
1041
- </Text>
1042
- ) : null}
1043
- <Flex justify="flex-end">
1044
- <Button
1045
- colorPalette="teal"
1046
- loading={savingSection === 'password'}
1047
- disabled={
1048
- !isSectionDirty('password') ||
1049
- Boolean(passwordError)
1050
- }
1051
- onClick={() => saveSection('password')}
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
- Save
1054
- </Button>
1055
- </Flex>
1056
- </Stack>
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
- </Stack>
1059
- )}
1060
- </Drawer.Body>
1061
- </Drawer.Content>
1215
+ )}
1216
+ </Drawer.Body>
1217
+ </Drawer.Content>
1218
+ </Tabs.Root>
1062
1219
  </Drawer.Positioner>
1063
1220
  </Portal>
1064
1221
  </Drawer.Root>