"use client"; /** * ProfileEditForm — the editor used on the dedicated /settings/profile page. * * Three groups of editable fields, rendered as plain page sections (no card * chrome) so the page reads as a normal settings-style screen rather than a * widget: * • Profile photo (R2 presign + PATCH /users/me { avatar_url }) * • Account (name, handle, country, email) * • Devotional details (sampradāya, gender, initiation, daily rounds) * * The page owns the title + back button; this component renders only the * sections and the Save/Cancel footer. `onDone` runs on "Cancel" (the parent * navigates back to the profile). Saving stays on the page with a toast and * re-baselines the form against the freshly-saved user. * * No log-out button here — that lives in the sidebar/settings footer so it * stays outside the edit context. */ import { useRef, useState } from "react"; import Image from "next/image"; import { ImagePlus, Save } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; import { auth, AuthError, type User } from "@/lib/auth"; import { COUNTRIES } from "@/lib/countries"; import { SAMPRADAYAS } from "@/lib/sampradayas"; import { compressImage } from "@/lib/compress-image"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { DatePicker } from "@/components/ui/date-picker"; /** Normalises a stored date/timestamp to the "YYYY-MM-DD" an * expects. Returns "" for null. */ function dateInput(s: string | null): string { return s ? s.slice(0, 10) : ""; } // Option lists for the comboboxes. "" is a real, selectable value meaning // "not specified" for the optional fields. const COUNTRY_OPTIONS: ComboboxOption[] = [ { value: "", label: "Not specified" }, ...COUNTRIES.map((c) => ({ value: c.code, label: c.name })), ]; const SAMPRADAYA_OPTIONS: ComboboxOption[] = [ { value: "", label: "Not specified" }, ...SAMPRADAYAS.map((s) => ({ value: s.code, label: s.name })), ]; const GENDER_OPTIONS: ComboboxOption[] = [ { value: "", label: "Prefer not to say" }, { value: "male", label: "Male" }, { value: "female", label: "Female" }, { value: "other", label: "Other" }, ]; export function ProfileEditForm({ user, onDone, }: { user: User; onDone: () => void; }) { const { setUser } = useAuth(); const fileInput = useRef(null); const [name, setName] = useState(user.name); const [handle, setHandle] = useState(user.handle); const [avatarUrl, setAvatarUrl] = useState(user.avatar_url); const [country, setCountry] = useState(user.country ?? ""); // Devotional details. Each "" means "not specified". const [sampradaya, setSampradaya] = useState(user.sampradaya ?? ""); const [gender, setGender] = useState(user.gender ?? ""); // `?? false` guards against a cached user object from before this field // existed (undefined → uncontrolled checkbox → React warning). const [initiated, setInitiated] = useState(user.initiated ?? false); const [initiatedAt, setInitiatedAt] = useState( dateInput(user.initiated_at), ); const [rounds, setRounds] = useState( user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "", ); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); // Originals normalised for comparison. The initiation date only counts // when the devotee is marked initiated — un-checking it clears the date. const origRounds = user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : ""; const effInitiatedAt = initiated ? initiatedAt : ""; const dirty = name.trim() !== user.name || handle.trim() !== user.handle || avatarUrl !== user.avatar_url || country !== (user.country ?? "") || sampradaya !== (user.sampradaya ?? "") || gender !== (user.gender ?? "") || initiated !== (user.initiated ?? false) || effInitiatedAt !== dateInput(user.initiated_at) || rounds !== origRounds; async function onSubmit(e: React.SyntheticEvent) { e.preventDefault(); setError(null); // Client-side guard for the round count (the BFF re-checks 1–1008). if (rounds !== "") { const n = Number(rounds); if (!Number.isInteger(n) || n < 1 || n > 1008) { setError("Daily chanting rounds must be a whole number between 1 and 1008."); return; } } setSaving(true); try { // Patch only what's changed — keeps the audit log tidy and avoids // bumping updated_at when the user just clicked "Save" without edits. // Empty strings clear the nullable fields; 0 clears the round count. const patch: Parameters[0] = {}; if (name.trim() !== user.name) patch.name = name.trim(); if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? ""; if (handle.trim() !== user.handle) patch.handle = handle.trim(); if (country !== (user.country ?? "")) patch.country = country; // "" clears the lineage; a code sets it. if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya; if (gender !== (user.gender ?? "")) patch.gender = gender; if (initiated !== (user.initiated ?? false)) patch.initiated = initiated; if (effInitiatedAt !== dateInput(user.initiated_at)) { patch.initiated_at = effInitiatedAt; } if (rounds !== origRounds) { patch.daily_chanting_rounds = rounds === "" ? 0 : Number(rounds); } const updated = await auth.updateProfile(patch); // Update the cached user in place and STAY on the page. The form's // fields re-baseline against the new `user` prop (so it's no longer // "dirty"); exit is via the page's back button or Cancel. setUser(updated); toast.success("Profile saved"); } catch (err) { setError(err instanceof AuthError ? err.message : "Could not save."); } finally { setSaving(false); } } async function onAvatarChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (fileInput.current) fileInput.current.value = ""; if (!file) return; setError(null); setUploading(true); try { // Avatars display at ≤ 200 px in every surface, so cap the // longest edge at 800 px — plenty of retina headroom while // keeping the stored bytes small. const compressed = await compressImage(file, { maxDimension: 800 }); const publicURL = await auth.uploadAvatar(compressed); setAvatarUrl(publicURL); } catch (err) { setError(err instanceof AuthError ? err.message : "Upload failed."); } finally { setUploading(false); } } return (
{avatarUrl ? ( ) : null}
setName(e.target.value)} maxLength={120} required />
{/* "@" sits inside the field as a fixed, non-editable prefix — pointer-events-none lets clicks fall through to the input, and the input is padded so the text never overlaps it. The stored value is still just the handle (no leading @). */}
@ setHandle(e.target.value)} maxLength={32} pattern="[a-zA-Z0-9._-]+" required className="pl-7" />

Letters, digits, dashes. Your public profile lives at /u/{handle || user.handle}.

Shown on your profile's “About this account” panel.

{user.email ? ( <>

Email changes aren't supported yet.

) : ( )}

Optional. Leave blank if you'd rather not say.

Have you received initiation (dīkṣā)?

setInitiated(e.target.checked)} className="size-5 shrink-0 accent-primary" />
{initiated ? (
) : null}
setRounds(e.target.value)} placeholder="e.g. 16" />

Your daily japa vow (1–1008). Leave blank if you'd rather not say.

{error ?

{error}

: null}
); } /** A plain page section: a bottom-bordered title over its fields. Matches the * /settings page section style so the editor reads as a normal page. */ function Section({ title, children, }: { title: string; children: React.ReactNode; }) { return (

{title}

{children}
); } function AddEmailInline() { const { setUser } = useAuth(); const [email, setEmail] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [sent, setSent] = useState(false); async function onSave(e: React.FormEvent) { e.preventDefault(); setError(null); setSaving(true); try { const updated = await auth.setEmail(email); setUser(updated); setSent(true); } catch (err) { setError(err instanceof AuthError ? err.message : "Could not save email."); } finally { setSaving(false); } } if (sent) { return (

✓ Email saved — check your inbox for a verification link.

); } return (
setEmail(e.target.value)} placeholder="you@example.com" />
{error ? (

{error}

) : (

Add an email to enable password recovery and notifications.

)}
); } function AvatarPreview({ src, name }: { src: string | null; name: string }) { if (src) { return ( {name} ); } const initials = name .split(/\s+/) .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase() || "?"; return (
{initials}
); }