@tradejs/app 1.0.5 → 1.0.8

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 (37) hide show
  1. package/bin/tradejs-app.mjs +129 -20
  2. package/package.json +13 -12
  3. package/public/auth-bg.jpg +0 -0
  4. package/public/next.svg +1 -0
  5. package/public/og-image-source.svg +91 -0
  6. package/public/og-image.png +0 -0
  7. package/public/vercel.svg +1 -0
  8. package/src/app/api/ai/route.ts +84 -20
  9. package/src/app/api/backtest/files/route.ts +12 -1
  10. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +18 -1
  11. package/src/app/api/kline/[provider]/[symbol]/[interval]/route.ts +357 -29
  12. package/src/app/api/scanner/[provider]/route.ts +7 -1
  13. package/src/app/api/scanner/route.ts +7 -1
  14. package/src/app/api/signal/[symbol]/[signalId]/route.ts +6 -0
  15. package/src/app/api/user/settings/route.ts +244 -0
  16. package/src/app/components/Dashboard/AiDrawer/index.tsx +38 -51
  17. package/src/app/components/Shared/Filters/Backtest/index.tsx +12 -5
  18. package/src/app/components/Shared/Filters/Root/index.tsx +12 -1
  19. package/src/app/components/Shared/Filters/Symbol/index.tsx +14 -19
  20. package/src/app/components/Shared/Filters/context.ts +2 -0
  21. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +948 -0
  22. package/src/app/components/Shared/Sidebar/index.tsx +13 -9
  23. package/src/app/components/UI/ColorMode/index.tsx +62 -15
  24. package/src/app/components/UI/Select/index.tsx +3 -0
  25. package/src/app/components/UI/SelectWithSearch/index.tsx +3 -0
  26. package/src/app/globals.css +11 -0
  27. package/src/app/layout.tsx +50 -11
  28. package/src/app/lib/currentUser.ts +27 -0
  29. package/src/app/lib/klineWindow.ts +17 -0
  30. package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +10 -2
  31. package/src/app/routes/signin/page.tsx +11 -2
  32. package/src/app/store/ai.ts +174 -0
  33. package/src/app/store/data.ts +219 -88
  34. package/src/app/store/index.ts +1 -0
  35. package/src/app/store/tests.ts +96 -9
  36. package/src/app/store/tickers.ts +113 -17
  37. package/src/proxy.ts +23 -50
@@ -0,0 +1,948 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import {
5
+ Box,
6
+ Button,
7
+ CloseButton,
8
+ Drawer,
9
+ Field,
10
+ Flex,
11
+ HStack,
12
+ IconButton,
13
+ Input,
14
+ Portal,
15
+ Spinner,
16
+ Stack,
17
+ Text,
18
+ } from '@chakra-ui/react';
19
+ import { FiEdit2, FiSettings } from 'react-icons/fi';
20
+ import {
21
+ AI_RESPONSE_LANGUAGE_OPTIONS,
22
+ normalizeAiResponseLanguage,
23
+ } from '@tradejs/infra/aiLanguages';
24
+ import {
25
+ AI_CUSTOM_ENDPOINT_VALUE,
26
+ AI_ENDPOINT_OPTIONS,
27
+ normalizeAiEndpoint,
28
+ } from '@tradejs/infra/aiEndpoints';
29
+ import {
30
+ AI_CUSTOM_MODEL_VALUE,
31
+ getAiModelOptionsForEndpoint,
32
+ hasPresetAiModelsForEndpoint,
33
+ normalizeAiModel,
34
+ } from '@tradejs/infra/aiModels';
35
+ import { toaster } from '@UI';
36
+
37
+ type SettingsResponse = {
38
+ userName: string;
39
+ settings: {
40
+ bybit: {
41
+ apiKey: string;
42
+ apiSecret: string;
43
+ };
44
+ coinalyze: {
45
+ apiKey: string;
46
+ };
47
+ ai: {
48
+ apiKey: string;
49
+ apiEndpoint: string;
50
+ model: string;
51
+ responseLanguage: string;
52
+ };
53
+ telegram: {
54
+ botToken: string;
55
+ chatId: string;
56
+ };
57
+ };
58
+ };
59
+
60
+ type SettingsErrorResponse = {
61
+ error?: string;
62
+ };
63
+
64
+ type SettingsViewState = {
65
+ userName: string;
66
+ bybitApiKey: string;
67
+ bybitApiSecret: string;
68
+ coinalyzeApiKey: string;
69
+ aiApiKey: string;
70
+ aiApiEndpoint: string;
71
+ aiModel: string;
72
+ aiResponseLanguage: string;
73
+ tgBotToken: string;
74
+ tgChatId: string;
75
+ };
76
+
77
+ type SettingsDraftState = Omit<SettingsViewState, 'userName'>;
78
+
79
+ type PasswordState = {
80
+ password: string;
81
+ confirmPassword: string;
82
+ };
83
+
84
+ type SectionName = 'bybit' | 'password' | 'coinalyze' | 'ai' | 'telegram';
85
+ type EditableField =
86
+ | 'bybitApiKey'
87
+ | 'bybitApiSecret'
88
+ | 'coinalyzeApiKey'
89
+ | 'aiApiKey'
90
+ | 'aiApiEndpoint'
91
+ | 'aiModel'
92
+ | 'aiResponseLanguage'
93
+ | 'tgBotToken'
94
+ | 'tgChatId';
95
+
96
+ const EMPTY_SETTINGS: SettingsViewState = {
97
+ userName: '',
98
+ bybitApiKey: '',
99
+ bybitApiSecret: '',
100
+ coinalyzeApiKey: '',
101
+ aiApiKey: '',
102
+ aiApiEndpoint: '',
103
+ aiModel: '',
104
+ aiResponseLanguage: '',
105
+ tgBotToken: '',
106
+ tgChatId: '',
107
+ };
108
+
109
+ const EMPTY_DRAFTS: SettingsDraftState = {
110
+ bybitApiKey: '',
111
+ bybitApiSecret: '',
112
+ coinalyzeApiKey: '',
113
+ aiApiKey: '',
114
+ aiApiEndpoint: '',
115
+ aiModel: '',
116
+ aiResponseLanguage: '',
117
+ tgBotToken: '',
118
+ tgChatId: '',
119
+ };
120
+
121
+ const EMPTY_PASSWORDS: PasswordState = {
122
+ password: '',
123
+ confirmPassword: '',
124
+ };
125
+
126
+ const EMPTY_EDITING: Record<EditableField, boolean> = {
127
+ bybitApiKey: false,
128
+ bybitApiSecret: false,
129
+ coinalyzeApiKey: false,
130
+ aiApiKey: false,
131
+ aiApiEndpoint: false,
132
+ aiModel: false,
133
+ aiResponseLanguage: false,
134
+ tgBotToken: false,
135
+ tgChatId: false,
136
+ };
137
+
138
+ const SECTION_FIELDS: Record<
139
+ Exclude<SectionName, 'password'>,
140
+ EditableField[]
141
+ > = {
142
+ bybit: ['bybitApiKey', 'bybitApiSecret'],
143
+ coinalyze: ['coinalyzeApiKey'],
144
+ ai: ['aiApiKey', 'aiApiEndpoint', 'aiModel', 'aiResponseLanguage'],
145
+ telegram: ['tgBotToken', 'tgChatId'],
146
+ };
147
+
148
+ const MASKED_FIELDS = new Set<EditableField>([
149
+ 'bybitApiKey',
150
+ 'bybitApiSecret',
151
+ 'coinalyzeApiKey',
152
+ 'aiApiKey',
153
+ 'tgBotToken',
154
+ ]);
155
+
156
+ const toViewState = (payload: SettingsResponse): SettingsViewState => ({
157
+ userName: payload.userName,
158
+ bybitApiKey: payload.settings.bybit.apiKey || '',
159
+ bybitApiSecret: payload.settings.bybit.apiSecret || '',
160
+ coinalyzeApiKey: payload.settings.coinalyze.apiKey || '',
161
+ aiApiKey: payload.settings.ai.apiKey || '',
162
+ aiApiEndpoint:
163
+ normalizeAiEndpoint(payload.settings.ai.apiEndpoint) ||
164
+ AI_ENDPOINT_OPTIONS[0].value,
165
+ aiModel:
166
+ normalizeAiModel(
167
+ payload.settings.ai.model,
168
+ normalizeAiEndpoint(payload.settings.ai.apiEndpoint) ||
169
+ AI_ENDPOINT_OPTIONS[0].value,
170
+ ) ||
171
+ payload.settings.ai.model ||
172
+ '',
173
+ aiResponseLanguage: normalizeAiResponseLanguage(
174
+ payload.settings.ai.responseLanguage,
175
+ ),
176
+ tgBotToken: payload.settings.telegram.botToken || '',
177
+ tgChatId: payload.settings.telegram.chatId || '',
178
+ });
179
+
180
+ const toDraftState = (view: SettingsViewState): SettingsDraftState => ({
181
+ ...EMPTY_DRAFTS,
182
+ aiApiEndpoint: view.aiApiEndpoint,
183
+ aiModel: view.aiModel,
184
+ aiResponseLanguage: view.aiResponseLanguage,
185
+ tgChatId: view.tgChatId,
186
+ });
187
+
188
+ const isSettingsResponse = (
189
+ payload: SettingsResponse | SettingsErrorResponse,
190
+ ): payload is SettingsResponse =>
191
+ Boolean(payload && typeof payload === 'object' && 'settings' in payload);
192
+
193
+ const getErrorMessage = (
194
+ payload: SettingsResponse | SettingsErrorResponse,
195
+ fallback: string,
196
+ ) =>
197
+ 'error' in payload && typeof payload.error === 'string' && payload.error
198
+ ? payload.error
199
+ : fallback;
200
+
201
+ const getDraftAiEndpoint = (drafts: SettingsDraftState) =>
202
+ normalizeAiEndpoint(drafts.aiApiEndpoint) || drafts.aiApiEndpoint.trim();
203
+
204
+ const getSelectedAiModelOption = (aiModel: string, aiEndpoint: string) => {
205
+ const trimmedModel = aiModel.trim();
206
+
207
+ return getAiModelOptionsForEndpoint(aiEndpoint).some(
208
+ (option) => option.value === trimmedModel,
209
+ )
210
+ ? trimmedModel
211
+ : AI_CUSTOM_MODEL_VALUE;
212
+ };
213
+
214
+ const DRAWER_SELECT_STYLE = {
215
+ width: '100%',
216
+ height: '40px',
217
+ paddingLeft: '12px',
218
+ paddingRight: '48px',
219
+ borderWidth: '1px',
220
+ borderColor: 'rgba(255, 255, 255, 0.16)',
221
+ borderRadius: '0.375rem',
222
+ background: 'rgba(0, 0, 0, 0.32)',
223
+ appearance: 'none',
224
+ WebkitAppearance: 'none',
225
+ MozAppearance: 'none',
226
+ backgroundImage:
227
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9' fill='none'%3E%3Cpath d='M1 1.5L7 7.5L13 1.5' stroke='%23E5E7EB' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\")",
228
+ backgroundRepeat: 'no-repeat',
229
+ backgroundPosition: 'right 16px center',
230
+ backgroundSize: '14px 9px',
231
+ color: 'rgb(229, 231, 235)',
232
+ } as const;
233
+
234
+ export const AccountSettingsDrawer = () => {
235
+ const [open, setOpen] = useState(false);
236
+ const [loading, setLoading] = useState(false);
237
+ const [settings, setSettings] = useState<SettingsViewState>(EMPTY_SETTINGS);
238
+ const [drafts, setDrafts] = useState<SettingsDraftState>(EMPTY_DRAFTS);
239
+ const [passwords, setPasswords] = useState<PasswordState>(EMPTY_PASSWORDS);
240
+ const [editing, setEditing] =
241
+ useState<Record<EditableField, boolean>>(EMPTY_EDITING);
242
+ const [savingSection, setSavingSection] = useState<SectionName | null>(null);
243
+
244
+ const syncState = useCallback((payload: SettingsResponse) => {
245
+ const next = toViewState(payload);
246
+ setSettings(next);
247
+ setDrafts(toDraftState(next));
248
+ setEditing(EMPTY_EDITING);
249
+ }, []);
250
+
251
+ const loadSettings = useCallback(async () => {
252
+ setLoading(true);
253
+
254
+ try {
255
+ const response = await fetch('/api/user/settings', {
256
+ cache: 'no-store',
257
+ });
258
+ const payload = (await response.json()) as
259
+ | SettingsResponse
260
+ | SettingsErrorResponse;
261
+
262
+ if (!response.ok || !isSettingsResponse(payload)) {
263
+ throw new Error(
264
+ getErrorMessage(payload, 'Failed to load account settings'),
265
+ );
266
+ }
267
+
268
+ syncState(payload);
269
+ } catch (error) {
270
+ toaster.error({
271
+ title: 'Failed to load settings',
272
+ description: (error as Error).message,
273
+ });
274
+ } finally {
275
+ setLoading(false);
276
+ }
277
+ }, [syncState]);
278
+
279
+ useEffect(() => {
280
+ if (!open) {
281
+ return;
282
+ }
283
+
284
+ void loadSettings();
285
+ }, [open, loadSettings]);
286
+
287
+ const passwordError = useMemo(() => {
288
+ if (!passwords.password && !passwords.confirmPassword) {
289
+ return '';
290
+ }
291
+
292
+ if (!passwords.password) {
293
+ return 'Password is required';
294
+ }
295
+
296
+ if (passwords.password !== passwords.confirmPassword) {
297
+ return 'Passwords do not match';
298
+ }
299
+
300
+ return '';
301
+ }, [passwords.confirmPassword, passwords.password]);
302
+
303
+ const isSectionDirty = useCallback(
304
+ (section: SectionName) => {
305
+ if (section === 'password') {
306
+ return Boolean(passwords.password || passwords.confirmPassword);
307
+ }
308
+
309
+ if (section === 'ai') {
310
+ const draftAiEndpoint = getDraftAiEndpoint(drafts);
311
+ return (
312
+ Boolean(drafts.aiApiKey.trim()) ||
313
+ draftAiEndpoint !== settings.aiApiEndpoint ||
314
+ drafts.aiModel.trim() !== settings.aiModel ||
315
+ drafts.aiResponseLanguage !== settings.aiResponseLanguage
316
+ );
317
+ }
318
+
319
+ if (section === 'coinalyze') {
320
+ return Boolean(drafts.coinalyzeApiKey.trim());
321
+ }
322
+
323
+ if (section === 'telegram') {
324
+ return (
325
+ Boolean(drafts.tgBotToken.trim()) ||
326
+ drafts.tgChatId !== settings.tgChatId
327
+ );
328
+ }
329
+
330
+ return SECTION_FIELDS[section].some((field) =>
331
+ Boolean(drafts[field].trim()),
332
+ );
333
+ },
334
+ [drafts, passwords.confirmPassword, passwords.password, settings],
335
+ );
336
+
337
+ const updateDraft = (field: EditableField, value: string) => {
338
+ setDrafts((current) => ({
339
+ ...current,
340
+ [field]: value,
341
+ }));
342
+ };
343
+
344
+ const resetFieldDraft = useCallback(
345
+ (field: EditableField) => {
346
+ setDrafts((current) => ({
347
+ ...current,
348
+ [field]: MASKED_FIELDS.has(field) ? '' : settings[field],
349
+ }));
350
+ },
351
+ [settings],
352
+ );
353
+
354
+ const cancelEditing = useCallback(
355
+ (field: EditableField) => {
356
+ setEditing((current) => ({
357
+ ...current,
358
+ [field]: false,
359
+ }));
360
+ resetFieldDraft(field);
361
+ },
362
+ [resetFieldDraft],
363
+ );
364
+
365
+ const enableEditing = useCallback(
366
+ (field: EditableField) => {
367
+ setEditing((current) => ({
368
+ ...current,
369
+ [field]: true,
370
+ }));
371
+ setDrafts((current) => ({
372
+ ...current,
373
+ [field]: MASKED_FIELDS.has(field) ? '' : settings[field],
374
+ }));
375
+ },
376
+ [settings],
377
+ );
378
+
379
+ const getSecretUpdateValue = (field: EditableField) => {
380
+ const trimmed = drafts[field].trim();
381
+ return trimmed ? trimmed : undefined;
382
+ };
383
+
384
+ const handleFieldBlur = useCallback(
385
+ (field: EditableField, value: string) => {
386
+ if (MASKED_FIELDS.has(field)) {
387
+ if (!value.trim()) {
388
+ cancelEditing(field);
389
+ }
390
+
391
+ return;
392
+ }
393
+
394
+ if (value === settings[field]) {
395
+ cancelEditing(field);
396
+ }
397
+ },
398
+ [cancelEditing, settings],
399
+ );
400
+
401
+ const saveSection = async (section: SectionName) => {
402
+ if (section !== 'password' && !isSectionDirty(section)) {
403
+ return;
404
+ }
405
+
406
+ if (section === 'password' && passwordError) {
407
+ toaster.error({
408
+ title: 'Password update failed',
409
+ description: passwordError,
410
+ });
411
+ return;
412
+ }
413
+
414
+ setSavingSection(section);
415
+
416
+ try {
417
+ const body =
418
+ section === 'bybit'
419
+ ? {
420
+ section,
421
+ data: {
422
+ apiKey: getSecretUpdateValue('bybitApiKey'),
423
+ apiSecret: getSecretUpdateValue('bybitApiSecret'),
424
+ },
425
+ }
426
+ : section === 'coinalyze'
427
+ ? {
428
+ section,
429
+ data: {
430
+ apiKey: getSecretUpdateValue('coinalyzeApiKey'),
431
+ },
432
+ }
433
+ : section === 'ai'
434
+ ? {
435
+ section,
436
+ data: {
437
+ apiKey: getSecretUpdateValue('aiApiKey'),
438
+ apiEndpoint: drafts.aiApiEndpoint,
439
+ model: drafts.aiModel.trim(),
440
+ responseLanguage: drafts.aiResponseLanguage,
441
+ },
442
+ }
443
+ : section === 'telegram'
444
+ ? {
445
+ section,
446
+ data: {
447
+ botToken: getSecretUpdateValue('tgBotToken'),
448
+ chatId:
449
+ drafts.tgChatId !== settings.tgChatId
450
+ ? drafts.tgChatId.trim()
451
+ : undefined,
452
+ },
453
+ }
454
+ : {
455
+ section,
456
+ data: {
457
+ password: passwords.password,
458
+ confirmPassword: passwords.confirmPassword,
459
+ },
460
+ };
461
+
462
+ const response = await fetch('/api/user/settings', {
463
+ method: 'PATCH',
464
+ headers: {
465
+ 'Content-Type': 'application/json',
466
+ },
467
+ body: JSON.stringify(body),
468
+ });
469
+ const payload = (await response.json()) as
470
+ | SettingsResponse
471
+ | SettingsErrorResponse;
472
+
473
+ if (!response.ok || !isSettingsResponse(payload)) {
474
+ throw new Error(
475
+ getErrorMessage(payload, 'Failed to save account settings'),
476
+ );
477
+ }
478
+
479
+ syncState(payload);
480
+
481
+ if (section === 'password') {
482
+ setPasswords(EMPTY_PASSWORDS);
483
+ }
484
+
485
+ toaster.success({
486
+ title: 'Settings saved',
487
+ description:
488
+ section === 'password'
489
+ ? 'Password updated successfully.'
490
+ : 'Account settings updated successfully.',
491
+ });
492
+ } catch (error) {
493
+ toaster.error({
494
+ title: 'Save failed',
495
+ description: (error as Error).message,
496
+ });
497
+ } finally {
498
+ setSavingSection(null);
499
+ }
500
+ };
501
+
502
+ const selectedAiEndpointOption = AI_ENDPOINT_OPTIONS.some(
503
+ (option) => option.value === drafts.aiApiEndpoint,
504
+ )
505
+ ? drafts.aiApiEndpoint
506
+ : AI_CUSTOM_ENDPOINT_VALUE;
507
+ const effectiveAiEndpoint = getDraftAiEndpoint(drafts);
508
+ const hasPresetAiModels = hasPresetAiModelsForEndpoint(effectiveAiEndpoint);
509
+ const aiModelOptions = getAiModelOptionsForEndpoint(effectiveAiEndpoint);
510
+ const selectedAiModelOption = getSelectedAiModelOption(
511
+ drafts.aiModel,
512
+ effectiveAiEndpoint,
513
+ );
514
+
515
+ const renderEditableField = ({
516
+ label,
517
+ field,
518
+ placeholder,
519
+ }: {
520
+ label: string;
521
+ field: EditableField;
522
+ placeholder?: string;
523
+ }) => {
524
+ const isEditing = editing[field];
525
+ const savedValue = settings[field];
526
+ const isMasked = MASKED_FIELDS.has(field);
527
+
528
+ return (
529
+ <Field.Root key={field} width="full">
530
+ <Field.Label>{label}</Field.Label>
531
+ <HStack align="stretch" width="full">
532
+ {isEditing ? (
533
+ <Input
534
+ flex="1"
535
+ minW="0"
536
+ value={drafts[field]}
537
+ placeholder={placeholder}
538
+ onChange={(event) => updateDraft(field, event.target.value)}
539
+ onBlur={(event) => handleFieldBlur(field, event.target.value)}
540
+ onKeyDown={(event) => {
541
+ if (event.key === 'Escape') {
542
+ event.preventDefault();
543
+ cancelEditing(field);
544
+ }
545
+ }}
546
+ autoFocus
547
+ fontFamily={isMasked ? 'mono' : undefined}
548
+ fontVariantNumeric="tabular-nums"
549
+ />
550
+ ) : (
551
+ <Flex
552
+ flex="1"
553
+ minW="0"
554
+ h="10"
555
+ px="3"
556
+ borderWidth="1px"
557
+ borderColor="whiteAlpha.200"
558
+ borderRadius="md"
559
+ bg="blackAlpha.300"
560
+ align="center"
561
+ color={savedValue ? 'gray.200' : 'gray.500'}
562
+ cursor="default"
563
+ userSelect="none"
564
+ >
565
+ <Text
566
+ width="full"
567
+ overflow="hidden"
568
+ whiteSpace="nowrap"
569
+ textOverflow="ellipsis"
570
+ color={savedValue ? 'gray.200' : 'gray.500'}
571
+ fontFamily={savedValue ? 'mono' : undefined}
572
+ fontVariantNumeric="tabular-nums"
573
+ >
574
+ {savedValue || 'Not set'}
575
+ </Text>
576
+ </Flex>
577
+ )}
578
+ <IconButton
579
+ aria-label={`Edit ${label}`}
580
+ size="md"
581
+ colorPalette="teal"
582
+ variant={isEditing ? 'solid' : 'outline'}
583
+ flexShrink={0}
584
+ onClick={() => enableEditing(field)}
585
+ >
586
+ <FiEdit2 />
587
+ </IconButton>
588
+ </HStack>
589
+ </Field.Root>
590
+ );
591
+ };
592
+
593
+ return (
594
+ <Drawer.Root
595
+ open={open}
596
+ onOpenChange={(event) => setOpen(event.open)}
597
+ size="xl"
598
+ >
599
+ <Drawer.Trigger asChild>
600
+ <IconButton
601
+ aria-label="Account settings"
602
+ size="md"
603
+ colorPalette="teal"
604
+ variant="outline"
605
+ >
606
+ <FiSettings />
607
+ </IconButton>
608
+ </Drawer.Trigger>
609
+ <Portal>
610
+ <Drawer.Backdrop />
611
+ <Drawer.Positioner>
612
+ <Drawer.Content display="flex" flexDirection="column">
613
+ <Drawer.Header>
614
+ <Drawer.Title>Account settings</Drawer.Title>
615
+ <Drawer.CloseTrigger asChild>
616
+ <CloseButton position="absolute" right="3" top="3" />
617
+ </Drawer.CloseTrigger>
618
+ </Drawer.Header>
619
+
620
+ <Drawer.Body overflowY="auto">
621
+ {loading ? (
622
+ <Flex minH="320px" align="center" justify="center">
623
+ <Spinner color="teal.300" size="lg" />
624
+ </Flex>
625
+ ) : (
626
+ <Stack gap={5}>
627
+ <Text fontSize="sm" color="gray.400">
628
+ Signed in as{' '}
629
+ <Text
630
+ as="span"
631
+ fontSize="sm"
632
+ fontWeight="600"
633
+ color="gray.100"
634
+ >
635
+ {settings.userName || 'Unknown user'}
636
+ </Text>
637
+ </Text>
638
+
639
+ <Box
640
+ borderWidth="1px"
641
+ borderColor="gray.700"
642
+ borderRadius="lg"
643
+ p={4}
644
+ bg="gray.900"
645
+ >
646
+ <Stack gap={4}>
647
+ <Box>
648
+ <Text fontWeight="600">Bybit connection</Text>
649
+ <Text fontSize="sm" color="gray.400">
650
+ API credentials used for exchange access.
651
+ </Text>
652
+ </Box>
653
+ {renderEditableField({
654
+ label: 'BYBIT_API_KEY',
655
+ field: 'bybitApiKey',
656
+ placeholder: 'Enter a new Bybit API key',
657
+ })}
658
+ {renderEditableField({
659
+ label: 'BYBIT_API_SECRET',
660
+ field: 'bybitApiSecret',
661
+ placeholder: 'Enter a new Bybit API secret',
662
+ })}
663
+ <Flex justify="flex-end">
664
+ <Button
665
+ colorPalette="teal"
666
+ loading={savingSection === 'bybit'}
667
+ disabled={!isSectionDirty('bybit')}
668
+ onClick={() => saveSection('bybit')}
669
+ >
670
+ Save
671
+ </Button>
672
+ </Flex>
673
+ </Stack>
674
+ </Box>
675
+
676
+ <Box
677
+ borderWidth="1px"
678
+ borderColor="gray.700"
679
+ borderRadius="lg"
680
+ p={4}
681
+ bg="gray.900"
682
+ >
683
+ <Stack gap={4}>
684
+ <Box>
685
+ <Text fontWeight="600">AI / LLM</Text>
686
+ <Text fontSize="sm" color="gray.400">
687
+ Stored in the user profile and used for AI analysis
688
+ and user-facing AI replies.
689
+ </Text>
690
+ </Box>
691
+ <Field.Root>
692
+ <Field.Label>AI_API_ENDPOINT</Field.Label>
693
+ <select
694
+ value={selectedAiEndpointOption}
695
+ onChange={(event) => {
696
+ const nextValue = event.target.value;
697
+ setDrafts((current) => ({
698
+ ...current,
699
+ aiApiEndpoint:
700
+ nextValue === AI_CUSTOM_ENDPOINT_VALUE
701
+ ? ''
702
+ : nextValue,
703
+ aiModel:
704
+ nextValue === AI_CUSTOM_ENDPOINT_VALUE
705
+ ? ''
706
+ : normalizeAiModel('', nextValue),
707
+ }));
708
+ }}
709
+ style={DRAWER_SELECT_STYLE}
710
+ >
711
+ {AI_ENDPOINT_OPTIONS.map((option) => (
712
+ <option key={option.value} value={option.value}>
713
+ {option.label}
714
+ </option>
715
+ ))}
716
+ </select>
717
+ <Text mt="2" fontSize="sm" color="gray.400">
718
+ {effectiveAiEndpoint || 'Endpoint is not set yet.'}
719
+ </Text>
720
+ </Field.Root>
721
+ {selectedAiEndpointOption === AI_CUSTOM_ENDPOINT_VALUE ? (
722
+ <Field.Root>
723
+ <Field.Label>Custom AI API endpoint URL</Field.Label>
724
+ <Input
725
+ value={drafts.aiApiEndpoint}
726
+ placeholder="https://your-openai-compatible-endpoint/v1"
727
+ onChange={(event) =>
728
+ updateDraft('aiApiEndpoint', event.target.value)
729
+ }
730
+ />
731
+ </Field.Root>
732
+ ) : null}
733
+ {renderEditableField({
734
+ label: 'AI_API_KEY',
735
+ field: 'aiApiKey',
736
+ placeholder: 'Enter a new AI API key',
737
+ })}
738
+ {hasPresetAiModels ? (
739
+ <Field.Root>
740
+ <Field.Label>AI_MODEL</Field.Label>
741
+ <select
742
+ value={selectedAiModelOption}
743
+ onChange={(event) => {
744
+ const nextValue = event.target.value;
745
+ if (nextValue === AI_CUSTOM_MODEL_VALUE) {
746
+ updateDraft('aiModel', '');
747
+ return;
748
+ }
749
+
750
+ updateDraft('aiModel', nextValue);
751
+ }}
752
+ style={DRAWER_SELECT_STYLE}
753
+ >
754
+ {aiModelOptions.map((option) => (
755
+ <option key={option.value} value={option.value}>
756
+ {option.label}
757
+ </option>
758
+ ))}
759
+ <option value={AI_CUSTOM_MODEL_VALUE}>
760
+ Custom model
761
+ </option>
762
+ </select>
763
+ </Field.Root>
764
+ ) : null}
765
+ {!hasPresetAiModels ||
766
+ selectedAiModelOption === AI_CUSTOM_MODEL_VALUE ? (
767
+ <Field.Root>
768
+ <Field.Label>Custom AI model name</Field.Label>
769
+ <Input
770
+ value={drafts.aiModel}
771
+ placeholder="Enter an OpenAI-compatible model name"
772
+ onChange={(event) =>
773
+ updateDraft('aiModel', event.target.value)
774
+ }
775
+ />
776
+ </Field.Root>
777
+ ) : null}
778
+ <Field.Root>
779
+ <Field.Label>AI_RESPONSE_LANGUAGE</Field.Label>
780
+ <select
781
+ value={drafts.aiResponseLanguage}
782
+ onChange={(event) =>
783
+ updateDraft(
784
+ 'aiResponseLanguage',
785
+ event.target.value,
786
+ )
787
+ }
788
+ style={DRAWER_SELECT_STYLE}
789
+ >
790
+ {AI_RESPONSE_LANGUAGE_OPTIONS.map((option) => (
791
+ <option key={option.value} value={option.value}>
792
+ {option.label}
793
+ </option>
794
+ ))}
795
+ </select>
796
+ </Field.Root>
797
+ <Flex justify="flex-end">
798
+ <Button
799
+ colorPalette="teal"
800
+ loading={savingSection === 'ai'}
801
+ disabled={!isSectionDirty('ai')}
802
+ onClick={() => saveSection('ai')}
803
+ >
804
+ Save
805
+ </Button>
806
+ </Flex>
807
+ </Stack>
808
+ </Box>
809
+
810
+ <Box
811
+ borderWidth="1px"
812
+ borderColor="gray.700"
813
+ borderRadius="lg"
814
+ p={4}
815
+ bg="gray.900"
816
+ >
817
+ <Stack gap={4}>
818
+ <Box>
819
+ <Text fontWeight="600">Coinalyze</Text>
820
+ <Text fontSize="sm" color="gray.400">
821
+ API key stored in the user profile for derivatives
822
+ data ingestion.
823
+ </Text>
824
+ </Box>
825
+ {renderEditableField({
826
+ label: 'COINALYZE_API_KEY',
827
+ field: 'coinalyzeApiKey',
828
+ placeholder: 'Enter a new Coinalyze API key',
829
+ })}
830
+ <Flex justify="flex-end">
831
+ <Button
832
+ colorPalette="teal"
833
+ loading={savingSection === 'coinalyze'}
834
+ disabled={!isSectionDirty('coinalyze')}
835
+ onClick={() => saveSection('coinalyze')}
836
+ >
837
+ Save
838
+ </Button>
839
+ </Flex>
840
+ </Stack>
841
+ </Box>
842
+
843
+ <Box
844
+ borderWidth="1px"
845
+ borderColor="gray.700"
846
+ borderRadius="lg"
847
+ p={4}
848
+ bg="gray.900"
849
+ >
850
+ <Stack gap={4}>
851
+ <Box>
852
+ <Text fontWeight="600">Telegram</Text>
853
+ <Text fontSize="sm" color="gray.400">
854
+ Bot credentials used for signal delivery.
855
+ </Text>
856
+ </Box>
857
+ {renderEditableField({
858
+ label: 'TG_BOT_TOKEN',
859
+ field: 'tgBotToken',
860
+ placeholder: 'Enter a new Telegram bot token',
861
+ })}
862
+ {renderEditableField({
863
+ label: 'TG_CHAT_ID',
864
+ field: 'tgChatId',
865
+ placeholder: 'Enter Telegram chat ID',
866
+ })}
867
+ <Flex justify="flex-end">
868
+ <Button
869
+ colorPalette="teal"
870
+ loading={savingSection === 'telegram'}
871
+ disabled={!isSectionDirty('telegram')}
872
+ onClick={() => saveSection('telegram')}
873
+ >
874
+ Save
875
+ </Button>
876
+ </Flex>
877
+ </Stack>
878
+ </Box>
879
+
880
+ <Box
881
+ borderWidth="1px"
882
+ borderColor="gray.700"
883
+ borderRadius="lg"
884
+ p={4}
885
+ bg="gray.900"
886
+ >
887
+ <Stack gap={4}>
888
+ <Box>
889
+ <Text fontWeight="600">Password</Text>
890
+ <Text fontSize="sm" color="gray.400">
891
+ Update the password used for sign in.
892
+ </Text>
893
+ </Box>
894
+ <Field.Root>
895
+ <Field.Label>Password</Field.Label>
896
+ <Input
897
+ type="password"
898
+ value={passwords.password}
899
+ onChange={(event) =>
900
+ setPasswords((current) => ({
901
+ ...current,
902
+ password: event.target.value,
903
+ }))
904
+ }
905
+ />
906
+ </Field.Root>
907
+ <Field.Root>
908
+ <Field.Label>Confirm password</Field.Label>
909
+ <Input
910
+ type="password"
911
+ value={passwords.confirmPassword}
912
+ onChange={(event) =>
913
+ setPasswords((current) => ({
914
+ ...current,
915
+ confirmPassword: event.target.value,
916
+ }))
917
+ }
918
+ />
919
+ </Field.Root>
920
+ {passwordError ? (
921
+ <Text fontSize="sm" color="red.300">
922
+ {passwordError}
923
+ </Text>
924
+ ) : null}
925
+ <Flex justify="flex-end">
926
+ <Button
927
+ colorPalette="teal"
928
+ loading={savingSection === 'password'}
929
+ disabled={
930
+ !isSectionDirty('password') ||
931
+ Boolean(passwordError)
932
+ }
933
+ onClick={() => saveSection('password')}
934
+ >
935
+ Save
936
+ </Button>
937
+ </Flex>
938
+ </Stack>
939
+ </Box>
940
+ </Stack>
941
+ )}
942
+ </Drawer.Body>
943
+ </Drawer.Content>
944
+ </Drawer.Positioner>
945
+ </Portal>
946
+ </Drawer.Root>
947
+ );
948
+ };