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> available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean } event_types: string[] implemented_combos: Record defaults?: { ntfyServer: string | null } } const CHANNEL_LABEL_KEYS: Record = { email: 'settings.notificationPreferences.email', webhook: 'settings.notificationPreferences.webhook', inapp: 'settings.notificationPreferences.inapp', ntfy: 'settings.notificationPreferences.ntfy', } const EVENT_LABEL_KEYS: Record = { trip_invite: 'settings.notifyTripInvite', booking_change: 'settings.notifyBookingChange', trip_reminder: 'settings.notifyTripReminder', 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(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 }) => { 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

{t('common.loading')}

if (visibleChannels.length === 0) { return (

{t('settings.notificationPreferences.noChannels')}

) } return (
{saving &&

{t('common.saving')}

} {matrix.available_channels.webhook && (

{t('settings.webhookUrl.hint')}

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)' }} />
)} {matrix.available_channels.ntfy && (

{t('settings.ntfyUrl.hint')}

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 }} /> 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 }} />

{t('settings.ntfyUrl.tokenHint')}

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 && ( )}
)} {/* Header row */}
'64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> {visibleChannels.map(ch => ( {t(CHANNEL_LABEL_KEYS[ch]) || ch} ))}
{/* 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 (
'64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}> {t(EVENT_LABEL_KEYS[eventType]) || eventType} {visibleChannels.map(ch => { if (!implementedForEvent.includes(ch)) { return } const isOn = matrix.preferences[eventType]?.[ch] ?? true return (
toggle(eventType, ch)} />
) })}
) })}
) } return (
{renderContent()}
) }