Files
spsays-training/components/profile/profile-edit-form.tsx
T
2026-06-04 19:30:50 +05:30

490 lines
16 KiB
TypeScript
Raw 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 block used in /u/me's edit mode.
*
* Three editable fields:
* • Display name (PATCH /users/me { name })
* • Handle (PATCH /users/me { handle }) — 409 if taken
* • Avatar (R2 presign + PATCH /users/me { avatar_url })
*
* `onDone` is invoked after a successful save OR when the user clicks
* "Cancel" — the parent flips back to view mode.
*
* No log-out button here — that's handled by the sidebar footer so it lives
* outside the edit context.
*/
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, Save, X } 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 edit form. The
// form's fields re-baseline against the new `user` prop (so it's no
// longer "dirty"), and we keep the ?edit=1 URL rather than bouncing
// back to the profile view. Exit is via 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-8 rounded-2xl bg-card ring-1 ring-foreground/10 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="font-[family-name:var(--font-title)] text-xl sm:text-2xl font-semibold text-foreground">
Edit profile
</h2>
<button
type="button"
onClick={onDone}
aria-label="Cancel"
className="size-9 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
>
<X className="size-4" />
</button>
</div>
{/* Avatar — same widget pattern as the old /profile form */}
<section 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>
</section>
<div className="space-y-4 pt-6 border-t border-border">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Account
</h3>
<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>
</div>
<div className="space-y-4 pt-6 border-t border-border">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Devotional details
</h3>
<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>
</div>
{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>
);
}
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>
);
}