Files
Synculous-2/synculous-client/src/app/dashboard/settings/page.tsx
chelsea 1cb929a776 Switch Discord notifications from webhook to user ID DMs
Uses the existing bot token to send DMs to users by their Discord user ID
instead of posting to a channel webhook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:14:46 -06:00

278 lines
10 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { VolumeIcon, VolumeOffIcon, SparklesIcon } from '@/components/ui/Icons';
import { playStepComplete } from '@/lib/sounds';
import { hapticTap } from '@/lib/haptics';
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
interface Preferences {
sound_enabled: boolean;
haptic_enabled: boolean;
show_launch_screen: boolean;
celebration_style: string;
}
interface NotifSettings {
discord_user_id: string;
discord_enabled: boolean;
ntfy_topic: string;
ntfy_enabled: boolean;
}
export default function SettingsPage() {
const [prefs, setPrefs] = useState<Preferences>({
sound_enabled: false,
haptic_enabled: true,
show_launch_screen: true,
celebration_style: 'standard',
});
const [notif, setNotif] = useState<NotifSettings>({
discord_user_id: '',
discord_enabled: false,
ntfy_topic: '',
ntfy_enabled: false,
});
const [isLoading, setIsLoading] = useState(true);
const [saved, setSaved] = useState(false);
useEffect(() => {
Promise.all([
api.preferences.get().then((data: Preferences) => setPrefs(data)),
api.notifications.getSettings().then((data) => setNotif({
discord_user_id: data.discord_user_id,
discord_enabled: data.discord_enabled,
ntfy_topic: data.ntfy_topic,
ntfy_enabled: data.ntfy_enabled,
})),
])
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const flashSaved = () => {
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
const updatePref = async (key: keyof Preferences, value: boolean | string) => {
const updated = { ...prefs, [key]: value };
setPrefs(updated);
try {
await api.preferences.update({ [key]: value });
flashSaved();
} catch {
setPrefs(prefs);
}
};
const updateNotif = async (updates: Partial<NotifSettings>) => {
const prev = { ...notif };
const updated = { ...notif, ...updates };
setNotif(updated);
try {
await api.notifications.updateSettings(updates);
flashSaved();
} catch {
setNotif(prev);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
{saved && (
<span className="text-sm text-green-600 animate-fade-in-up">Saved</span>
)}
</div>
{/* Session Experience */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Session Experience</h2>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
{/* Sound */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
{prefs.sound_enabled ? (
<VolumeIcon size={20} className="text-indigo-500" />
) : (
<VolumeOffIcon size={20} className="text-gray-400" />
)}
<div>
<p className="font-medium text-gray-900">Sound effects</p>
<p className="text-sm text-gray-500">Subtle audio cues on step completion</p>
</div>
</div>
<button
onClick={() => {
updatePref('sound_enabled', !prefs.sound_enabled);
if (!prefs.sound_enabled) playStepComplete();
}}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.sound_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.sound_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Haptics */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<SparklesIcon size={20} className={prefs.haptic_enabled ? 'text-indigo-500' : 'text-gray-400'} />
<div>
<p className="font-medium text-gray-900">Haptic feedback</p>
<p className="text-sm text-gray-500">Gentle vibration on actions</p>
</div>
</div>
<button
onClick={() => {
updatePref('haptic_enabled', !prefs.haptic_enabled);
if (!prefs.haptic_enabled) hapticTap();
}}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.haptic_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.haptic_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Launch Screen */}
<div className="flex items-center justify-between p-4">
<div>
<p className="font-medium text-gray-900">Pre-routine launch screen</p>
<p className="text-sm text-gray-500">Environment check and emotion bridge</p>
</div>
<button
onClick={() => updatePref('show_launch_screen', !prefs.show_launch_screen)}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.show_launch_screen ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.show_launch_screen ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
</div>
{/* Notifications */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Notifications</h2>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
{/* Push Notifications */}
<PushNotificationToggle />
{/* ntfy */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">ntfy</p>
<p className="text-sm text-gray-500">Push notifications via ntfy.sh</p>
</div>
<button
onClick={() => updateNotif({ ntfy_enabled: !notif.ntfy_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
notif.ntfy_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
notif.ntfy_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{notif.ntfy_enabled && (
<input
type="text"
placeholder="Your ntfy topic ID"
value={notif.ntfy_topic}
onChange={(e) => setNotif({ ...notif, ntfy_topic: e.target.value })}
onBlur={() => updateNotif({ ntfy_topic: notif.ntfy_topic })}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400"
/>
)}
</div>
{/* Discord */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Discord</p>
<p className="text-sm text-gray-500">Get DMs from the Synculous bot</p>
</div>
<button
onClick={() => updateNotif({ discord_enabled: !notif.discord_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
notif.discord_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
notif.discord_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{notif.discord_enabled && (
<input
type="text"
placeholder="Your Discord user ID"
value={notif.discord_user_id}
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400"
/>
)}
</div>
</div>
</div>
{/* Celebration Style */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Celebration Style</h2>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
{[
{ value: 'standard', label: 'Standard', desc: 'Full animated celebration with stats and rewards' },
{ value: 'quick', label: 'Quick', desc: 'Brief confirmation, then back to dashboard' },
{ value: 'none', label: 'None', desc: 'No celebration screen, return immediately' },
].map(option => (
<button
key={option.value}
onClick={() => updatePref('celebration_style', option.value)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div>
<p className="font-medium text-gray-900">{option.label}</p>
<p className="text-sm text-gray-500">{option.desc}</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
prefs.celebration_style === option.value
? 'border-indigo-500'
: 'border-gray-300'
}`}>
{prefs.celebration_style === option.value && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
))}
</div>
</div>
</div>
);
}