Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react' | |
| import { Lock } from 'lucide-react' | |
| import { useTranslation } from '../../i18n' | |
| import { notificationsApi, settingsApi } from '../../api/client' | |
| import { useToast } from '../shared/Toast' | |
| import ToggleSwitch from './ToggleSwitch' | |
| import Section from './Section' | |
| interface PreferencesMatrix { | |
| preferences: Record<string, Record<string, boolean>> | |
| available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean } | |
| event_types: string[] | |
| implemented_combos: Record<string, string[]> | |
| defaults?: { ntfyServer: string | null } | |
| } | |
| const CHANNEL_LABEL_KEYS: Record<string, string> = { | |
| email: 'settings.notificationPreferences.email', | |
| webhook: 'settings.notificationPreferences.webhook', | |
| inapp: 'settings.notificationPreferences.inapp', | |
| ntfy: 'settings.notificationPreferences.ntfy', | |
| } | |
| const EVENT_LABEL_KEYS: Record<string, string> = { | |
| trip_invite: 'settings.notifyTripInvite', | |
| booking_change: 'settings.notifyBookingChange', | |
| trip_reminder: 'settings.notifyTripReminder', | |
| todo_due: 'settings.notifyTodoDue', | |
| vacay_invite: 'settings.notifyVacayInvite', | |
| photos_shared: 'settings.notifyPhotosShared', | |
| collab_message: 'settings.notifyCollabMessage', | |
| packing_tagged: 'settings.notifyPackingTagged', | |
| version_available: 'settings.notifyVersionAvailable', | |
| } | |
| export default function NotificationsTab(): React.ReactElement { | |
| const { t } = useTranslation() | |
| const toast = useToast() | |
| const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null) | |
| const [saving, setSaving] = useState(false) | |
| const [webhookUrl, setWebhookUrl] = useState('') | |
| const [webhookIsSet, setWebhookIsSet] = useState(false) | |
| const [webhookSaving, setWebhookSaving] = useState(false) | |
| const [webhookTesting, setWebhookTesting] = useState(false) | |
| const [ntfyTopic, setNtfyTopic] = useState('') | |
| const [ntfyServer, setNtfyServer] = useState('') | |
| const [ntfyToken, setNtfyToken] = useState('') | |
| const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false) | |
| const [ntfySaving, setNtfySaving] = useState(false) | |
| const [ntfyTesting, setNtfyTesting] = useState(false) | |
| useEffect(() => { | |
| notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) | |
| settingsApi.get().then((data: { settings: Record<string, unknown> }) => { | |
| const val = (data.settings?.webhook_url as string) || '' | |
| if (val === '••••••••') { | |
| setWebhookIsSet(true) | |
| setWebhookUrl('') | |
| } else { | |
| setWebhookUrl(val) | |
| } | |
| setNtfyTopic((data.settings?.ntfy_topic as string) || '') | |
| setNtfyServer((data.settings?.ntfy_server as string) || '') | |
| const rawToken = (data.settings?.ntfy_token as string) || '' | |
| if (rawToken === '••••••••') { | |
| setNtfyTokenIsSet(true) | |
| setNtfyToken('') | |
| } else { | |
| setNtfyToken(rawToken) | |
| } | |
| }).catch(() => {}) | |
| }, []) | |
| const visibleChannels = matrix | |
| ? (['email', 'webhook', 'ntfy', 'inapp'] as const).filter(ch => { | |
| if (!matrix.available_channels[ch as keyof typeof matrix.available_channels]) return false | |
| return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) | |
| }) | |
| : [] | |
| const toggle = async (eventType: string, channel: string) => { | |
| if (!matrix) return | |
| const current = matrix.preferences[eventType]?.[channel] ?? true | |
| const updated = { | |
| ...matrix.preferences, | |
| [eventType]: { ...matrix.preferences[eventType], [channel]: !current }, | |
| } | |
| setMatrix(m => m ? { ...m, preferences: updated } : m) | |
| setSaving(true) | |
| try { | |
| await notificationsApi.updatePreferences(updated) | |
| } catch { | |
| setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m) | |
| } finally { | |
| setSaving(false) | |
| } | |
| } | |
| const saveWebhookUrl = async () => { | |
| setWebhookSaving(true) | |
| try { | |
| await settingsApi.set('webhook_url', webhookUrl) | |
| if (webhookUrl) setWebhookIsSet(true) | |
| else setWebhookIsSet(false) | |
| toast.success(t('settings.webhookUrl.saved')) | |
| } catch { | |
| toast.error(t('common.error')) | |
| } finally { | |
| setWebhookSaving(false) | |
| } | |
| } | |
| const testWebhookUrl = async () => { | |
| if (!webhookUrl && !webhookIsSet) return | |
| setWebhookTesting(true) | |
| try { | |
| const result = await notificationsApi.testWebhook(webhookUrl || undefined) | |
| if (result.success) toast.success(t('settings.webhookUrl.testSuccess')) | |
| else toast.error(result.error || t('settings.webhookUrl.testFailed')) | |
| } catch { | |
| toast.error(t('settings.webhookUrl.testFailed')) | |
| } finally { | |
| setWebhookTesting(false) | |
| } | |
| } | |
| const saveNtfySettings = async () => { | |
| setNtfySaving(true) | |
| try { | |
| await settingsApi.setBulk({ | |
| ntfy_topic: ntfyTopic, | |
| ntfy_server: ntfyServer, | |
| ...(ntfyToken && ntfyToken !== '••••••••' ? { ntfy_token: ntfyToken } : {}), | |
| }) | |
| if (ntfyToken && ntfyToken !== '••••••••') setNtfyTokenIsSet(true) | |
| toast.success(t('settings.ntfyUrl.saved')) | |
| } catch { | |
| toast.error(t('common.error')) | |
| } finally { | |
| setNtfySaving(false) | |
| } | |
| } | |
| const clearNtfyToken = async () => { | |
| try { | |
| await settingsApi.set('ntfy_token', '') | |
| setNtfyToken('') | |
| setNtfyTokenIsSet(false) | |
| toast.success(t('settings.ntfyUrl.tokenCleared')) | |
| } catch { | |
| toast.error(t('common.error')) | |
| } | |
| } | |
| const testNtfySettings = async () => { | |
| if (!ntfyTopic) return | |
| setNtfyTesting(true) | |
| try { | |
| const result = await notificationsApi.testNtfy({ | |
| topic: ntfyTopic, | |
| server: ntfyServer || null, | |
| token: ntfyToken && ntfyToken !== '••••••••' ? ntfyToken : null, | |
| }) | |
| if (result.success) toast.success(t('settings.ntfyUrl.testSuccess')) | |
| else toast.error(result.error || t('settings.ntfyUrl.testFailed')) | |
| } catch { | |
| toast.error(t('settings.ntfyUrl.testFailed')) | |
| } finally { | |
| setNtfyTesting(false) | |
| } | |
| } | |
| const renderContent = () => { | |
| if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p> | |
| if (visibleChannels.length === 0) { | |
| return ( | |
| <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}> | |
| {t('settings.notificationPreferences.noChannels')} | |
| </p> | |
| ) | |
| } | |
| return ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> | |
| {saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>} | |
| {matrix.available_channels.webhook && ( | |
| <div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}> | |
| <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}> | |
| {t('settings.webhookUrl.label')} | |
| </label> | |
| <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p> | |
| <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> | |
| <input | |
| type="text" | |
| value={webhookUrl} | |
| onChange={e => setWebhookUrl(e.target.value)} | |
| placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')} | |
| style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }} | |
| /> | |
| <button | |
| onClick={saveWebhookUrl} | |
| disabled={webhookSaving} | |
| style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }} | |
| > | |
| {t('common.save')} | |
| </button> | |
| <button | |
| onClick={testWebhookUrl} | |
| disabled={(!webhookUrl && !webhookIsSet) || webhookTesting} | |
| style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }} | |
| > | |
| {t('settings.webhookUrl.test')} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {matrix.available_channels.ntfy && ( | |
| <div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}> | |
| <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}> | |
| {t('settings.ntfyUrl.topicLabel')} | |
| </label> | |
| <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.ntfyUrl.hint')}</p> | |
| <input | |
| type="text" | |
| value={ntfyTopic} | |
| onChange={e => setNtfyTopic(e.target.value)} | |
| placeholder={t('settings.ntfyUrl.topicPlaceholder')} | |
| style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }} | |
| /> | |
| <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}> | |
| {t('settings.ntfyUrl.serverLabel')} | |
| </label> | |
| <input | |
| type="text" | |
| value={ntfyServer} | |
| onChange={e => setNtfyServer(e.target.value)} | |
| placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')} | |
| style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }} | |
| /> | |
| <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}> | |
| {t('settings.ntfyUrl.tokenLabel')} | |
| </label> | |
| <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 4 }}>{t('settings.ntfyUrl.tokenHint')}</p> | |
| <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> | |
| <input | |
| type="password" | |
| value={ntfyToken} | |
| onChange={e => setNtfyToken(e.target.value)} | |
| placeholder={ntfyTokenIsSet ? '••••••••' : ''} | |
| style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }} | |
| /> | |
| {ntfyTokenIsSet && ( | |
| <button | |
| onClick={clearNtfyToken} | |
| style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }} | |
| > | |
| {t('common.clear')} | |
| </button> | |
| )} | |
| <button | |
| onClick={saveNtfySettings} | |
| disabled={ntfySaving} | |
| style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: ntfySaving ? 'not-allowed' : 'pointer', opacity: ntfySaving ? 0.6 : 1 }} | |
| > | |
| {t('common.save')} | |
| </button> | |
| <button | |
| onClick={testNtfySettings} | |
| disabled={!ntfyTopic || ntfyTesting} | |
| style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ntfyTopic || ntfyTesting) ? 'not-allowed' : 'pointer', opacity: (!ntfyTopic || ntfyTesting) ? 0.5 : 1 }} | |
| > | |
| {t('settings.ntfyUrl.test')} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Header row */} | |
| <div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> | |
| <span /> | |
| {visibleChannels.map(ch => ( | |
| <span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}> | |
| {t(CHANNEL_LABEL_KEYS[ch]) || ch} | |
| </span> | |
| ))} | |
| </div> | |
| {/* Event rows */} | |
| {matrix.event_types.map(eventType => { | |
| const implementedForEvent = matrix.implemented_combos[eventType] ?? [] | |
| const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch)) | |
| if (relevantChannels.length === 0) return null | |
| return ( | |
| <div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}> | |
| <span style={{ fontSize: 13, color: 'var(--text-primary)' }}> | |
| {t(EVENT_LABEL_KEYS[eventType]) || eventType} | |
| </span> | |
| {visibleChannels.map(ch => { | |
| if (!implementedForEvent.includes(ch)) { | |
| return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span> | |
| } | |
| const isOn = matrix.preferences[eventType]?.[ch] ?? true | |
| return ( | |
| <div key={ch} style={{ display: 'flex', justifyContent: 'center' }}> | |
| <ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} /> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <Section title={t('settings.notifications')} icon={Lock}> | |
| {renderContent()} | |
| </Section> | |
| ) | |
| } | |