Files
2026-06-15 23:54:34 +05:30

494 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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
* <input type="date"> 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<HTMLInputElement>(null);
const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [country, setCountry] = useState<string>(user.country ?? "");
// Devotional details. Each "" means "not specified".
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
const [gender, setGender] = useState<string>(user.gender ?? "");
// `?? false` guards against a cached user object from before this field
// existed (undefined → uncontrolled checkbox → React warning).
const [initiated, setInitiated] = useState<boolean>(user.initiated ?? false);
const [initiatedAt, setInitiatedAt] = useState<string>(
dateInput(user.initiated_at),
);
const [rounds, setRounds] = useState<string>(
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "",
);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(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<typeof auth.updateProfile>[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<HTMLInputElement>) {
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 (
<form onSubmit={onSubmit} className="space-y-10">
<Section title="Profile photo">
<div className="flex items-center gap-4">
<AvatarPreview src={avatarUrl} name={user.name} />
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onAvatarChange}
/>
<Button
variant="outline"
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading || saving}
className="h-10 px-4 text-sm gap-1.5"
>
<ImagePlus className="size-4" />
{uploading ? "Uploading…" : avatarUrl ? "Change" : "Upload"}
</Button>
{avatarUrl ? (
<Button
variant="ghost"
type="button"
onClick={() => setAvatarUrl(null)}
disabled={uploading || saving}
className="h-10 px-4 text-sm"
>
Remove
</Button>
) : null}
</div>
</div>
</Section>
<Section title="Account">
<div className="space-y-1.5">
<Label htmlFor="profile-name">Display name</Label>
<Input
id="profile-name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={120}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label>
{/* "@" 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 @). */}
<div className="relative">
<span
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"
>
@
</span>
<Input
id="profile-handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
maxLength={32}
pattern="[a-zA-Z0-9._-]+"
required
className="pl-7"
/>
</div>
<p className="text-xs text-muted-foreground">
Letters, digits, dashes. Your public profile lives at /u/{handle || user.handle}.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-country">Country</Label>
<Combobox
id="profile-country"
ariaLabel="Country"
options={COUNTRY_OPTIONS}
value={country}
onChange={setCountry}
placeholder="Not specified"
searchPlaceholder="Search countries…"
emptyText="No matching country."
/>
<p className="text-xs text-muted-foreground">
Shown on your profile&apos;s “About this account” panel.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-email">Email</Label>
{user.email ? (
<>
<Input id="profile-email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email changes aren&apos;t supported yet.
</p>
</>
) : (
<AddEmailInline />
)}
</div>
</Section>
<Section title="Devotional details">
<div className="space-y-1.5">
<Label htmlFor="profile-sampradaya">Sampradāya</Label>
<Combobox
id="profile-sampradaya"
ariaLabel="Sampradāya"
options={SAMPRADAYA_OPTIONS}
value={sampradaya}
onChange={setSampradaya}
placeholder="Not specified"
searchPlaceholder="Search sampradāya…"
emptyText="No matching sampradāya."
/>
<p className="text-xs text-muted-foreground">
Optional. Leave blank if you&apos;d rather not say.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-gender">Gender</Label>
<Combobox
id="profile-gender"
ariaLabel="Gender"
options={GENDER_OPTIONS}
value={gender}
onChange={setGender}
searchable={false}
placeholder="Prefer not to say"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<Label htmlFor="profile-initiated">Initiated</Label>
<p className="text-xs text-muted-foreground">
Have you received initiation (dīkṣā)?
</p>
</div>
<input
id="profile-initiated"
type="checkbox"
checked={initiated}
onChange={(e) => setInitiated(e.target.checked)}
className="size-5 shrink-0 accent-primary"
/>
</div>
{initiated ? (
<div className="space-y-1.5">
<Label htmlFor="profile-initiated-at">Initiation date</Label>
<DatePicker
id="profile-initiated-at"
ariaLabel="Initiation date"
value={initiatedAt}
onChange={setInitiatedAt}
max={new Date().toISOString().slice(0, 10)}
placeholder="Select initiation date"
/>
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="profile-rounds">Daily chanting rounds</Label>
<Input
id="profile-rounds"
type="number"
inputMode="numeric"
min={1}
max={1008}
value={rounds}
onChange={(e) => setRounds(e.target.value)}
placeholder="e.g. 16"
/>
<p className="text-xs text-muted-foreground">
Your daily japa vow (1–1008). Leave blank if you&apos;d rather not say.
</p>
</div>
</Section>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-2 pt-6 border-t border-border">
<Button
type="button"
variant="ghost"
onClick={onDone}
className="h-10 px-4 text-sm"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || uploading || !dirty}
className="h-10 px-4 text-sm gap-1.5"
>
<Save className="size-4" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
);
}
/** 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 (
<section className="space-y-4">
<div className="border-b border-border pb-3">
<h2 className="font-[family-name:var(--font-title)] text-xl font-semibold text-foreground">
{title}
</h2>
</div>
<div className="space-y-4">{children}</div>
</section>
);
}
function AddEmailInline() {
const { setUser } = useAuth();
const [email, setEmail] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<p className="text-xs text-muted-foreground">
✓ Email saved — check your inbox for a verification link.
</p>
);
}
return (
<form onSubmit={onSave} className="space-y-1.5">
<div className="flex gap-2">
<Input
id="profile-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
<Button
type="submit"
disabled={saving || email.trim() === ""}
className="h-10 px-3 text-sm"
>
{saving ? "Saving…" : "Save"}
</Button>
</div>
{error ? (
<p className="text-xs text-destructive">{error}</p>
) : (
<p className="text-xs text-muted-foreground">
Add an email to enable password recovery and notifications.
</p>
)}
</form>
);
}
function AvatarPreview({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={80}
height={80}
className="size-20 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-20 rounded-full bg-muted flex items-center justify-center text-2xl font-medium text-muted-foreground ring-1 ring-foreground/10">
{initials}
</div>
);
}