'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Button, CloseButton, Drawer, Field, Flex, HStack, IconButton, Input, Portal, Spinner, Stack, Text, } from '@chakra-ui/react'; import { FiEdit2, FiSettings } from 'react-icons/fi'; import { AI_RESPONSE_LANGUAGE_OPTIONS, normalizeAiResponseLanguage, } from '@tradejs/core/aiLanguages'; import { AI_CUSTOM_ENDPOINT_VALUE, AI_ENDPOINT_OPTIONS, normalizeAiEndpoint, } from '@tradejs/core/aiEndpoints'; import { AI_CUSTOM_MODEL_VALUE, getAiModelOptionsForEndpoint, hasPresetAiModelsForEndpoint, normalizeAiModel, } from '@tradejs/core/aiModels'; import { toaster } from '#ui'; import { TradingAccountsPanel } from './TradingAccountsPanel'; type SettingsResponse = { userName: string; settings: { coinalyze: { apiKey: string; }; coinmarketcap: { apiKey: string; }; ai: { apiKey: string; apiEndpoint: string; model: string; responseLanguage: string; }; telegram: { botToken: string; chatId: string; }; }; }; type SettingsErrorResponse = { error?: string; }; type SettingsViewState = { userName: string; coinalyzeApiKey: string; coinmarketcapApiKey: string; aiApiKey: string; aiApiEndpoint: string; aiModel: string; aiResponseLanguage: string; tgBotToken: string; tgChatId: string; }; type SettingsDraftState = Omit; type PasswordState = { password: string; confirmPassword: string; }; type SectionName = | 'password' | 'coinalyze' | 'coinmarketcap' | 'ai' | 'telegram'; type EditableField = | 'coinalyzeApiKey' | 'coinmarketcapApiKey' | 'aiApiKey' | 'aiApiEndpoint' | 'aiModel' | 'aiResponseLanguage' | 'tgBotToken' | 'tgChatId'; const EMPTY_SETTINGS: SettingsViewState = { userName: '', coinalyzeApiKey: '', coinmarketcapApiKey: '', aiApiKey: '', aiApiEndpoint: '', aiModel: '', aiResponseLanguage: '', tgBotToken: '', tgChatId: '', }; const EMPTY_DRAFTS: SettingsDraftState = { coinalyzeApiKey: '', coinmarketcapApiKey: '', aiApiKey: '', aiApiEndpoint: '', aiModel: '', aiResponseLanguage: '', tgBotToken: '', tgChatId: '', }; const EMPTY_PASSWORDS: PasswordState = { password: '', confirmPassword: '', }; const EMPTY_EDITING: Record = { coinalyzeApiKey: false, coinmarketcapApiKey: false, aiApiKey: false, aiApiEndpoint: false, aiModel: false, aiResponseLanguage: false, tgBotToken: false, tgChatId: false, }; const MASKED_FIELDS = new Set([ 'coinalyzeApiKey', 'coinmarketcapApiKey', 'aiApiKey', 'tgBotToken', ]); const toViewState = (payload: SettingsResponse): SettingsViewState => ({ userName: payload.userName, coinalyzeApiKey: payload.settings.coinalyze.apiKey || '', coinmarketcapApiKey: payload.settings.coinmarketcap.apiKey || '', aiApiKey: payload.settings.ai.apiKey || '', aiApiEndpoint: normalizeAiEndpoint(payload.settings.ai.apiEndpoint) || AI_ENDPOINT_OPTIONS[0].value, aiModel: normalizeAiModel( payload.settings.ai.model, normalizeAiEndpoint(payload.settings.ai.apiEndpoint) || AI_ENDPOINT_OPTIONS[0].value, ) || payload.settings.ai.model || '', aiResponseLanguage: normalizeAiResponseLanguage( payload.settings.ai.responseLanguage, ), tgBotToken: payload.settings.telegram.botToken || '', tgChatId: payload.settings.telegram.chatId || '', }); const toDraftState = (view: SettingsViewState): SettingsDraftState => ({ ...EMPTY_DRAFTS, aiApiEndpoint: view.aiApiEndpoint, aiModel: view.aiModel, aiResponseLanguage: view.aiResponseLanguage, tgChatId: view.tgChatId, }); const isSettingsResponse = ( payload: SettingsResponse | SettingsErrorResponse, ): payload is SettingsResponse => Boolean(payload && typeof payload === 'object' && 'settings' in payload); const getErrorMessage = ( payload: SettingsResponse | SettingsErrorResponse, fallback: string, ) => 'error' in payload && typeof payload.error === 'string' && payload.error ? payload.error : fallback; const getDraftAiEndpoint = (drafts: SettingsDraftState) => normalizeAiEndpoint(drafts.aiApiEndpoint) || drafts.aiApiEndpoint.trim(); const getSelectedAiModelOption = (aiModel: string, aiEndpoint: string) => { const trimmedModel = aiModel.trim(); return getAiModelOptionsForEndpoint(aiEndpoint).some( (option) => option.value === trimmedModel, ) ? trimmedModel : AI_CUSTOM_MODEL_VALUE; }; const DRAWER_SELECT_STYLE = { width: '100%', height: '40px', paddingLeft: '12px', paddingRight: '48px', borderWidth: '1px', borderColor: 'rgba(255, 255, 255, 0.16)', borderRadius: '0.375rem', background: 'rgba(0, 0, 0, 0.32)', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', backgroundImage: "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\")", backgroundRepeat: 'no-repeat', backgroundPosition: 'right 16px center', backgroundSize: '14px 9px', color: 'rgb(229, 231, 235)', } as const; export const AccountSettingsDrawer = () => { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [settings, setSettings] = useState(EMPTY_SETTINGS); const [drafts, setDrafts] = useState(EMPTY_DRAFTS); const [passwords, setPasswords] = useState(EMPTY_PASSWORDS); const [editing, setEditing] = useState>(EMPTY_EDITING); const [savingSection, setSavingSection] = useState(null); const syncState = useCallback((payload: SettingsResponse) => { const next = toViewState(payload); setSettings(next); setDrafts(toDraftState(next)); setEditing(EMPTY_EDITING); }, []); const loadSettings = useCallback(async () => { setLoading(true); try { const response = await fetch('/api/user/settings', { cache: 'no-store', }); const payload = (await response.json()) as | SettingsResponse | SettingsErrorResponse; if (!response.ok || !isSettingsResponse(payload)) { throw new Error( getErrorMessage(payload, 'Failed to load account settings'), ); } syncState(payload); } catch (error) { toaster.error({ title: 'Failed to load settings', description: (error as Error).message, }); } finally { setLoading(false); } }, [syncState]); useEffect(() => { if (!open) { return; } void loadSettings(); }, [open, loadSettings]); const passwordError = useMemo(() => { if (!passwords.password && !passwords.confirmPassword) { return ''; } if (!passwords.password) { return 'Password is required'; } if (passwords.password !== passwords.confirmPassword) { return 'Passwords do not match'; } return ''; }, [passwords.confirmPassword, passwords.password]); const isSectionDirty = useCallback( (section: SectionName) => { if (section === 'password') { return Boolean(passwords.password || passwords.confirmPassword); } if (section === 'ai') { const draftAiEndpoint = getDraftAiEndpoint(drafts); return ( Boolean(drafts.aiApiKey.trim()) || draftAiEndpoint !== settings.aiApiEndpoint || drafts.aiModel.trim() !== settings.aiModel || drafts.aiResponseLanguage !== settings.aiResponseLanguage ); } if (section === 'coinalyze') { return Boolean(drafts.coinalyzeApiKey.trim()); } if (section === 'coinmarketcap') { return Boolean(drafts.coinmarketcapApiKey.trim()); } if (section === 'telegram') { return ( Boolean(drafts.tgBotToken.trim()) || drafts.tgChatId !== settings.tgChatId ); } return false; }, [drafts, passwords.confirmPassword, passwords.password, settings], ); const updateDraft = (field: EditableField, value: string) => { setDrafts((current) => ({ ...current, [field]: value, })); }; const resetFieldDraft = useCallback( (field: EditableField) => { setDrafts((current) => ({ ...current, [field]: MASKED_FIELDS.has(field) ? '' : settings[field], })); }, [settings], ); const cancelEditing = useCallback( (field: EditableField) => { setEditing((current) => ({ ...current, [field]: false, })); resetFieldDraft(field); }, [resetFieldDraft], ); const enableEditing = useCallback( (field: EditableField) => { setEditing((current) => ({ ...current, [field]: true, })); setDrafts((current) => ({ ...current, [field]: MASKED_FIELDS.has(field) ? '' : settings[field], })); }, [settings], ); const getSecretUpdateValue = (field: EditableField) => { const trimmed = drafts[field].trim(); return trimmed ? trimmed : undefined; }; const handleFieldBlur = useCallback( (field: EditableField, value: string) => { if (MASKED_FIELDS.has(field)) { if (!value.trim()) { cancelEditing(field); } return; } if (value === settings[field]) { cancelEditing(field); } }, [cancelEditing, settings], ); const saveSection = async (section: SectionName) => { if (section !== 'password' && !isSectionDirty(section)) { return; } if (section === 'password' && passwordError) { toaster.error({ title: 'Password update failed', description: passwordError, }); return; } setSavingSection(section); try { const body = section === 'coinalyze' ? { section, data: { apiKey: getSecretUpdateValue('coinalyzeApiKey'), }, } : section === 'ai' ? { section, data: { apiKey: getSecretUpdateValue('aiApiKey'), apiEndpoint: drafts.aiApiEndpoint, model: drafts.aiModel.trim(), responseLanguage: drafts.aiResponseLanguage, }, } : section === 'coinmarketcap' ? { section, data: { apiKey: getSecretUpdateValue('coinmarketcapApiKey'), }, } : section === 'telegram' ? { section, data: { botToken: getSecretUpdateValue('tgBotToken'), chatId: drafts.tgChatId !== settings.tgChatId ? drafts.tgChatId.trim() : undefined, }, } : { section, data: { password: passwords.password, confirmPassword: passwords.confirmPassword, }, }; const response = await fetch('/api/user/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); const payload = (await response.json()) as | SettingsResponse | SettingsErrorResponse; if (!response.ok || !isSettingsResponse(payload)) { throw new Error( getErrorMessage(payload, 'Failed to save account settings'), ); } syncState(payload); if (section === 'password') { setPasswords(EMPTY_PASSWORDS); } toaster.success({ title: 'Settings saved', description: section === 'password' ? 'Password updated successfully.' : 'Account settings updated successfully.', }); } catch (error) { toaster.error({ title: 'Save failed', description: (error as Error).message, }); } finally { setSavingSection(null); } }; const selectedAiEndpointOption = AI_ENDPOINT_OPTIONS.some( (option) => option.value === drafts.aiApiEndpoint, ) ? drafts.aiApiEndpoint : AI_CUSTOM_ENDPOINT_VALUE; const effectiveAiEndpoint = getDraftAiEndpoint(drafts); const hasPresetAiModels = hasPresetAiModelsForEndpoint(effectiveAiEndpoint); const aiModelOptions = getAiModelOptionsForEndpoint(effectiveAiEndpoint); const selectedAiModelOption = getSelectedAiModelOption( drafts.aiModel, effectiveAiEndpoint, ); const renderEditableField = ({ label, field, placeholder, }: { label: string; field: EditableField; placeholder?: string; }) => { const isEditing = editing[field]; const savedValue = settings[field]; const isMasked = MASKED_FIELDS.has(field); return ( {label} {isEditing ? ( updateDraft(field, event.target.value)} onBlur={(event) => handleFieldBlur(field, event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); cancelEditing(field); } }} autoFocus fontFamily={isMasked ? 'mono' : undefined} fontVariantNumeric="tabular-nums" /> ) : ( {savedValue || 'Not set'} )} enableEditing(field)} > ); }; return ( setOpen(event.open)} size="xl" > Account settings {loading ? ( ) : ( Signed in as{' '} {settings.userName || 'Unknown user'} CoinMarketCap API key stored in the user profile for historical global market context ingestion. {renderEditableField({ label: 'COINMARKETCAP_API_KEY', field: 'coinmarketcapApiKey', placeholder: 'Enter a new CoinMarketCap API key', })} AI / LLM Stored in the user profile and used for AI analysis and user-facing AI replies. AI_API_ENDPOINT {effectiveAiEndpoint || 'Endpoint is not set yet.'} {selectedAiEndpointOption === AI_CUSTOM_ENDPOINT_VALUE ? ( Custom AI API endpoint URL updateDraft('aiApiEndpoint', event.target.value) } /> ) : null} {renderEditableField({ label: 'AI_API_KEY', field: 'aiApiKey', placeholder: 'Enter a new AI API key', })} {hasPresetAiModels ? ( AI_MODEL ) : null} {!hasPresetAiModels || selectedAiModelOption === AI_CUSTOM_MODEL_VALUE ? ( Custom AI model name updateDraft('aiModel', event.target.value) } /> ) : null} AI_RESPONSE_LANGUAGE Coinalyze API key stored in the user profile for derivatives data ingestion. {renderEditableField({ label: 'COINALYZE_API_KEY', field: 'coinalyzeApiKey', placeholder: 'Enter a new Coinalyze API key', })} Telegram Bot credentials used for signal delivery. {renderEditableField({ label: 'TG_BOT_TOKEN', field: 'tgBotToken', placeholder: 'Enter a new Telegram bot token', })} {renderEditableField({ label: 'TG_CHAT_ID', field: 'tgChatId', placeholder: 'Enter Telegram chat ID', })} Password Update the password used for sign in. Password setPasswords((current) => ({ ...current, password: event.target.value, })) } /> Confirm password setPasswords((current) => ({ ...current, confirmPassword: event.target.value, })) } /> {passwordError ? ( {passwordError} ) : null} )} ); };