246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import {
|
||
|
|
BeakerIcon,
|
||
|
|
BellAlertIcon,
|
||
|
|
CheckCircleIcon,
|
||
|
|
ClockIcon,
|
||
|
|
FireIcon,
|
||
|
|
} from "@heroicons/react/24/outline";
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { useSession } from "@/lib/auth-client";
|
||
|
|
import {
|
||
|
|
ReminderKind,
|
||
|
|
ReminderRule,
|
||
|
|
ReminderSettings,
|
||
|
|
defaultReminderSettings,
|
||
|
|
loadHellthData,
|
||
|
|
saveHellthData,
|
||
|
|
} from "@/lib/hellth-data";
|
||
|
|
|
||
|
|
const reminderMeta: Record<
|
||
|
|
ReminderKind,
|
||
|
|
{ title: string; description: string; icon: typeof BeakerIcon; color: string }
|
||
|
|
> = {
|
||
|
|
water: {
|
||
|
|
title: "Wasser trinken",
|
||
|
|
description: "Regelmäßig kleine Trinkimpulse statt abends alles nachholen.",
|
||
|
|
icon: BeakerIcon,
|
||
|
|
color: "text-blue-500",
|
||
|
|
},
|
||
|
|
snack: {
|
||
|
|
title: "Snack prüfen",
|
||
|
|
description: "Hilft, geplante Snacks bewusst zu essen und ungeplantes Snacken zu vermeiden.",
|
||
|
|
icon: FireIcon,
|
||
|
|
color: "text-orange-500",
|
||
|
|
},
|
||
|
|
movement: {
|
||
|
|
title: "Kurz bewegen",
|
||
|
|
description: "Steh vom Schreibtisch auf, streck dich oder geh ein paar Schritte.",
|
||
|
|
icon: ClockIcon,
|
||
|
|
color: "text-emerald-500",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
function permissionLabel(permission: NotificationPermission | "unsupported") {
|
||
|
|
if (permission === "granted") return "Benachrichtigungen sind erlaubt.";
|
||
|
|
if (permission === "denied") return "Benachrichtigungen sind im Browser blockiert.";
|
||
|
|
if (permission === "unsupported") return "Dieser Browser unterstützt keine Web-Benachrichtigungen.";
|
||
|
|
return "Benachrichtigungen sind noch nicht aktiviert.";
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ErinnerungenPage() {
|
||
|
|
const { data: session } = useSession();
|
||
|
|
const userKey = session?.user?.id ?? session?.user?.email ?? null;
|
||
|
|
const [settings, setSettings] = useState<ReminderSettings>(defaultReminderSettings);
|
||
|
|
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("default");
|
||
|
|
const [saved, setSaved] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!userKey) return;
|
||
|
|
setSettings(loadHellthData(userKey).reminders);
|
||
|
|
setPermission(typeof Notification === "undefined" ? "unsupported" : Notification.permission);
|
||
|
|
}, [userKey]);
|
||
|
|
|
||
|
|
function persist(nextSettings: ReminderSettings) {
|
||
|
|
if (!userKey) return;
|
||
|
|
setSettings(nextSettings);
|
||
|
|
const data = loadHellthData(userKey);
|
||
|
|
saveHellthData({ ...data, reminders: nextSettings }, userKey);
|
||
|
|
setSaved(true);
|
||
|
|
window.setTimeout(() => setSaved(false), 1500);
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateRule(kind: ReminderKind, patch: Partial<ReminderRule>) {
|
||
|
|
persist({
|
||
|
|
...settings,
|
||
|
|
rules: {
|
||
|
|
...settings.rules,
|
||
|
|
[kind]: { ...settings.rules[kind], ...patch },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function requestPermission() {
|
||
|
|
if (typeof Notification === "undefined") {
|
||
|
|
setPermission("unsupported");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const result = await Notification.requestPermission();
|
||
|
|
setPermission(result);
|
||
|
|
persist({
|
||
|
|
...settings,
|
||
|
|
enabled: result === "granted" ? settings.enabled : false,
|
||
|
|
permissionRequestedAt: new Date().toISOString(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function sendTest(kind: ReminderKind) {
|
||
|
|
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
||
|
|
const rule = settings.rules[kind];
|
||
|
|
if ("serviceWorker" in navigator) {
|
||
|
|
try {
|
||
|
|
const registration = await navigator.serviceWorker.ready;
|
||
|
|
await registration.showNotification(`Hellth Hub: ${reminderMeta[kind].title}`, {
|
||
|
|
body: rule.message,
|
||
|
|
tag: `hellth-test-${kind}`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
} catch {
|
||
|
|
// Fall through to regular notification.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
new Notification(`Hellth Hub: ${reminderMeta[kind].title}`, { body: rule.message });
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-5xl space-y-5 pb-24">
|
||
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<div className="flex items-start justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Coach Impulse</p>
|
||
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Erinnerungen</h1>
|
||
|
|
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
|
||
|
|
Kleine Push-Impulse für Wasser, geplante Snacks und Bewegung im Arbeitsalltag.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<BellAlertIcon className="h-10 w-10 text-[#0F766E]" />
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-xl font-black text-gray-950">Benachrichtigungen</h2>
|
||
|
|
<p className="mt-1 text-sm font-semibold text-gray-400">{permissionLabel(permission)}</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex flex-wrap gap-3">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => void requestPermission()}
|
||
|
|
className="rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white"
|
||
|
|
>
|
||
|
|
Berechtigung anfragen
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
disabled={permission !== "granted"}
|
||
|
|
onClick={() => persist({ ...settings, enabled: !settings.enabled })}
|
||
|
|
className={`rounded-full px-5 py-3 text-sm font-black disabled:opacity-40 ${
|
||
|
|
settings.enabled ? "bg-emerald-600 text-white" : "bg-gray-100 text-gray-950"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{settings.enabled ? "Aktiv" : "Pausiert"}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<div className="grid gap-4 lg:grid-cols-3">
|
||
|
|
{(Object.keys(settings.rules) as ReminderKind[]).map((kind) => {
|
||
|
|
const meta = reminderMeta[kind];
|
||
|
|
const rule = settings.rules[kind];
|
||
|
|
return (
|
||
|
|
<section key={kind} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<div className="flex items-start gap-3">
|
||
|
|
<meta.icon className={`h-8 w-8 ${meta.color}`} />
|
||
|
|
<div>
|
||
|
|
<h2 className="text-xl font-black text-gray-950">{meta.title}</h2>
|
||
|
|
<p className="mt-1 text-sm font-semibold text-gray-400">{meta.description}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<label className="mt-5 flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
|
||
|
|
Aktivieren
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={rule.enabled}
|
||
|
|
onChange={(event) => updateRule(kind, { enabled: event.target.checked })}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
|
|
<label className="text-xs font-black uppercase text-gray-400">
|
||
|
|
Von
|
||
|
|
<input
|
||
|
|
type="time"
|
||
|
|
value={rule.startTime}
|
||
|
|
onChange={(event) => updateRule(kind, { startTime: event.target.value })}
|
||
|
|
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="text-xs font-black uppercase text-gray-400">
|
||
|
|
Bis
|
||
|
|
<input
|
||
|
|
type="time"
|
||
|
|
value={rule.endTime}
|
||
|
|
onChange={(event) => updateRule(kind, { endTime: event.target.value })}
|
||
|
|
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<label className="mt-4 block text-xs font-black uppercase text-gray-400">
|
||
|
|
Intervall Minuten
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
min={15}
|
||
|
|
step={15}
|
||
|
|
value={rule.intervalMinutes}
|
||
|
|
onChange={(event) => updateRule(kind, { intervalMinutes: Number(event.target.value) || 60 })}
|
||
|
|
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<label className="mt-4 block text-xs font-black uppercase text-gray-400">
|
||
|
|
Nachricht
|
||
|
|
<textarea
|
||
|
|
value={rule.message}
|
||
|
|
onChange={(event) => updateRule(kind, { message: event.target.value })}
|
||
|
|
className="mt-1 min-h-24 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-semibold normal-case text-gray-950"
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
disabled={permission !== "granted"}
|
||
|
|
onClick={() => void sendTest(kind)}
|
||
|
|
className="mt-4 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40"
|
||
|
|
>
|
||
|
|
Test senden
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{saved ? (
|
||
|
|
<div className="fixed bottom-24 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">
|
||
|
|
<CheckCircleIcon className="h-5 w-5" />
|
||
|
|
Gespeichert
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|