diff --git a/messages/de.json b/messages/de.json index 4b71b036d..8bd10c0ba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3856,11 +3856,24 @@ }, "integrations": { "title": "Integrationen", - "description": "Withings, moodLog und weitere verbundene Dienste." + "description": "Withings, moodLog und weitere verbundene Dienste.", + "tabs": { + "connections": "Verbindungen", + "channels": "Kanäle", + "sources": "Quellen" + }, + "connections": { + "description": "Withings, WHOOP, Fitbit, Polar, Oura und Nightscout verbinden." + }, + "channels": { + "description": "Telegram, ntfy, Web-Push, Webhook und E-Mail – die Wege, über die Benachrichtigungen zugestellt werden." + } }, "notifications": { "title": "Benachrichtigungen", - "description": "Live-Übersicht aller konfigurierten Kanäle." + "description": "Welche Erinnerungen ausgelöst werden. Die Zustellkanäle liegen unter Integrationen.", + "channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter", + "channelsHintLink": "Integrationen → Kanäle" }, "layout": { "title": "Layout & Personalisierung", diff --git a/messages/en.json b/messages/en.json index 2020367c1..d63cc8d01 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3856,11 +3856,24 @@ }, "integrations": { "title": "Integrations", - "description": "Withings, moodLog, and other connected services." + "description": "Withings, moodLog, and other connected services.", + "tabs": { + "connections": "Connections", + "channels": "Channels", + "sources": "Sources" + }, + "connections": { + "description": "Connect Withings, WHOOP, Fitbit, Polar, Oura, and Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook, and Email — the routes notifications are delivered through." + } }, "notifications": { "title": "Notifications", - "description": "Live overview of every configured channel." + "description": "Which reminders fire. The delivery channels live under Integrations.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under", + "channelsHintLink": "Integrations → Channels" }, "layout": { "title": "Layout & Personalization", diff --git a/messages/es.json b/messages/es.json index 84a8ec7fa..58fb8e67b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3856,11 +3856,24 @@ }, "integrations": { "title": "Integraciones", - "description": "Withings, moodLog y otros servicios conectados." + "description": "Withings, moodLog y otros servicios conectados.", + "tabs": { + "connections": "Conexiones", + "channels": "Canales", + "sources": "Fuentes" + }, + "connections": { + "description": "Conecta Withings, WHOOP, Fitbit, Polar, Oura y Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook y correo: las vías por las que se entregan las notificaciones." + } }, "notifications": { "title": "Notificaciones", - "description": "Vista en vivo de cada canal configurado." + "description": "Qué recordatorios se activan. Los canales de entrega están en Integraciones.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en", + "channelsHintLink": "Integraciones → Canales" }, "layout": { "title": "Diseño y personalización", diff --git a/messages/fr.json b/messages/fr.json index 41658b9ab..90f37fe30 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3856,11 +3856,24 @@ }, "integrations": { "title": "Intégrations", - "description": "Withings, moodLog et autres services connectés." + "description": "Withings, moodLog et autres services connectés.", + "tabs": { + "connections": "Connexions", + "channels": "Canaux", + "sources": "Sources" + }, + "connections": { + "description": "Connecter Withings, WHOOP, Fitbit, Polar, Oura et Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook et e-mail : les voies par lesquelles les notifications sont envoyées." + } }, "notifications": { "title": "Notifications", - "description": "Aperçu en direct de chaque canal configuré." + "description": "Quels rappels se déclenchent. Les canaux de diffusion se trouvent dans Intégrations.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans", + "channelsHintLink": "Intégrations → Canaux" }, "layout": { "title": "Disposition et personnalisation", diff --git a/messages/it.json b/messages/it.json index 79a1f8b2e..78fbc6e9e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3856,11 +3856,24 @@ }, "integrations": { "title": "Integrazioni", - "description": "Withings, moodLog e altri servizi collegati." + "description": "Withings, moodLog e altri servizi collegati.", + "tabs": { + "connections": "Connessioni", + "channels": "Canali", + "sources": "Origini" + }, + "connections": { + "description": "Collega Withings, WHOOP, Fitbit, Polar, Oura e Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook ed e-mail: le vie attraverso cui vengono recapitate le notifiche." + } }, "notifications": { "title": "Notifiche", - "description": "Panoramica in tempo reale di ogni canale configurato." + "description": "Quali promemoria vengono attivati. I canali di recapito si trovano in Integrazioni.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in", + "channelsHintLink": "Integrazioni → Canali" }, "layout": { "title": "Layout e personalizzazione", diff --git a/messages/pl.json b/messages/pl.json index 1fabc2b51..c85a7106f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3856,11 +3856,24 @@ }, "integrations": { "title": "Integracje", - "description": "Withings, moodLog i inne połączone usługi." + "description": "Withings, moodLog i inne połączone usługi.", + "tabs": { + "connections": "Połączenia", + "channels": "Kanały", + "sources": "Źródła" + }, + "connections": { + "description": "Połącz Withings, WHOOP, Fitbit, Polar, Oura i Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook i e-mail — drogi dostarczania powiadomień." + } }, "notifications": { "title": "Powiadomienia", - "description": "Podgląd na żywo każdego skonfigurowanego kanału." + "description": "Które przypomnienia są uruchamiane. Kanały dostarczania znajdują się w Integracjach.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w", + "channelsHintLink": "Integracje → Kanały" }, "layout": { "title": "Układ i personalizacja", diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 7484da40f..dfebc9b88 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -1,234 +1,38 @@ "use client"; -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; +/** + * `` — Settings → Integrations. + * + * v1.18.0 (S3) — Integrations is now the one home for every delivery / ingest + * connection. Three sub-tabs under one nav entry: + * 1. Connections — Withings / WHOOP / Fitbit / Polar / Oura / Nightscout + * (the OAuth + device data sources). See `connections-panel.tsx`. + * 2. Channels — Telegram / ntfy / Web Push / Webhook / Email plus the live + * per-channel status surface (moved here from Settings → Notifications, + * where it never belonged: channels are delivery providers, not + * reminder preferences). See `notification-channels-panel.tsx`. + * + * `parseOAuthOutcome` / `oauthReasonKey` are re-exported from + * `connections-panel.tsx` so existing unit-test imports keep resolving. + */ -import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; -import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; -import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; -import { OuraCard } from "@/components/settings/integrations/oura-card"; -import { PolarCard } from "@/components/settings/integrations/polar-card"; -import { - pickStatus, - useIntegrationStatuses, - type IntegrationStatusViewModel, -} from "@/components/settings/integrations/shared"; -import { WhoopCard } from "@/components/settings/integrations/whoop-card"; -import { WithingsCard } from "@/components/settings/integrations/withings-card"; -import { useAuth } from "@/hooks/use-auth"; +import { useState } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; +import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -/** - * The Withings OAuth callback (`/api/withings/callback`) redirects back - * here with `?withings=connected` or `?withings=error&reason=`. - * Map every reason tag the callback emits onto a human-readable i18n - * key (what went wrong + what to do next). Unknown tags fall back to - * the generic entry so a future callback branch never strands the user - * with silent params. - */ -const WITHINGS_OAUTH_ERROR_KEYS: Record = { - csrf1: "settings.withingsOauthError.csrf1", - replay: "settings.withingsOauthError.replay", - state: "settings.withingsOauthError.state", - expired: "settings.withingsOauthError.expired", - cross_user: "settings.withingsOauthError.cross_user", - nocode: "settings.withingsOauthError.nocode", - nocreds: "settings.withingsOauthError.nocreds", - token: "settings.withingsOauthError.token", -}; +export { + parseOAuthOutcome, + oauthReasonKey, +} from "@/components/settings/integrations/connections-panel"; -type WithingsOauthOutcome = - | { kind: "connected" } - | { kind: "error"; reason: string }; - -/** - * The OAuth providers whose callbacks redirect back to the settings page with a - * `?=connected|error&reason=` outcome param. Polar/Oura own a - * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both - * are invalidated on a successful return so the card repaints either way. - */ -const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const; -type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number]; - -const OAUTH_OUTCOME_KEYS: Record< - OAuthOutcomeProvider, - () => readonly unknown[] -> = { - polar: queryKeys.polar, - oura: queryKeys.oura, - whoop: queryKeys.whoop, - fitbit: queryKeys.fitbit, -}; - -/** - * Reason tags the four OAuth callbacks emit. Known tags resolve to a specific - * message; anything else falls back to the provider's `generic` copy. Union of - * the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`). - */ -const OAUTH_OUTCOME_REASONS = new Set([ - "csrf1", - "state", - "cross_user", - "nocode", - "nocreds", - "token", - "rate_limited", - "expired", -]); - -type OAuthOutcome = - | { provider: OAuthOutcomeProvider; kind: "connected" } - | { provider: OAuthOutcomeProvider; kind: "error"; reason: string }; - -/** - * Parse the OAuth-return outcome from a URL query string. Reads the first - * provider whose `?=connected|error` param is present, in - * `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider - * coverage is unit-testable without a browser. - */ -export function parseOAuthOutcome(search: string): OAuthOutcome | null { - const params = new URLSearchParams(search); - for (const provider of OAUTH_OUTCOME_PROVIDERS) { - const v = params.get(provider); - if (v === "connected") return { provider, kind: "connected" }; - if (v === "error") { - return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; - } - } - return null; -} - -/** - * Resolve the i18n key for an error reason tag. Known tags map to the - * provider-specific message; anything else falls back to `generic`. - */ -export function oauthReasonKey( - provider: OAuthOutcomeProvider, - reason: string, -): string { - return OAUTH_OUTCOME_REASONS.has(reason) - ? `settings.${provider}OauthError.${reason}` - : `settings.${provider}OauthError.generic`; -} - -/** - * Adapt the consolidated-envelope view-model into the OAuth card's status - * shape. Returns `undefined` when the envelope hasn't loaded so the card - * renders its loading/disconnected default rather than a half-populated state. - */ -function toOAuthStatus( - vm: IntegrationStatusViewModel | undefined, -): OAuthProviderStatus | undefined { - if (!vm) return undefined; - return { - connected: vm.connected ?? false, - configured: vm.configured ?? false, - available: vm.available ?? false, - hasOwnCredentials: vm.hasOwnCredentials, - state: vm.state, - lastSuccessAt: vm.lastSuccessAt, - lastAttemptAt: vm.lastAttemptAt, - lastError: vm.lastError, - }; -} +type IntegrationsTab = "connections" | "channels"; export function IntegrationsSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); - const router = useRouter(); - const queryClient = useQueryClient(); - - const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); - - // OAuth callback handler — reads `?withings=connected|error&reason=…` - // from the URL (lazy initialiser, same shape as the Codex handler in - // `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the - // callback set these params and nothing ever read them: a user came - // back from Withings onto a silently unchanged settings page. - const [withingsOauthOutcome] = useState(() => { - if (typeof window === "undefined") return null; - const params = new URLSearchParams(window.location.search); - const status = params.get("withings"); - if (status === "connected") return { kind: "connected" }; - if (status === "error") { - return { kind: "error", reason: params.get("reason") ?? "unknown" }; - } - return null; - }); - - useEffect(() => { - if (!withingsOauthOutcome) return; - // Scrub the one-shot params so a reload / bookmark doesn't replay - // the toast. - const url = new URL(window.location.href); - url.searchParams.delete("withings"); - url.searchParams.delete("reason"); - router.replace(`${url.pathname}${url.search}`, { scroll: false }); - if (withingsOauthOutcome.kind === "connected") { - toast.success(t("settings.withingsOauthConnected")); - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - const reasonKey = - WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? - "settings.withingsOauthError.generic"; - toast.error(t("settings.withingsOauthFailed"), { - description: t(reasonKey), - duration: 10_000, - }); - } - }, [withingsOauthOutcome, router, queryClient, t]); - - // v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers. - // The Polar / Oura / WHOOP / Fitbit callbacks redirect back with - // `?=connected` or `?=error&reason=`; surface the - // outcome as a toast and scrub the one-shot params so a reload doesn't replay - // it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a - // WHOOP or Fitbit round-trip landed on a silently unchanged settings page, - // the same gap the Withings handler was written to close. - const [oauthOutcome] = useState(() => { - if (typeof window === "undefined") return null; - return parseOAuthOutcome(window.location.search); - }); - - useEffect(() => { - if (!oauthOutcome) return; - const { provider } = oauthOutcome; - const url = new URL(window.location.href); - url.searchParams.delete(provider); - url.searchParams.delete("reason"); - router.replace(`${url.pathname}${url.search}`, { scroll: false }); - if (oauthOutcome.kind === "connected") { - toast.success(t(`settings.${provider}OauthConnected`)); - // Polar/Oura own a per-card status query; WHOOP/Fitbit read off the - // consolidated envelope — invalidate both so the card repaints either way. - queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - // Known reason tags resolve to a specific message; anything else falls - // back to the generic copy (matching the Withings handler). - toast.error(t(`settings.${provider}OauthFailed`), { - description: t(oauthReasonKey(provider, oauthOutcome.reason)), - duration: 10_000, - }); - } - }, [oauthOutcome, router, queryClient, t]); - - const withingsViewModel = pickStatus(integrationStatus, "withings"); - const whoopViewModel = pickStatus(integrationStatus, "whoop"); - const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); - // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards - // no longer fire their own /api//status round-trip. - const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); - const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); + const [tab, setTab] = useState("connections"); return (
{t("settings.sections.integrations.description")}

- {/* Cross-link to Settings → Sources: when two integrations (or - an integration + manual entry) report the same metric, the - source-priority ladder decides which value counts — a fact - newcomers otherwise discover only after a confusing chart. */} -

- {t("settings.integrationsSourcesHint")}{" "} - - {t("settings.integrationsSourcesHintLink")} - -

- - - - - - + setTab(value as IntegrationsTab)} + > + + + {t("settings.sections.integrations.tabs.connections")} + + + {t("settings.sections.integrations.tabs.channels")} + + + + + + + + + +
); } diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx new file mode 100644 index 000000000..d7ec368fc --- /dev/null +++ b/src/components/settings/integrations/connections-panel.tsx @@ -0,0 +1,257 @@ +"use client"; + +/** + * `` — the connected-services half of Settings → + * Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout. + * + * v1.18.0 (S3) — extracted out of `integrations-section.tsx` so the section + * file can host the Connections / Channels / Sources sub-tabs without growing + * unbounded. This panel owns the OAuth-callback toast handlers (the four + * providers + Withings redirect back here with `?=…` params) and the + * card grid. It renders no page header of its own; the parent supplies it. + */ + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; +import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; +import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; +import { OuraCard } from "@/components/settings/integrations/oura-card"; +import { PolarCard } from "@/components/settings/integrations/polar-card"; +import { + pickStatus, + useIntegrationStatuses, + type IntegrationStatusViewModel, +} from "@/components/settings/integrations/shared"; +import { WhoopCard } from "@/components/settings/integrations/whoop-card"; +import { WithingsCard } from "@/components/settings/integrations/withings-card"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * The Withings OAuth callback (`/api/withings/callback`) redirects back + * here with `?withings=connected` or `?withings=error&reason=`. + * Map every reason tag the callback emits onto a human-readable i18n + * key (what went wrong + what to do next). Unknown tags fall back to + * the generic entry so a future callback branch never strands the user + * with silent params. + */ +const WITHINGS_OAUTH_ERROR_KEYS: Record = { + csrf1: "settings.withingsOauthError.csrf1", + replay: "settings.withingsOauthError.replay", + state: "settings.withingsOauthError.state", + expired: "settings.withingsOauthError.expired", + cross_user: "settings.withingsOauthError.cross_user", + nocode: "settings.withingsOauthError.nocode", + nocreds: "settings.withingsOauthError.nocreds", + token: "settings.withingsOauthError.token", +}; + +type WithingsOauthOutcome = + | { kind: "connected" } + | { kind: "error"; reason: string }; + +/** + * The OAuth providers whose callbacks redirect back to the settings page with a + * `?=connected|error&reason=` outcome param. Polar/Oura own a + * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both + * are invalidated on a successful return so the card repaints either way. + */ +const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const; +type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number]; + +const OAUTH_OUTCOME_KEYS: Record< + OAuthOutcomeProvider, + () => readonly unknown[] +> = { + polar: queryKeys.polar, + oura: queryKeys.oura, + whoop: queryKeys.whoop, + fitbit: queryKeys.fitbit, +}; + +/** + * Reason tags the four OAuth callbacks emit. Known tags resolve to a specific + * message; anything else falls back to the provider's `generic` copy. Union of + * the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`). + */ +const OAUTH_OUTCOME_REASONS = new Set([ + "csrf1", + "state", + "cross_user", + "nocode", + "nocreds", + "token", + "rate_limited", + "expired", +]); + +type OAuthOutcome = + | { provider: OAuthOutcomeProvider; kind: "connected" } + | { provider: OAuthOutcomeProvider; kind: "error"; reason: string }; + +/** + * Parse the OAuth-return outcome from a URL query string. Reads the first + * provider whose `?=connected|error` param is present, in + * `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider + * coverage is unit-testable without a browser. + */ +export function parseOAuthOutcome(search: string): OAuthOutcome | null { + const params = new URLSearchParams(search); + for (const provider of OAUTH_OUTCOME_PROVIDERS) { + const v = params.get(provider); + if (v === "connected") return { provider, kind: "connected" }; + if (v === "error") { + return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; + } + } + return null; +} + +/** + * Resolve the i18n key for an error reason tag. Known tags map to the + * provider-specific message; anything else falls back to `generic`. + */ +export function oauthReasonKey( + provider: OAuthOutcomeProvider, + reason: string, +): string { + return OAUTH_OUTCOME_REASONS.has(reason) + ? `settings.${provider}OauthError.${reason}` + : `settings.${provider}OauthError.generic`; +} + +/** + * Adapt the consolidated-envelope view-model into the OAuth card's status + * shape. Returns `undefined` when the envelope hasn't loaded so the card + * renders its loading/disconnected default rather than a half-populated state. + */ +function toOAuthStatus( + vm: IntegrationStatusViewModel | undefined, +): OAuthProviderStatus | undefined { + if (!vm) return undefined; + return { + connected: vm.connected ?? false, + configured: vm.configured ?? false, + available: vm.available ?? false, + hasOwnCredentials: vm.hasOwnCredentials, + state: vm.state, + lastSuccessAt: vm.lastSuccessAt, + lastAttemptAt: vm.lastAttemptAt, + lastError: vm.lastError, + }; +} + +export function ConnectionsPanel() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + + // OAuth callback handler — reads `?withings=connected|error&reason=…` + // from the URL (lazy initialiser, same shape as the Codex handler in + // `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the + // callback set these params and nothing ever read them: a user came + // back from Withings onto a silently unchanged settings page. + const [withingsOauthOutcome] = useState(() => { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const status = params.get("withings"); + if (status === "connected") return { kind: "connected" }; + if (status === "error") { + return { kind: "error", reason: params.get("reason") ?? "unknown" }; + } + return null; + }); + + useEffect(() => { + if (!withingsOauthOutcome) return; + // Scrub the one-shot params so a reload / bookmark doesn't replay + // the toast. + const url = new URL(window.location.href); + url.searchParams.delete("withings"); + url.searchParams.delete("reason"); + router.replace(`${url.pathname}${url.search}`, { scroll: false }); + if (withingsOauthOutcome.kind === "connected") { + toast.success(t("settings.withingsOauthConnected")); + queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + const reasonKey = + WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? + "settings.withingsOauthError.generic"; + toast.error(t("settings.withingsOauthFailed"), { + description: t(reasonKey), + duration: 10_000, + }); + } + }, [withingsOauthOutcome, router, queryClient, t]); + + // v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers. + // The Polar / Oura / WHOOP / Fitbit callbacks redirect back with + // `?=connected` or `?=error&reason=`; surface the + // outcome as a toast and scrub the one-shot params so a reload doesn't replay + // it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a + // WHOOP or Fitbit round-trip landed on a silently unchanged settings page, + // the same gap the Withings handler was written to close. + const [oauthOutcome] = useState(() => { + if (typeof window === "undefined") return null; + return parseOAuthOutcome(window.location.search); + }); + + useEffect(() => { + if (!oauthOutcome) return; + const { provider } = oauthOutcome; + const url = new URL(window.location.href); + url.searchParams.delete(provider); + url.searchParams.delete("reason"); + router.replace(`${url.pathname}${url.search}`, { scroll: false }); + if (oauthOutcome.kind === "connected") { + toast.success(t(`settings.${provider}OauthConnected`)); + // Polar/Oura own a per-card status query; WHOOP/Fitbit read off the + // consolidated envelope — invalidate both so the card repaints either way. + queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + // Known reason tags resolve to a specific message; anything else falls + // back to the generic copy (matching the Withings handler). + toast.error(t(`settings.${provider}OauthFailed`), { + description: t(oauthReasonKey(provider, oauthOutcome.reason)), + duration: 10_000, + }); + } + }, [oauthOutcome, router, queryClient, t]); + + const withingsViewModel = pickStatus(integrationStatus, "withings"); + const whoopViewModel = pickStatus(integrationStatus, "whoop"); + const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); + // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards + // no longer fire their own /api//status round-trip. + const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); + const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); + + return ( +
+

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

+ + + + + + + +
+ ); +} diff --git a/src/components/settings/integrations/notification-channels-panel.tsx b/src/components/settings/integrations/notification-channels-panel.tsx new file mode 100644 index 000000000..0faf9a4a8 --- /dev/null +++ b/src/components/settings/integrations/notification-channels-panel.tsx @@ -0,0 +1,93 @@ +"use client"; + +/** + * `` — the delivery-provider half of Settings → + * Integrations. + * + * v1.18.0 (S3) — the notification CHANNELS (Telegram / ntfy / Web Push / + * Webhook / Email, plus the live per-channel status surface) moved out of + * Settings → Notifications and into Integrations. Channels are delivery + * providers — the same conceptual family as Withings / WHOOP / a connected + * device — not notification preferences. The reminder-TYPE content (which + * events fire) stays under Settings → Reminders / Notifications. + * + * Renders inside the Integrations "Channels" sub-tab. Owns no page header of + * its own; the parent section supplies it. + */ + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { NotificationStatusCard } from "@/components/settings/notification-status-card"; +import { NtfyCard } from "@/components/settings/ntfy-card"; +import { TelegramCard } from "@/components/settings/telegram-card"; +import { WebPushCard } from "@/components/settings/web-push-card"; +import { WebhookCard } from "@/components/settings/webhook-card"; +import { EmailCard } from "@/components/settings/email-card"; +import { apiGet } from "@/lib/api/api-fetch"; + +interface GlobalServiceAvailability { + telegramGlobal: boolean; + ntfyGlobal: boolean; + webPushGlobal: boolean; + apiGlobal: boolean; + moodLogGlobal: boolean; +} + +export function NotificationChannelsPanel() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data: globalServices } = useQuery({ + queryKey: queryKeys.settingsGlobalServices(), + queryFn: async () => { + return apiGet("/api/settings/global-services"); + }, + enabled: isAuthenticated, + }); + + const showTelegram = globalServices?.telegramGlobal ?? true; + const showNtfy = globalServices?.ntfyGlobal ?? true; + const showWebPush = globalServices?.webPushGlobal ?? true; + + return ( +
+

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

+ + + + {/* v1.4.27 MB3 — `scroll-mt-28` keeps each channel card's anchor + target below the safe-area sticky page header when the user lands + on `/settings/integrations#telegram` or similar. */} + {showTelegram && ( +
+ +
+ )} + {showNtfy && ( +
+ +
+ )} + {showWebPush && ( +
+ +
+ )} + {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack / + Matrix / Home Assistant in one channel). */} +
+ +
+ {/* v1.17.1 — SMTP / email. The card hides itself when the operator + hasn't configured SMTP_* env, so it never shows a dead toggle. */} +
+ +
+
+ ); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 600569109..35add96c8 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,56 +1,37 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { ChevronRight } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; -import { NotificationStatusCard } from "@/components/settings/notification-status-card"; -import { NtfyCard } from "@/components/settings/ntfy-card"; -import { TelegramCard } from "@/components/settings/telegram-card"; -import { WebPushCard } from "@/components/settings/web-push-card"; -import { WebhookCard } from "@/components/settings/webhook-card"; -import { EmailCard } from "@/components/settings/email-card"; -import { apiGet } from "@/lib/api/api-fetch"; - -interface GlobalServiceAvailability { - telegramGlobal: boolean; - ntfyGlobal: boolean; - webPushGlobal: boolean; - apiGlobal: boolean; - moodLogGlobal: boolean; -} +/** + * `` — Settings → Notifications. + * + * v1.18.0 (S3) — the delivery CHANNELS (Telegram / ntfy / Web Push / Webhook / + * Email + the live per-channel status surface) moved to Settings → + * Integrations → Channels: a channel is a delivery provider, the same family as + * a connected device, not a notification preference. What stays here is the + * reminder-TYPE content (which events fire): mood reminder, medication + * low-stock runway, proactive Coach nudge. S4 will consolidate these onto the + * Reminders hub. + */ export function NotificationsSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); - const { data: globalServices } = useQuery({ - queryKey: queryKeys.settingsGlobalServices(), - queryFn: async () => { - return apiGet("/api/settings/global-services"); - }, - enabled: isAuthenticated, - }); - - const showTelegram = globalServices?.telegramGlobal ?? true; - const showNtfy = globalServices?.ntfyGlobal ?? true; - const showWebPush = globalServices?.webPushGlobal ?? true; - return (
- {/* v1.4.33 IW7 — disambiguate the channel-config screen from the - inbox at `/notifications`. Both surfaces used to be named plain - "Notifications", so the crumb spells out where the user is and - offers a one-tap jump to the inbox. */} + {/* v1.4.33 IW7 — disambiguate this settings screen from the inbox at + `/notifications`. The crumb spells out where the user is and offers a + one-tap jump to the inbox. */}