"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Compass, ImageUp, KeyRound, Loader2, Save, Shield, Trash2, User, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { DateInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { useAuth } from "@/hooks/use-auth"; import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { restartOnboardingTour } from "@/lib/onboarding/tour-restart"; import { describePasskeyError } from "@/lib/passkey-errors"; import { queryKeys } from "@/lib/query-keys"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface PasskeyInfo { id: string; name: string; credentialDeviceType: string; credentialBackedUp: boolean; createdAt: string; } // v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant // retired; the shared `` primitive owns the visual // contract now. Existing `
setEmail(e.target.value)} placeholder={t("auth.emailPlaceholder")} maxLength={320} autoComplete="email" enterKeyHint="next" />
setGender(e.target.value)} >

{t("settings.genderHint")}

setHeightCm(e.target.value)} placeholder="175" min={50} max={300} step={0.1} />
{/* Date of birth + language share one paired grid row so the profile form keeps a single rhythm (every row two cells wide on sm+). Date of birth is the bottom of the biological-profile block; language is the only UI preference on this card. They sit together to close the "single-cell row" gap that broke the form's grid. */}
setDateOfBirth(e.target.value)} max={new Date().toISOString().slice(0, 10)} />

{t("settings.dateOfBirthHint")}

setLocale(e.target.value as Locale)} > {locales.map((loc) => ( ))}

{t("settings.languageDescription")}

{/* Timezone + unit system share one grid row — both are personal display preferences (like language above). The unit dropdown PATCHes its own endpoint on change; the timezone saves through the form's submit handler. */}
{/* v1.7.0 — optional patient-identity fields surfaced on the health-record export cover + FHIR Patient. All optional; the KVNR is validated server-side and stored encrypted. */}

{t("settings.identity.description")}

setFullName(e.target.value)} placeholder={t("settings.identity.fullNamePlaceholder")} maxLength={120} autoComplete="name" />
setInsurerName(e.target.value)} placeholder={t("settings.identity.insurerPlaceholder")} maxLength={120} />
setInsuranceNumber(e.target.value.toUpperCase()) } placeholder="A123456780" maxLength={10} autoCapitalize="characters" spellCheck={false} />

{t("settings.identity.insuranceNumberHint")}

{saveMsg && (

{saveMsg}

)}
{/* Global injection-site exclusions live with the account profile — a personal preference that applies app-wide. The unit system moved into the profile form above, beside the timezone dropdown. */} {/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this lets any account opt in (or opt out) before the gated /cycle page is reachable. */} {/* Passkeys card */}
{passkeyMsg && (

{passkeyMsg}

)}
{/* Password card. v1.4.19 A6: action-button placement contract — on mobile (`=sm`) it right-aligns next to the title. The previous `flex items-center justify-between` pattern overflowed the card edge on Pixel 5 once button copy got longer than ~24 ch (the German "Passwort ändern" already pushes it; the tour card's "Restart onboarding tour" actually broke through the right border by ~48 px). */}

{t("settings.passwordReset")}

{t("settings.changePasswordDescription")}

{/* Tour replay card. v1.4.15 Phase B5: a one-shot button that resets `users.onboarding_tour_completed` on the server AND dispatches a window event so a dashboard already in the background reopens the spotlight tour immediately. v1.4.19 A6 — same stack-on-mobile / right-align-on-desktop contract as the password card so both action surfaces look identical. */}

{t("onboarding.tour.restart")}

{t("onboarding.tour.restartHint")}

{tourFeedback && (

{tourFeedback.text}

)}
{ setPasswordDialogOpen(open); if (!open) { setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); setPasswordMsg(null); setPasswordMsgType(null); } }} > {t("settings.passwordReset")} {t("settings.changePasswordDescription")}
setCurrentPassword(e.target.value)} />
setNewPassword(e.target.value)} />
setConfirmPassword(e.target.value)} />
{passwordMsg && (

{passwordMsg}

)}
); } function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [deleteMsg, setDeleteMsg] = useState(null); const { data: passkeys } = useQuery({ queryKey: queryKeys.passkeys(), queryFn: async () => { const res = await fetch("/api/auth/passkeys"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as PasskeyInfo[]; }, enabled: isAuthenticated, }); const deletePasskey = useMutation({ mutationFn: async (id: string) => { const res = await fetch(`/api/auth/passkeys/${id}`, { method: "DELETE" }); const json = await res.json(); if (!res.ok) throw new Error(json.error || t("common.error")); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); setDeleteMsg(null); }, onError: (err: Error) => { setDeleteMsg(err.message); }, }); const DEVICE_TYPE_LABELS: Record = { singleDevice: t("settings.singleDevice"), multiDevice: t("settings.multiDevice"), }; if (!passkeys || passkeys.length === 0) { return (

{t("settings.passkeys")}

{t("settings.noPasskeys")}

); } return (

{t("settings.registeredPasskeys")}

{t("settings.passkeysDescription")}

{/* Phase A5 / B-mobile HIGH: the passkey table previously rendered with `min-w-[620px]` inside `overflow-x-auto`, which on a 393px viewport hid the right-most column — including the destructive delete action. Render the desktop table only at `≥ md`, and at `< md` paint a card-list where every passkey's name, device type, backup status, created date, and delete action are all visible without scrolling. */} {/* v1.4.33 — desktop table flips to card list at `lg` instead of `md` so iPad portrait (768 px = exactly the `md` inflection) lands on the card layout. The table needs ~620 px of column width to read; below that it scrolls horizontally and the destructive delete column is the one that disappears. */}
{passkeys.map((pk, idx) => ( ))}
{t("settings.passkeyName")} {t("settings.passkeyDevice")} {t("settings.passkeyBackup")} {t("settings.passkeyCreated")} {t("settings.passkeyActions")}
{pk.name} {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? pk.credentialDeviceType} {pk.credentialBackedUp ? t("settings.backedUp") : t("common.no")} {formatDate(pk.createdAt)} {t("settings.deletePasskey")} {t("settings.deletePasskeyDescription")} {t("common.cancel")} deletePasskey.mutate(pk.id)} > {t("common.delete")}
{/* Mobile + tablet card list — < lg only. Each passkey gets its own card so the delete action stays visible and tap-targetable (44 px) without the user having to horizontally scroll through a wide table. v1.4.33 bumped the breakpoint from `md` to `lg` so iPad portrait (768 px) stays on the card layout instead of flipping between layouts on rotation. */}
    {passkeys.map((pk) => (
  • {pk.name}

    {/* v1.4.27 MB7 / CF-75 — promote the device-type from plain text to an outline Badge so the mobile card list reads consistent with the desktop table's "Single-device / Multi-device" column. Sits on the same chip row as the backup status and date so all metadata reads as a single horizontal stride. */}
    {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? pk.credentialDeviceType} {pk.credentialBackedUp ? t("settings.backedUp") : t("common.no")} {formatDate(pk.createdAt)}
    {t("settings.deletePasskey")} {t("settings.deletePasskeyDescription")} {t("common.cancel")} deletePasskey.mutate(pk.id)} > {t("common.delete")}
  • ))}
{deleteMsg && (
{deleteMsg}
)}
); } // Accepted upload types + byte cap mirror the server contract in // `src/app/api/user/avatar/route.ts` so the client rejects an obvious // bad file before the round-trip. The server still re-validates by // magic-byte sniff — this is a UX shortcut, not the security boundary. const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"]; const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024; function getAvatarInitials(name: string): string { return name .split(/[\s._-]+/) .slice(0, 2) .map((s) => s[0]?.toUpperCase() ?? "") .join(""); } function AvatarSection() { const { t } = useTranslations(); const { user, refetch } = useAuth(); const fileInputRef = useRef(null); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const upload = useMutation({ mutationFn: async (file: File) => { const body = new FormData(); body.append("file", file); const res = await fetch("/api/user/avatar", { method: "POST", body }); const json = await res.json().catch(() => ({})); if (!res.ok) { if (res.status === 415) throw new Error(t("settings.avatar.invalidType")); if (res.status === 413) throw new Error(t("settings.avatar.tooLarge")); throw new Error(json.error || t("settings.avatar.error")); } }, onSuccess: async () => { setMsgType("success"); setMsg(t("settings.avatar.uploaded")); await refetch(); }, onError: (err: Error) => { setMsgType("error"); setMsg(err.message); }, }); const remove = useMutation({ mutationFn: async () => { const res = await fetch("/api/user/avatar", { method: "DELETE" }); if (!res.ok) { const json = await res.json().catch(() => ({})); throw new Error(json.error || t("settings.avatar.error")); } }, onSuccess: async () => { setMsgType("success"); setMsg(t("settings.avatar.removed")); await refetch(); }, onError: (err: Error) => { setMsgType("error"); setMsg(err.message); }, }); function handleFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; // Reset so re-selecting the same file still fires `onChange`. e.target.value = ""; if (!file) return; setMsg(null); setMsgType(null); if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) { setMsgType("error"); setMsg(t("settings.avatar.invalidType")); return; } if (file.size > AVATAR_MAX_UPLOAD_BYTES) { setMsgType("error"); setMsg(t("settings.avatar.tooLarge")); return; } upload.mutate(file); } if (!user) return null; const avatarUrl = user.avatarUrl ?? null; const busy = upload.isPending || remove.isPending; return (

{t("settings.avatar.description")}

{avatarUrl && } {getAvatarInitials(user.username)}
{avatarUrl && ( )}

{t("settings.avatar.hint")}

{msg && (

{msg}

)}
); }