"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, Download, Link2, Loader2, RefreshCw, Save, Smile, Unlink, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/settings/password-input"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import type { IntegrationPillState } from "@/components/settings/integration-status-pill"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; interface MoodLogStatus { configured: boolean; enabled: boolean; lastSyncedAt: string | null; entryCount: number; webhookSecret: string | null; } interface GlobalServiceAvailability { telegramGlobal: boolean; ntfyGlobal: boolean; webPushGlobal: boolean; apiGlobal: boolean; moodLogGlobal: boolean; } // v1.4.15 Phase B2: shared status payload for both integration cards. // v1.4.19 Phase A5: the redundant in-card status banner is gone — the // IntegrationStatusPill now owns state + last-sync presentation, and // the actionable error message is shown inline above the action row. type IntegrationKey = "withings" | "moodlog"; type IntegrationState = | "connected" | "error_transient" | "error_reauth" | "disconnected"; interface IntegrationStatusViewModel { integration: IntegrationKey; state: IntegrationState; lastSuccessAt: string | null; lastAttemptAt: string | null; lastError: string | null; consecutiveFailures: number; configured?: boolean; connected?: boolean; connectedAt?: string | null; legacyLastSyncedAt?: string | null; tokenExpiresAt?: string | null; tokenExpired?: boolean | null; enabled?: boolean; } interface IntegrationStatusEnvelope { threshold: number; integrations: IntegrationStatusViewModel[]; } /** * Shared status fetch for the Settings → Integrations card. Returns * the per-integration view-model AND the global threshold so the * "{n}/{threshold} consecutive failures" string in the UI is single- * sourced from the server. */ function useIntegrationStatuses(enabled: boolean) { return useQuery({ queryKey: ["integrations", "status"], queryFn: async () => { const res = await fetch("/api/integrations/status"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as IntegrationStatusEnvelope; }, enabled, refetchOnWindowFocus: true, }); } function pickStatus( envelope: IntegrationStatusEnvelope | undefined, integration: IntegrationKey, ): IntegrationStatusViewModel | undefined { return envelope?.integrations.find((i) => i.integration === integration); } /** * Collapse the API's four-state machine into the three states the * pill UI cares about: `error_transient` and `error_reauth` both * surface as the same "Error — reconnect" pill, the actionable * difference (whether the user must reconnect vs wait for the next * retry) is conveyed via the inline error text underneath. */ function pillStateFor( status: IntegrationStatusViewModel | undefined, ): IntegrationPillState { if (!status) return "disconnected"; switch (status.state) { case "connected": return "connected"; case "error_transient": case "error_reauth": return "error"; case "disconnected": return "disconnected"; } } /** * Inline actionable error message that surfaces under the pill when a * sync attempt failed. The pill conveys "something is wrong"; this * line tells the user *what* is wrong so they can act on it. Keeping * it deliberately small (one icon + one line) so it doesn't recreate * the v1.4.18 redundant banner Marc removed. */ function IntegrationErrorMessage({ message }: { message: string }) { return (

{message}

); } export function IntegrationsSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const { data: globalServices } = useQuery({ queryKey: ["settings", "global-services"], queryFn: async () => { const res = await fetch("/api/settings/global-services"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as GlobalServiceAvailability; }, enabled: isAuthenticated, }); const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); const moodLogEnabled = globalServices?.moodLogGlobal ?? true; const withingsViewModel = pickStatus(integrationStatus, "withings"); const moodLogViewModel = pickStatus(integrationStatus, "moodlog"); return (

{t("settings.sections.integrations.title")}

{t("settings.sections.integrations.description")}

{moodLogEnabled && }
); } function WithingsCard({ isAuthenticated, viewModel, }: { isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( null, ); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); const [credsMsg, setCredsMsg] = useState(null); const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( null, ); const queryClient = useQueryClient(); const { data: status } = useQuery({ queryKey: ["withings", "status"], queryFn: async () => { const res = await fetch("/api/withings/status"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as { connected: boolean; configured: boolean; lastSyncedAt?: string | null; connectedAt?: string; tokenExpired?: boolean; }; }, enabled: isAuthenticated, }); const disconnect = useMutation({ mutationFn: async () => { const res = await fetch("/api/withings/disconnect", { method: "POST" }); if (!res.ok) throw new Error("Failed"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["withings"] }); queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); }, }); async function handleSync(fullSync = false) { setSyncing(true); setSyncMsg(null); setSyncMsgType(null); try { const res = await fetch("/api/withings/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fullSync }), }); const json = await res.json(); if (res.ok) { setSyncMsg( fullSync ? t("settings.withingsFullSyncResult", { count: json.data.imported, }) : t("settings.withingsSyncResult", { count: json.data.imported }), ); setSyncMsgType("success"); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); } else { setSyncMsg(json.error || t("settings.withingsSyncFailed")); setSyncMsgType("error"); } } catch { setSyncMsg(t("settings.withingsSyncFailed")); setSyncMsgType("error"); } finally { setSyncing(false); } } async function handleSaveCredentials(e: React.FormEvent) { e.preventDefault(); setCredsSaving(true); setCredsMsg(null); setCredsMsgType(null); try { const res = await fetch("/api/withings/credentials", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim(), }), }); if (res.ok) { setCredsMsg(t("settings.withingsCredentialsSaved")); setCredsMsgType("success"); setClientId(""); setClientSecret(""); queryClient.invalidateQueries({ queryKey: ["withings"] }); } else { try { const json = await res.json(); setCredsMsg(json.error || t("settings.savingError")); } catch { setCredsMsg(t("settings.savingError")); } setCredsMsgType("error"); } } catch { setCredsMsg(t("common.networkError")); setCredsMsgType("error"); } setCredsSaving(false); } // The pill state derives from the cross-integration status // envelope, but the per-card `lastSyncedAt` is sourced from the // Withings-specific endpoint so it stays accurate immediately // after a manual "Sync now" (which only invalidates the per-card // query). When neither endpoint has answered yet we fall back to // "disconnected" so the card never renders a status-less header. const pillState: IntegrationPillState = status?.connected ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = pillState === "error" && viewModel?.lastError ? viewModel.lastError : null; return (

{t("settings.withings")}

{t("settings.withingsDescription")}


{errorMessage && }

{t("settings.withingsCredentials")}

setClientId(e.target.value)} placeholder={ status?.configured ? t("settings.withingsCredentialsSavedPlaceholder") : t("settings.withingsClientId") } maxLength={200} />
setClientSecret(e.target.value)} placeholder={ status?.configured ? t("settings.withingsCredentialsSavedPlaceholderSecret") : t("settings.withingsClientSecret") } maxLength={200} />
{credsMsg && (

{credsMsg}

)}
{status?.connected ? ( <>
{t("settings.withingsFullSyncTitle")} {t("settings.withingsFullSyncDescription")} {t("common.cancel")} handleSync(true)}> {t("settings.withingsSynchronize")} {t("settings.withingsDisconnectTitle")} {t("settings.withingsDisconnectDescription")} {t("common.cancel")} disconnect.mutate()} > {t("settings.withingsDisconnect")}
{syncMsg && (

{syncMsg}

)} ) : status?.configured ? ( ) : (
{t("settings.withingsNoCredentials")}
)}
); } function MoodLogCard({ viewModel, }: { viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [url, setUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [saving, setSaving] = useState(false); const [syncing, setSyncing] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const { data: status, refetch: refetchStatus } = useQuery({ queryKey: ["moodlog-status"], queryFn: async () => { const res = await fetch("/api/integrations/moodlog/status"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as MoodLogStatus; }, }); async function handleSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); setMsg(null); const res = await fetch("/api/settings/moodlog", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }), }); if (res.ok) { setMsg(t("settings.moodLogSaved")); setMsgType("success"); setUrl(""); setApiKey(""); await refetchStatus(); queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); } else { const json = await res.json(); setMsg(json.error || t("settings.savingError")); setMsgType("error"); } setSaving(false); } async function handleSync(fullSync = false) { setSyncing(true); setMsg(null); try { const res = await fetch("/api/integrations/moodlog/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fullSync }), }); if (res.ok) { const json = await res.json(); setMsg( t("settings.moodLogSyncResult").replace( "{count}", String(json.data.imported), ), ); setMsgType("success"); await refetchStatus(); queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); } else { setMsg(t("settings.moodLogSyncFailed")); setMsgType("error"); } } catch { setMsg(t("settings.moodLogSyncFailed")); setMsgType("error"); } setSyncing(false); } async function handleDisconnect() { const res = await fetch("/api/settings/moodlog", { method: "DELETE" }); if (res.ok) { setMsg(t("settings.moodLogDisconnected")); setMsgType("success"); await refetchStatus(); queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); } } // Mirror Withings logic: the pill state comes from the cross- // integration envelope; per-card `lastSyncedAt` from the Mood Log // endpoint so a manual sync paints fresh "X min ago" without // waiting for the envelope to refetch. const pillState: IntegrationPillState = status?.configured ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = pillState === "error" && viewModel?.lastError ? viewModel.lastError : null; return (

{t("settings.moodLogDescription")}

{/* v1.4.19 A5 — visual divider matches Withings for consistency (Marc explicitly called the asymmetry out). */}
{errorMessage && }
setUrl(e.target.value)} />
setApiKey(e.target.value)} />
{status?.configured && (
{status.webhookSecret && (

{t("settings.moodLogWebhookSecretHelp")}

)} {/* v1.4.19 A5 — The "letzter Sync" line that used to live here is gone. The pill in the card header already carries that information; repeating it inside the body is the redundancy Marc flagged. The entry-count is the only number we still surface because it's an integration-specific datapoint the pill cannot convey. */}
{t("settings.moodLogEntries")}: {status.entryCount}
{t("settings.moodLogFullSyncTitle")} {t("settings.moodLogFullSyncDescription")} {t("common.cancel")} handleSync(true)}> {t("settings.moodLogFullSync")} {/* v1.4.3: aligned with the Withings disconnect button — outline + text-destructive instead of solid destructive so the trigger reads as a reversible action; the actual "yes, disconnect" confirmation inside the dialog keeps its solid-red treatment. */} {t("settings.moodLogDisconnectTitle")} {t("settings.moodLogDisconnectDescription")} {t("common.cancel")} {t("settings.moodLogDisconnect")}
)} {msg && (

{msg}

)}
); }