Files
HealthLog/src/components/settings/telegram-card.tsx
T
Marc-André Bombeck 3ebef19ac3 fix(settings): one save model and one confirm per card
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
2026-10-21 19:42:53 +02:00

194 lines
6.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface TelegramSettings {
enabled: boolean;
hasBotToken: boolean;
chatId: string | null;
}
export function TelegramCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.telegramSettings(),
queryFn: async () => {
return apiGet<TelegramSettings>("/api/settings/telegram");
},
enabled: isAuthenticated,
});
// Sync from server using the React-recommended "previous-payload-id"
// pattern instead of setState-in-effect. Reseeds after every save, so the
// switch reads what the server stored.
const settingsKey = settings
? `${settings.chatId ?? ""}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
if (settings!.chatId) setChatId(settings!.chatId);
setEnabled(settings!.enabled);
}
const dirty = isTelegramFormDirty({ botToken, chatId, enabled }, settings);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const body: Record<string, unknown> = { enabled };
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one.
const res = await apiFetchRaw("/api/settings/telegram", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).catch(() => null);
if (res?.ok) {
toastWrittenOutcome("success", t("settings.saved"));
setBotToken("");
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
} else {
const json = (await res?.json().catch(() => null)) as {
error?: string;
} | null;
const message = json?.error || t("settings.savingError");
setError(message);
showErrorToast(message);
}
} finally {
setSaving(false);
}
}
return (
<SettingsCard>
<SettingsCardHeader
icon={MessageCircle}
title={t("settings.telegram")}
description={t("settings.telegramDescription")}
/>
<div className="space-y-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tg-token">{t("settings.botToken")}</Label>
<PasswordInput
id="tg-token"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder={
settings?.hasBotToken
? t("settings.withingsCredentialsSavedPlaceholder")
: "123456:ABC-DEF..."
}
maxLength={100}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tg-chatid">{t("settings.chatId")}</Label>
<Input
id="tg-chatid"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
placeholder="123456789"
maxLength={50}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
id="tg-enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={saving}
/>
<Label htmlFor="tg-enabled" className="cursor-pointer">
{t("settings.enableNotifications")}
</Label>
</div>
<SettingsInfoTile icon={Info} tone="neutral">
<ol className="list-decimal space-y-1 pl-4">
<li>{t("settings.telegramStep1")}</li>
<li>{t("settings.telegramStep2")}</li>
<li>{t("settings.telegramStep3")}</li>
</ol>
</SettingsInfoTile>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/telegram/test"
disabled={!settings?.hasBotToken}
unsavedChanges={dirty}
/>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
);
}