mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(settings): research-mode toggle with version re-prompt banner
Adds the Research Mode card to Settings → Advanced alongside the existing danger zone. Toggle ON opens the acknowledgment dialog (the dialog owns the POST so the user reads the disclaimer first); OFF fires DELETE directly. An amber re-prompt banner appears above the toggle whenever the server's currentDisclaimerVersion drifts ahead of the acknowledgedVersion, surfacing a Re-acknowledge CTA that re-opens the dialog without flipping the toggle. The Advanced section description is updated across all six locales to reflect that the section now hosts both controls.
This commit is contained in:
1 parent
85ba382009
commit
20a72d2e32
8 files changed
+444
-12
No files matched your search
+13
-1
@@ -1582,6 +1582,18 @@
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Profil, Sicherheit & Verbindungen",
|
||||
"researchMode": {
|
||||
"sectionTitle": "Forschungsmodus",
|
||||
"subtitle": "Optionale Anzeige einer geschätzten GLP-1-Wirkstoffkurve auf Basis der von der EMA veröffentlichten Populations-Pharmakokinetik. Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat.",
|
||||
"toggleLabel": "Geschätzte Wirkstoffkurve anzeigen",
|
||||
"disabledStatus": "Deaktiviert. Die Wirkstoffkurve ist ausgeblendet.",
|
||||
"enabledStaleStatus": "Der Disclaimer wurde aktualisiert. Bestätige unten erneut, um die Kurve wieder anzuzeigen.",
|
||||
"acknowledgedOn": "Bestätigt am {date}.",
|
||||
"rePromptTitle": "Disclaimer aktualisiert",
|
||||
"rePromptBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn erneut und bestätige, damit die Kurve sichtbar bleibt.",
|
||||
"rePromptCta": "Disclaimer erneut bestätigen",
|
||||
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
},
|
||||
@@ -1667,7 +1679,7 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Account-weiter Notausgang — unwiderruflich alle Gesundheitsdaten löschen, Account bleibt erhalten."
|
||||
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export",
|
||||
|
||||
+13
-1
@@ -1582,6 +1582,18 @@
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"researchMode": {
|
||||
"sectionTitle": "Research Mode",
|
||||
"subtitle": "Opt-in display of an estimated GLP-1 drug-level curve, derived from EMA-published population pharmacokinetics. Reading-only — never a measurement or medical advice.",
|
||||
"toggleLabel": "Show the estimated drug-level chart",
|
||||
"disabledStatus": "Disabled. The drug-level chart is hidden.",
|
||||
"enabledStaleStatus": "The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
"acknowledgedOn": "Acknowledged on {date}.",
|
||||
"rePromptTitle": "Disclaimer updated",
|
||||
"rePromptBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and re-confirm to keep the chart visible.",
|
||||
"rePromptCta": "Re-acknowledge disclaimer",
|
||||
"disableError": "Could not disable Research Mode. Please try again."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
@@ -1667,7 +1679,7 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
|
||||
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export",
|
||||
|
||||
+14
-2
@@ -1582,6 +1582,18 @@
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"researchMode": {
|
||||
"sectionTitle": "Modo Investigación",
|
||||
"subtitle": "Visualización opcional de una curva estimada del nivel de fármaco GLP-1, derivada de la farmacocinética poblacional publicada por la EMA. Solo lectura: nunca una medición ni un consejo médico.",
|
||||
"toggleLabel": "Mostrar la curva estimada del nivel de fármaco",
|
||||
"disabledStatus": "Desactivado. La curva está oculta.",
|
||||
"enabledStaleStatus": "El disclaimer se actualizó. Reconfirma debajo para volver a mostrar la curva.",
|
||||
"acknowledgedOn": "Confirmado el {date}.",
|
||||
"rePromptTitle": "Disclaimer actualizado",
|
||||
"rePromptBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo y reconfirma para mantener la curva visible.",
|
||||
"rePromptCta": "Reconfirmar disclaimer",
|
||||
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
@@ -1666,8 +1678,8 @@
|
||||
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
|
||||
"title": "Avanzado",
|
||||
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud."
|
||||
},
|
||||
"export": {
|
||||
"title": "Exportar",
|
||||
|
||||
+14
-2
@@ -1582,6 +1582,18 @@
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"researchMode": {
|
||||
"sectionTitle": "Mode Recherche",
|
||||
"subtitle": "Affichage optionnel d'une courbe de niveau estimé de médicament GLP-1, dérivée de la pharmacocinétique de population publiée par l'EMA. Lecture seule — jamais une mesure ni un conseil médical.",
|
||||
"toggleLabel": "Afficher la courbe estimée du niveau de médicament",
|
||||
"disabledStatus": "Désactivé. La courbe est masquée.",
|
||||
"enabledStaleStatus": "Le disclaimer a été mis à jour. Reconfirmez ci-dessous pour réafficher la courbe.",
|
||||
"acknowledgedOn": "Confirmé le {date}.",
|
||||
"rePromptTitle": "Disclaimer mis à jour",
|
||||
"rePromptBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le et reconfirmez pour garder la courbe visible.",
|
||||
"rePromptCta": "Reconfirmer le disclaimer",
|
||||
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
@@ -1666,8 +1678,8 @@
|
||||
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
|
||||
"title": "Avancé",
|
||||
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé."
|
||||
},
|
||||
"export": {
|
||||
"title": "Exporter",
|
||||
|
||||
+14
-2
@@ -1582,6 +1582,18 @@
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"researchMode": {
|
||||
"sectionTitle": "Modalità Ricerca",
|
||||
"subtitle": "Visualizzazione opzionale di una curva stimata del livello del farmaco GLP-1, derivata dalla farmacocinetica di popolazione pubblicata dall'EMA. Solo lettura: mai una misurazione né un consiglio medico.",
|
||||
"toggleLabel": "Mostra la curva stimata del livello del farmaco",
|
||||
"disabledStatus": "Disattivata. La curva è nascosta.",
|
||||
"enabledStaleStatus": "Il disclaimer è stato aggiornato. Riconferma sotto per rivedere la curva.",
|
||||
"acknowledgedOn": "Confermato il {date}.",
|
||||
"rePromptTitle": "Disclaimer aggiornato",
|
||||
"rePromptBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo e riconferma per mantenere visibile la curva.",
|
||||
"rePromptCta": "Riconferma disclaimer",
|
||||
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
@@ -1666,8 +1678,8 @@
|
||||
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
|
||||
"title": "Avanzate",
|
||||
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute."
|
||||
},
|
||||
"export": {
|
||||
"title": "Esporta",
|
||||
|
||||
+14
-2
@@ -1582,6 +1582,18 @@
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"researchMode": {
|
||||
"sectionTitle": "Tryb Badawczy",
|
||||
"subtitle": "Opcjonalne wyświetlanie szacowanej krzywej poziomu leku GLP-1, wyprowadzonej z farmakokinetyki populacyjnej opublikowanej przez EMA. Tylko do odczytu — nigdy pomiar ani porada medyczna.",
|
||||
"toggleLabel": "Pokaż szacowaną krzywą poziomu leku",
|
||||
"disabledStatus": "Wyłączone. Krzywa jest ukryta.",
|
||||
"enabledStaleStatus": "Disclaimer został zaktualizowany. Potwierdź ponownie poniżej, aby przywrócić krzywą.",
|
||||
"acknowledgedOn": "Potwierdzono {date}.",
|
||||
"rePromptTitle": "Zaktualizowano disclaimer",
|
||||
"rePromptBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie i potwierdź, aby krzywa pozostała widoczna.",
|
||||
"rePromptCta": "Potwierdź ponownie disclaimer",
|
||||
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
@@ -1666,8 +1678,8 @@
|
||||
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
|
||||
"title": "Zaawansowane",
|
||||
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych."
|
||||
},
|
||||
"export": {
|
||||
"title": "Eksportuj",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
|
||||
*
|
||||
* The card lives next to the danger-zone delete and drives three
|
||||
* server interactions:
|
||||
*
|
||||
* GET /api/auth/me/research-mode on mount, via TanStack Query
|
||||
* POST /api/auth/me/research-mode via the acknowledgment dialog
|
||||
* DELETE /api/auth/me/research-mode directly when the user
|
||||
* toggles OFF
|
||||
*
|
||||
* The toggle ON path opens the dialog; only the dialog's CTA fires
|
||||
* the POST (so the user reads the disclaimer first). OFF fires
|
||||
* DELETE directly. A version-mismatch banner renders above the
|
||||
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
|
||||
* even when `enabled === true`.
|
||||
*
|
||||
* The dialog itself is covered by
|
||||
* `ResearchModeAcknowledgmentDialog.test.tsx`; we mock it down to a
|
||||
* marker here so the toggle behaviour is the only contract under
|
||||
* test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const queryResults: Record<string, unknown> = {};
|
||||
|
||||
function setQueryResult(keyJoined: string, data: unknown) {
|
||||
queryResults[keyJoined] = data;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = (queryKey as Array<string | number>).join("/");
|
||||
return {
|
||||
data: queryResults[key],
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The acknowledgment dialog is exercised in its own spec; mock the
|
||||
// import to a transparent marker so we can assert "the toggle ON path
|
||||
// renders the dialog with the right props" without re-running the
|
||||
// dialog tree.
|
||||
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: ({
|
||||
open,
|
||||
currentDisclaimerVersion,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentDisclaimerVersion: string | null;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-ack-dialog"
|
||||
data-open={open ? "true" : "false"}
|
||||
data-version={currentDisclaimerVersion ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
||||
});
|
||||
|
||||
describe("<AdvancedSection> → Research Mode card", () => {
|
||||
it("renders the section title + subtitle + toggle in OFF state", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-card"');
|
||||
expect(html).toContain("Research Mode");
|
||||
expect(html).toContain("Show the estimated drug-level chart");
|
||||
// OFF state status line.
|
||||
expect(html).toContain("Disabled. The drug-level chart is hidden.");
|
||||
// No re-prompt banner when versions already align (or the user has
|
||||
// never acknowledged in the first place).
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is unchecked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toBeDefined();
|
||||
expect(switchTag).not.toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the acknowledged-on status when enabled + versions aligned", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-05-14T08:30:00Z",
|
||||
acknowledgedVersion: "2026-05-14.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// Acknowledged-on copy carries the formatted date (the i18n
|
||||
// template uses `{date}`; the formatter renders the day).
|
||||
expect(html).toMatch(/Acknowledged on/);
|
||||
// Banner stays hidden when versions match.
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is checked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the re-prompt banner when versions diverge", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-04-01T00:00:00Z",
|
||||
acknowledgedVersion: "2026-04-01.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
|
||||
expect(html).toContain("Disclaimer updated");
|
||||
expect(html).toContain(
|
||||
'data-slot="settings-research-mode-reprompt-cta"',
|
||||
);
|
||||
expect(html).toContain("Re-acknowledge disclaimer");
|
||||
// Status line reflects the stale state without claiming
|
||||
// "acknowledged on …" — that copy would mis-represent that the
|
||||
// chart is currently painting.
|
||||
expect(html).not.toMatch(/Acknowledged on/);
|
||||
expect(html).toContain(
|
||||
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
);
|
||||
// The toggle stays "on" — we surface the previous choice; the
|
||||
// banner explains why the chart isn't painting.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("forwards the current server-supplied version to the dialog", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-06-01.2",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// The dialog renders even when closed (its `open` prop is what
|
||||
// controls visibility); the data-version attribute lets us verify
|
||||
// the parent passes the live version string through.
|
||||
expect(html).toContain('data-slot="mock-ack-dialog"');
|
||||
expect(html).toMatch(/data-version="2026-06-01\.2"/);
|
||||
expect(html).toMatch(/data-open="false"/);
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />, "de");
|
||||
|
||||
expect(html).toContain("Forschungsmodus");
|
||||
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
|
||||
expect(html).toContain("Deaktiviert");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,17 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* What stays here is the irreversible danger-zone — the "wipe all my
|
||||
* data" surface that should never live next to a single-click export
|
||||
* button.
|
||||
*
|
||||
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
|
||||
* Research Mode and the data-reset are both opt-in / version-gated
|
||||
* controls; they share a semantic shelf even though they touch
|
||||
* different parts of the user record. Toggle ON opens the
|
||||
* acknowledgment dialog (the user reads + confirms before the server
|
||||
* stamps the version). Toggle OFF fires DELETE directly. A separate
|
||||
* amber banner above the toggle catches the version-mismatch case
|
||||
* (server bumped the disclaimer while the user was still
|
||||
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
|
||||
* dialog.
|
||||
*/
|
||||
export function AdvancedSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -47,11 +61,166 @@ export function AdvancedSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResearchModeStatus {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
}
|
||||
|
||||
function ResearchModeCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [toggleBusy, setToggleBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
|
||||
queryKey: ["research-mode"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/research-mode");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as ResearchModeStatus;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const versionsAligned =
|
||||
!!status &&
|
||||
status.acknowledgedVersion === status.currentDisclaimerVersion;
|
||||
const showRePrompt = !!status?.enabled && !versionsAligned;
|
||||
// The Switch's `checked` mirrors the server flag exactly — even when
|
||||
// the version is stale we keep the toggle "on" so the user sees that
|
||||
// their previous choice is preserved; the banner above the toggle
|
||||
// explains why the chart isn't painting.
|
||||
const switchChecked = !!status?.enabled;
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
if (toggleBusy) return;
|
||||
setErrorMessage(null);
|
||||
if (next) {
|
||||
// Toggle ON → open the acknowledgment dialog. The dialog owns
|
||||
// the POST; on success it invalidates the `research-mode` query
|
||||
// and the toggle reflects the new state.
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
// Toggle OFF → fire DELETE directly.
|
||||
setToggleBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["research-mode"] });
|
||||
} catch {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
} finally {
|
||||
setToggleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
data-slot="settings-research-mode-card"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-dracula-purple h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.researchMode.sectionTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.researchMode.subtitle")}
|
||||
</p>
|
||||
|
||||
{showRePrompt && (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="settings-research-mode-reprompt"
|
||||
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="text-foreground font-medium">
|
||||
{t("settings.researchMode.rePromptTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.researchMode.rePromptBody")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
data-slot="settings-research-mode-reprompt-cta"
|
||||
>
|
||||
{t("settings.researchMode.rePromptCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.researchMode.toggleLabel")}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="settings-research-mode-status"
|
||||
>
|
||||
{isLoading
|
||||
? t("common.loading")
|
||||
: status?.enabled
|
||||
? versionsAligned
|
||||
? t("settings.researchMode.acknowledgedOn", {
|
||||
date: status.acknowledgedAt
|
||||
? formatDateTime(status.acknowledgedAt)
|
||||
: "—",
|
||||
})
|
||||
: t("settings.researchMode.enabledStaleStatus")
|
||||
: t("settings.researchMode.disabledStatus")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={switchChecked}
|
||||
disabled={isLoading || toggleBusy}
|
||||
onCheckedChange={(next) => {
|
||||
void handleToggle(next);
|
||||
}}
|
||||
aria-label={t("settings.researchMode.toggleLabel")}
|
||||
data-slot="settings-research-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 text-sm"
|
||||
data-slot="settings-research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataResetCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
Reference in new issue
Block a user