"use client"; import { useState } from "react"; import Link from "next/link"; import { Loader2 } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { useTranslations } from "@/lib/i18n/context"; import type { CycleGoal, CycleProfileDTO, SecondarySymptom } from "./types"; import { useUpdateCyclePrefs } from "./use-cycle"; /** * v1.15.0 — the single-page cycle settings form. * * One dynamic form, no top/bottom split (the project's settings-UX rule): * the inclusive goal selector drives all copy — fertile-window language only * appears for TRYING_TO_CONCEIVE; GENERAL_HEALTH / PERIMENOPAUSE / OFF hide * conception framing. Priors, raw-chart mode, sensitive-category encryption, * discreet notifications, and the data export/delete affordances all live on * the one page. PATCHes `/api/auth/me/cycle-prefs`. * * The form seeds its local state from `profile` once on mount; the parent * passes `key={profile.updatedAt}` so a profile refetch remounts the form * with the fresh values rather than syncing through an effect. */ const GOALS: CycleGoal[] = [ "GENERAL_HEALTH", "AVOID_PREGNANCY", "TRYING_TO_CONCEIVE", "PERIMENOPAUSE", "OFF", ]; const SECONDARY_SYMPTOMS: SecondarySymptom[] = ["MUCUS", "CERVIX"]; function numOrEmpty(v: number | null): string { return v == null ? "" : String(v); } export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { const { t } = useTranslations(); const update = useUpdateCyclePrefs(); const [goal, setGoal] = useState(profile.goal); const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode); const [predictionEnabled, setPredictionEnabled] = useState( profile.predictionEnabled, ); const [encryption, setEncryption] = useState( profile.sensitiveCategoryEncryption, ); const [discreet, setDiscreet] = useState(profile.discreetNotifications); const [secondarySymptom, setSecondarySymptom] = useState( profile.secondarySymptom, ); const [cycleLen, setCycleLen] = useState( numOrEmpty(profile.typicalCycleLength), ); const [periodLen, setPeriodLen] = useState( numOrEmpty(profile.typicalPeriodLength), ); const [lutealLen, setLutealLen] = useState( numOrEmpty(profile.lutealPhaseLength), ); function parsePrior(v: string): number | null { const n = Number(v); return v.trim() === "" || !Number.isFinite(n) ? null : Math.round(n); } async function handleSave() { await update.mutateAsync({ goal, secondarySymptom, rawChartMode, predictionEnabled, sensitiveCategoryEncryption: encryption, discreetNotifications: discreet, typicalCycleLength: parsePrior(cycleLen), typicalPeriodLength: parsePrior(periodLen), lutealPhaseLength: parsePrior(lutealLen), }); } return ( {t("cycle.settings.title")} {t("cycle.goal.description")} {/* Inclusive goal selector — drives the rest of the copy. */}
setGoal(e.target.value as CycleGoal)} > {GOALS.map((g) => ( ))}
{/* Predictions — when the goal hides conception framing, the description never names a fertile window. */} {/* Priors */}

{t("cycle.settings.priorsDescription")}

{/* Advanced — symptothermal secondary symptom. The default is mucus; the choice never forces itself on a casual user (progressive disclosure: it sits here in the advanced cycle settings). */}
setSecondarySymptom(e.target.value as SecondarySymptom) } > {SECONDARY_SYMPTOMS.map((s) => ( ))}

{t("cycle.settings.secondarySymptomDescription")}{" "} {/* Primary source for the symptothermal double-check method: the German NFP prospective cohort (Frank-Herrmann et al., Human Reproduction 2007). Plain anchor — no markdown renderer. */} {t("cycle.settings.secondarySymptomCitation")}

{/* Privacy toggles */} {/* Reminders — the per-channel toggles live on the notifications page; point users there rather than duplicating the toggle state here. */} {/* Fertile-window reminder — surfaced ONLY under the conception goal. The toggle itself is the default-OFF per-channel CYCLE_FERTILE_SOON switch on the notifications page; here we just point a TTC user to it, so the affordance never appears (and fertile language never shows) for the other goals (the inclusive-framing rule). */} {goal === "TRYING_TO_CONCEIVE" ? ( ) : null} {/* Cycle data export + delete live with the main Settings → Export and Account surfaces (cycle rows ride the full-backup export and the account-level delete), so the standalone affordances are not duplicated here. */}
{update.isSuccess ? ( {t("cycle.settings.saved")} ) : null} {update.isError ? ( {t("cycle.settings.saveError")} ) : null}
); } function ToggleRow({ id, label, description, checked, onChange, }: { id: string; label: string; description: string; checked: boolean; onChange: (v: boolean) => void; }) { return (

{description}

); } /** * A "label + description → go to notifications" row. The long fertile-window * label collided with its button when both shared one tight flex line; here the * row stacks on the narrowest screens (button on its own line, full-width tap * target) and reflows to a clean inline layout from `sm` up, where the text * column can wrap freely while the button stays top-aligned and never shrinks. */ function LinkRow({ title, description, linkLabel, }: { title: string; description: string; linkLabel: string; }) { return (

{title}

{description}

); } function PriorField({ id, label, value, onChange, min, max, }: { id: string; label: string; value: string; onChange: (v: string) => void; min: number; max: number; }) { return (
onChange(e.target.value)} className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none" />
); }