494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
"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'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'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'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'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>
|
||
);
|
||
}
|