import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import Layout from '../components/Layout'; import PasswordInput from '../components/PasswordInput'; import { settingsApi, profileApi, adminApi, NotificationSettings, AISettings, UserProfile, SystemSettings, } from '../api/client'; type SettingsSection = 'profile' | 'notifications' | 'ai' | 'admin'; interface VersionInfo { version: string; releaseDate: string; } export default function Settings() { const [activeSection, setActiveSection] = useState('profile'); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [versionInfo, setVersionInfo] = useState(null); // Profile state const [profile, setProfile] = useState(null); const [profileName, setProfileName] = useState(''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isSavingProfile, setIsSavingProfile] = useState(false); const [isChangingPassword, setIsChangingPassword] = useState(false); // Notification state const [notificationSettings, setNotificationSettings] = useState(null); const [telegramBotToken, setTelegramBotToken] = useState(''); const [telegramChatId, setTelegramChatId] = useState(''); const [telegramEnabled, setTelegramEnabled] = useState(true); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); const [discordEnabled, setDiscordEnabled] = useState(true); const [pushoverUserKey, setPushoverUserKey] = useState(''); const [pushoverAppToken, setPushoverAppToken] = useState(''); const [pushoverEnabled, setPushoverEnabled] = useState(true); const [ntfyTopic, setNtfyTopic] = useState(''); const [ntfyEnabled, setNtfyEnabled] = useState(true); const [isSavingNotifications, setIsSavingNotifications] = useState(false); const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | 'pushover' | 'ntfy' | null>(null); // AI state const [aiSettings, setAISettings] = useState(null); const [aiEnabled, setAIEnabled] = useState(false); const [aiVerificationEnabled, setAIVerificationEnabled] = useState(false); const [aiProvider, setAIProvider] = useState<'anthropic' | 'openai' | 'ollama'>('anthropic'); const [anthropicApiKey, setAnthropicApiKey] = useState(''); const [openaiApiKey, setOpenaiApiKey] = useState(''); const [ollamaBaseUrl, setOllamaBaseUrl] = useState(''); const [ollamaModel, setOllamaModel] = useState(''); const [availableOllamaModels, setAvailableOllamaModels] = useState([]); const [isTestingOllama, setIsTestingOllama] = useState(false); const [isSavingAI, setIsSavingAI] = useState(false); const [isTestingAI, setIsTestingAI] = useState(false); const [testUrl, setTestUrl] = useState(''); // Admin state const [users, setUsers] = useState([]); const [systemSettings, setSystemSettings] = useState(null); const [isLoadingAdmin, setIsLoadingAdmin] = useState(false); const [isSavingAdmin, setIsSavingAdmin] = useState(false); const [showAddUser, setShowAddUser] = useState(false); const [newUserEmail, setNewUserEmail] = useState(''); const [newUserPassword, setNewUserPassword] = useState(''); const [newUserRole, setNewUserRole] = useState<'user' | 'admin'>('user'); const [isCreatingUser, setIsCreatingUser] = useState(false); useEffect(() => { fetchInitialData(); // Fetch version info fetch('/version.json') .then(res => res.json()) .then(data => setVersionInfo(data)) .catch(() => {}); // Silently fail if version.json not found }, []); const fetchInitialData = async () => { try { const [profileRes, notificationsRes, aiRes] = await Promise.all([ profileApi.get(), settingsApi.getNotifications(), settingsApi.getAI(), ]); setProfile(profileRes.data); setProfileName(profileRes.data.name || ''); setNotificationSettings(notificationsRes.data); // Populate notification fields with actual values setTelegramBotToken(notificationsRes.data.telegram_bot_token || ''); setTelegramChatId(notificationsRes.data.telegram_chat_id || ''); setTelegramEnabled(notificationsRes.data.telegram_enabled ?? true); setDiscordWebhookUrl(notificationsRes.data.discord_webhook_url || ''); setDiscordEnabled(notificationsRes.data.discord_enabled ?? true); setPushoverUserKey(notificationsRes.data.pushover_user_key || ''); setPushoverAppToken(notificationsRes.data.pushover_app_token || ''); setPushoverEnabled(notificationsRes.data.pushover_enabled ?? true); setNtfyTopic(notificationsRes.data.ntfy_topic || ''); setNtfyEnabled(notificationsRes.data.ntfy_enabled ?? true); // Populate AI fields with actual values setAISettings(aiRes.data); setAIEnabled(aiRes.data.ai_enabled); setAIVerificationEnabled(aiRes.data.ai_verification_enabled ?? false); if (aiRes.data.ai_provider) { setAIProvider(aiRes.data.ai_provider); } setAnthropicApiKey(aiRes.data.anthropic_api_key || ''); setOpenaiApiKey(aiRes.data.openai_api_key || ''); setOllamaBaseUrl(aiRes.data.ollama_base_url || ''); setOllamaModel(aiRes.data.ollama_model || ''); } catch { setError('Failed to load settings'); } finally { setIsLoading(false); } }; const fetchAdminData = async () => { if (!profile?.is_admin) return; setIsLoadingAdmin(true); try { const [usersRes, settingsRes] = await Promise.all([ adminApi.getUsers(), adminApi.getSettings(), ]); setUsers(usersRes.data); setSystemSettings(settingsRes.data); } catch { setError('Failed to load admin data'); } finally { setIsLoadingAdmin(false); } }; useEffect(() => { if (activeSection === 'admin' && profile?.is_admin && users.length === 0) { fetchAdminData(); } }, [activeSection, profile]); const clearMessages = () => { setError(''); setSuccess(''); }; // Profile handlers const handleSaveProfile = async () => { clearMessages(); setIsSavingProfile(true); try { const response = await profileApi.update({ name: profileName }); setProfile(response.data); setSuccess('Profile updated successfully'); } catch { setError('Failed to update profile'); } finally { setIsSavingProfile(false); } }; const handleChangePassword = async () => { clearMessages(); if (newPassword !== confirmPassword) { setError('New passwords do not match'); return; } if (newPassword.length < 6) { setError('Password must be at least 6 characters'); return; } setIsChangingPassword(true); try { await profileApi.changePassword(currentPassword, newPassword); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setSuccess('Password changed successfully'); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string } } }; setError(error.response?.data?.error || 'Failed to change password'); } finally { setIsChangingPassword(false); } }; // Notification handlers const handleSaveTelegram = async () => { clearMessages(); setIsSavingNotifications(true); try { const response = await settingsApi.updateNotifications({ telegram_bot_token: telegramBotToken || null, telegram_chat_id: telegramChatId || null, }); setNotificationSettings(response.data); setTelegramBotToken(''); setSuccess('Telegram settings saved successfully'); } catch { setError('Failed to save Telegram settings'); } finally { setIsSavingNotifications(false); } }; const handleSaveDiscord = async () => { clearMessages(); setIsSavingNotifications(true); try { const response = await settingsApi.updateNotifications({ discord_webhook_url: discordWebhookUrl || null, }); setNotificationSettings(response.data); setDiscordWebhookUrl(''); setSuccess('Discord settings saved successfully'); } catch { setError('Failed to save Discord settings'); } finally { setIsSavingNotifications(false); } }; const handleTestTelegram = async () => { clearMessages(); setIsTesting('telegram'); try { await settingsApi.testTelegram(); setSuccess('Test notification sent to Telegram!'); } catch { setError('Failed to send test notification'); } finally { setIsTesting(null); } }; const handleTestDiscord = async () => { clearMessages(); setIsTesting('discord'); try { await settingsApi.testDiscord(); setSuccess('Test notification sent to Discord!'); } catch { setError('Failed to send test notification'); } finally { setIsTesting(null); } }; const handleSavePushover = async () => { clearMessages(); setIsSavingNotifications(true); try { const response = await settingsApi.updateNotifications({ pushover_user_key: pushoverUserKey || null, pushover_app_token: pushoverAppToken || null, }); setNotificationSettings(response.data); setPushoverUserKey(''); setPushoverAppToken(''); setSuccess('Pushover settings saved successfully'); } catch { setError('Failed to save Pushover settings'); } finally { setIsSavingNotifications(false); } }; const handleTestPushover = async () => { clearMessages(); setIsTesting('pushover'); try { await settingsApi.testPushover(); setSuccess('Test notification sent to Pushover!'); } catch { setError('Failed to send test notification'); } finally { setIsTesting(null); } }; const handleToggleTelegram = async (enabled: boolean) => { setTelegramEnabled(enabled); try { const response = await settingsApi.updateNotifications({ telegram_enabled: enabled }); setNotificationSettings(response.data); } catch { setTelegramEnabled(!enabled); setError('Failed to update Telegram status'); } }; const handleToggleDiscord = async (enabled: boolean) => { setDiscordEnabled(enabled); try { const response = await settingsApi.updateNotifications({ discord_enabled: enabled }); setNotificationSettings(response.data); } catch { setDiscordEnabled(!enabled); setError('Failed to update Discord status'); } }; const handleTogglePushover = async (enabled: boolean) => { setPushoverEnabled(enabled); try { const response = await settingsApi.updateNotifications({ pushover_enabled: enabled }); setNotificationSettings(response.data); } catch { setPushoverEnabled(!enabled); setError('Failed to update Pushover status'); } }; const handleSaveNtfy = async () => { clearMessages(); setIsSavingNotifications(true); try { const response = await settingsApi.updateNotifications({ ntfy_topic: ntfyTopic || null, }); setNotificationSettings(response.data); setSuccess('ntfy settings saved successfully'); } catch { setError('Failed to save ntfy settings'); } finally { setIsSavingNotifications(false); } }; const handleTestNtfy = async () => { clearMessages(); setIsTesting('ntfy'); try { await settingsApi.testNtfy(); setSuccess('Test notification sent to ntfy!'); } catch { setError('Failed to send test notification'); } finally { setIsTesting(null); } }; const handleToggleNtfy = async (enabled: boolean) => { setNtfyEnabled(enabled); try { const response = await settingsApi.updateNotifications({ ntfy_enabled: enabled }); setNotificationSettings(response.data); } catch { setNtfyEnabled(!enabled); setError('Failed to update ntfy status'); } }; // AI handlers const handleSaveAI = async () => { clearMessages(); setIsSavingAI(true); try { const response = await settingsApi.updateAI({ ai_enabled: aiEnabled, ai_verification_enabled: aiVerificationEnabled, ai_provider: aiProvider, anthropic_api_key: anthropicApiKey || undefined, openai_api_key: openaiApiKey || undefined, ollama_base_url: aiProvider === 'ollama' ? ollamaBaseUrl || null : undefined, ollama_model: aiProvider === 'ollama' ? ollamaModel || null : undefined, }); setAISettings(response.data); setAIVerificationEnabled(response.data.ai_verification_enabled ?? false); setAnthropicApiKey(''); setOpenaiApiKey(''); setSuccess('AI settings saved successfully'); } catch { setError('Failed to save AI settings'); } finally { setIsSavingAI(false); } }; const handleTestOllama = async () => { clearMessages(); if (!ollamaBaseUrl) { setError('Please enter the Ollama base URL'); return; } setIsTestingOllama(true); try { const response = await settingsApi.testOllama(ollamaBaseUrl); if (response.data.success) { setAvailableOllamaModels(response.data.models || []); setSuccess(`Connected to Ollama! Found ${response.data.models?.length || 0} models.`); } else { setError(response.data.error || 'Failed to connect to Ollama'); } } catch { setError('Failed to connect to Ollama. Make sure it is running.'); } finally { setIsTestingOllama(false); } }; const handleTestAI = async () => { clearMessages(); if (!testUrl) { setError('Please enter a URL to test'); return; } setIsTestingAI(true); try { const response = await settingsApi.testAI(testUrl); if (response.data.success && response.data.price) { setSuccess( `AI extraction successful! Found: ${response.data.name || 'Unknown'} - ` + `${response.data.price.currency} ${response.data.price.price.toFixed(2)} ` + `(confidence: ${(response.data.confidence * 100).toFixed(0)}%)` ); } else { setError('AI could not extract price from this URL'); } } catch { setError('Failed to test AI extraction'); } finally { setIsTestingAI(false); } }; // Admin handlers const handleToggleRegistration = async () => { clearMessages(); setIsSavingAdmin(true); try { const newValue = systemSettings?.registration_enabled !== 'true'; const response = await adminApi.updateSettings({ registration_enabled: newValue }); setSystemSettings(response.data); setSuccess(`Registration ${newValue ? 'enabled' : 'disabled'}`); } catch { setError('Failed to update settings'); } finally { setIsSavingAdmin(false); } }; const handleCreateUser = async () => { clearMessages(); if (!newUserEmail || !newUserPassword) { setError('Email and password are required'); return; } if (newUserPassword.length < 8) { setError('Password must be at least 8 characters'); return; } setIsCreatingUser(true); try { await adminApi.createUser(newUserEmail, newUserPassword, newUserRole === 'admin'); // Refresh users list const usersRes = await adminApi.getUsers(); setUsers(usersRes.data); setNewUserEmail(''); setNewUserPassword(''); setNewUserRole('user'); setShowAddUser(false); setSuccess('User created successfully'); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string } } }; setError(error.response?.data?.error || 'Failed to create user'); } finally { setIsCreatingUser(false); } }; const handleDeleteUser = async (userId: number) => { if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) { return; } clearMessages(); try { await adminApi.deleteUser(userId); setUsers(users.filter(u => u.id !== userId)); setSuccess('User deleted successfully'); } catch { setError('Failed to delete user'); } }; const handleRoleChange = async (userId: number, newRole: 'user' | 'admin') => { clearMessages(); const isAdmin = newRole === 'admin'; try { await adminApi.setUserAdmin(userId, isAdmin); setUsers(users.map(u => u.id === userId ? { ...u, is_admin: isAdmin } : u)); setSuccess(`User role updated to ${newRole}`); } catch { setError('Failed to update user role'); } }; if (isLoading) { return (
); } return (
โ† Back to Dashboard

Settings

{error &&
{error}
} {success &&
{success}
}
{/* Version Info */} {versionInfo && (
PriceGhost v{versionInfo.version}
)}
{activeSection === 'profile' && ( <>
๐Ÿ‘ค

Profile Information

Update your display name and email preferences.

Email cannot be changed

setProfileName(e.target.value)} placeholder="Enter your name" />
๐Ÿ”’

Change Password

Update your password to keep your account secure.

setCurrentPassword(e.target.value)} placeholder="Enter current password" />
setNewPassword(e.target.value)} placeholder="Enter new password" />
setConfirmPassword(e.target.value)} placeholder="Confirm new password" />
)} {activeSection === 'notifications' && ( <>
๐Ÿ“ฑ

Telegram Notifications

{notificationSettings?.telegram_bot_token && notificationSettings?.telegram_chat_id ? 'Configured' : 'Not configured'}

Receive price drop and back-in-stock alerts via Telegram. You'll need to create a Telegram bot and get your chat ID.

{notificationSettings?.telegram_bot_token && notificationSettings?.telegram_chat_id && (
Enable Telegram Notifications Toggle to enable or disable Telegram alerts
)}
setTelegramBotToken(e.target.value)} placeholder="Enter your bot token" />

Create a bot via @BotFather on Telegram to get a token

setTelegramChatId(e.target.value)} placeholder="Enter your chat ID" />

Send /start to @userinfobot to get your chat ID

{notificationSettings?.telegram_bot_token && notificationSettings?.telegram_chat_id && ( )}
๐Ÿ’ฌ

Discord Notifications

{notificationSettings?.discord_webhook_url ? 'Configured' : 'Not configured'}

Receive price drop and back-in-stock alerts in a Discord channel. Create a webhook in your Discord server settings.

{notificationSettings?.discord_webhook_url && (
Enable Discord Notifications Toggle to enable or disable Discord alerts
)}
setDiscordWebhookUrl(e.target.value)} placeholder="https://discord.com/api/webhooks/..." />

Server Settings โ†’ Integrations โ†’ Webhooks โ†’ New Webhook

{notificationSettings?.discord_webhook_url && ( )}
๐Ÿ””

Pushover Notifications

{notificationSettings?.pushover_user_key && notificationSettings?.pushover_app_token ? 'Configured' : 'Not configured'}

Receive price drop and back-in-stock alerts via Pushover. You'll need to create a Pushover account and an application to get your keys.

{notificationSettings?.pushover_user_key && notificationSettings?.pushover_app_token && (
Enable Pushover Notifications Toggle to enable or disable Pushover alerts
)}
setPushoverUserKey(e.target.value)} placeholder="Enter your user key" />

Find your User Key on the Pushover dashboard after logging in

setPushoverAppToken(e.target.value)} placeholder="Enter your app token" />

Create an application at pushover.net/apps to get an API token

{notificationSettings?.pushover_user_key && notificationSettings?.pushover_app_token && ( )}
๐Ÿ“ฒ

ntfy Notifications

{notificationSettings?.ntfy_topic ? 'Configured' : 'Not configured'}

Receive push notifications via ntfy.sh - a simple, free notification service. No account required! Just pick a topic name and subscribe to it in the ntfy app.

{notificationSettings?.ntfy_topic && (
Enable ntfy Notifications Toggle to enable or disable ntfy alerts
)}
setNtfyTopic(e.target.value)} placeholder="my-price-alerts" />

Pick a unique topic name (e.g., priceghost-myname-123). Then subscribe to it in the{' '} ntfy app.

{notificationSettings?.ntfy_topic && ( )}
)} {activeSection === 'ai' && ( <>
๐Ÿค–

AI-Powered Price Extraction

{aiSettings?.ai_enabled ? 'Enabled' : 'Disabled'}

Enable AI-powered price extraction for better compatibility with websites that standard scraping can't handle. When enabled, AI will be used as a fallback when regular scraping fails to find a price.

Enable AI Extraction Use AI as a fallback when standard scraping fails
Enable AI Verification Verify all scraped prices with AI to ensure accuracy
{aiVerificationEnabled && (
Price badges explained:
โœ“ AI AI verified the scraped price is correct
โšก AI AI corrected an incorrect price (e.g., scraped savings amount instead of actual price)
)} {(aiEnabled || aiVerificationEnabled) && ( <>
{aiProvider === 'anthropic' && (
setAnthropicApiKey(e.target.value)} placeholder="sk-ant-..." />

Get your API key from{' '} console.anthropic.com

)} {aiProvider === 'openai' && (
setOpenaiApiKey(e.target.value)} placeholder="sk-..." />

Get your API key from{' '} platform.openai.com

)} {aiProvider === 'ollama' && ( <>
setOllamaBaseUrl(e.target.value)} placeholder="http://localhost:11434" style={{ flex: 1 }} />

The URL where Ollama is running. Default is http://localhost:11434

{availableOllamaModels.length > 0 ? ( ) : ( setOllamaModel(e.target.value)} placeholder={aiSettings?.ollama_model || 'llama3.2, mistral, etc.'} /> )}

{availableOllamaModels.length > 0 ? 'Select from available models or test connection to refresh list' : 'Enter model name or test connection to see available models' } {aiSettings?.ollama_base_url && aiSettings?.ollama_model && ` (currently: ${aiSettings.ollama_model})`}

)} )}
{aiSettings?.ai_enabled && (aiSettings.anthropic_api_key || aiSettings.openai_api_key || (aiSettings.ollama_base_url && aiSettings.ollama_model)) && (
๐Ÿงช

Test AI Extraction

Test AI extraction on a product URL to see if it can successfully extract the price.

setTestUrl(e.target.value)} placeholder="https://example.com/product" />
)} )} {activeSection === 'admin' && profile?.is_admin && ( <>
โš™๏ธ

System Settings

Configure system-wide settings for PriceGhost.

User Registration Allow new users to register accounts
๐Ÿ‘ฅ

User Management

Manage user accounts and permissions.

{!showAddUser ? ( ) : (

Add New User

setNewUserEmail(e.target.value)} placeholder="user@example.com" />
setNewUserPassword(e.target.value)} placeholder="Minimum 8 characters" />
)} {isLoadingAdmin ? (
) : ( {users.map((user) => ( ))}
Email Name Role Joined Actions
{user.email} {user.name || '-'} {user.id === profile?.id ? ( Admin (You) ) : ( )} {new Date(user.created_at).toLocaleDateString()} {user.id !== profile?.id && ( )}
)}
)}
); }