"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 { MentionTextarea } from "@/components/social/mention-textarea";
import { cn } from "@/lib/utils";
/** Profile bio cap. Mirrors the BFF's service.MaxBioRunes. */
const BIO_MAX = 200;
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 [bio, setBio] = useState(user.bio ?? "");
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 : "";
// Spread, not .length — counts code points so emoji and Devanāgarī match
// the server's rune-based cap instead of over-counting UTF-16 units.
const bioCount = [...bio.trim()].length;
const dirty =
name.trim() !== user.name ||
handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url ||
bio.trim() !== (user.bio ?? "") ||
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);
// Counted in code points to match the BFF's rune-based cap, so a bio in
// Devanāgarī is measured the same way as one in Latin script.
if ([...bio.trim()].length > BIO_MAX) {
setError(`Bio must be ${BIO_MAX} characters or fewer.`);
return;
}
// 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();
// "" clears the bio.
if (bio.trim() !== (user.bio ?? "")) patch.bio = bio.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 (
);
}
/** 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 (