Files
yourwillyourwish/app/account/settings/settings-client.tsx
2026-02-06 21:44:04 -06:00

361 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState, useRef } from "react";
import { getPasswordRequirements, PasswordRequirement } from "../../../lib/auth/validation";
export default function SettingsClient() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [passwordRequirements, setPasswordRequirements] = useState<PasswordRequirement[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const getMinDate = (): string => {
const date = new Date();
date.setFullYear(date.getFullYear() - 100);
return date.toISOString().split('T')[0];
};
const getMaxDate = (): string => {
const date = new Date();
date.setFullYear(date.getFullYear() - 18);
return date.toISOString().split('T')[0];
};
const [profile, setProfile] = useState({
firstName: "",
lastName: "",
gender: "",
dob: "",
address: "",
avatarUrl: "",
email: "",
});
useEffect(() => {
fetch("/api/account/profile")
.then((r) => r.json())
.then((d) => d.ok && setProfile(d.profile))
.catch(() => null);
}, []);
useEffect(() => {
setPasswordRequirements(getPasswordRequirements(newPassword));
}, [newPassword]);
async function submit() {
setMsg(null);
// Client-side validation
if (!currentPassword.trim()) {
setMsg("Current password is required");
return;
}
// Validate new password meets all requirements
const unmetRequirements = passwordRequirements.filter(req => !req.met);
if (unmetRequirements.length > 0) {
const messages = unmetRequirements.map(req => req.name).join(", ");
setMsg(`Password requirements not met: ${messages}`);
return;
}
if (newPassword !== confirmPassword) {
setMsg("Passwords do not match");
return;
}
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword, confirmPassword }),
});
const data = await res.json();
if (data.ok) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
setMsg(data.ok ? "✅ Password updated." : data.message);
}
async function saveProfile() {
setMsg(null);
const res = await fetch("/api/account/profile", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(profile),
});
const data = await res.json();
setMsg(data.ok ? "✅ Profile updated." : data.message);
// Reload profile data to show updated avatar
if (data.ok) {
const profileRes = await fetch("/api/account/profile");
const profileData = await profileRes.json();
if (profileData.ok) {
setProfile(profileData.profile);
}
// Trigger navbar refresh by dispatching custom event
window.dispatchEvent(new Event('profile-updated'));
}
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith("image/")) {
setMsg("Please select an image file");
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setMsg("Image size must be less than 5MB");
return;
}
setUploading(true);
setMsg(null);
// Convert to base64
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
setProfile({ ...profile, avatarUrl: base64 });
setUploading(false);
setMsg("✅ Image loaded. Click 'Save Profile' to update.");
};
reader.onerror = () => {
setUploading(false);
setMsg("Failed to read image file");
};
reader.readAsDataURL(file);
}
return (
<div className="space-y-8">
<div className="card border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-secondary text-white flex items-center justify-center shadow-md">
👤
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Profile Information</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Update your personal details</p>
</div>
</div>
<div className="space-y-4">
{/* Profile Photo Section */}
<div className="flex items-center gap-6 p-5 rounded-2xl bg-gradient-to-r from-primary/5 to-secondary/5 border border-primary/10">
<div className="relative">
{profile.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.avatarUrl}
alt="Profile"
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-slate-700 shadow-xl"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-r from-primary to-secondary text-white flex items-center justify-center text-3xl font-bold shadow-xl">
{profile.firstName?.[0]?.toUpperCase() || "U"}
</div>
)}
{uploading && (
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
</div>
)}
</div>
<div className="flex-1">
<h3 className="font-semibold mb-1 text-slate-900 dark:text-white">Profile Photo</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
JPG, PNG or GIF. Max size 5MB.
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<div className="flex flex-wrap gap-2">
<button
onClick={() => fileInputRef.current?.click()}
className="btn-secondary text-sm"
disabled={uploading}
>
📷 Choose Photo
</button>
{profile.avatarUrl && (
<button
onClick={() => setProfile({ ...profile, avatarUrl: "" })}
className="px-4 py-2 rounded-xl text-sm text-danger hover:bg-danger/10 transition-all duration-200"
>
🗑 Remove
</button>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">First Name *</label>
<input
className="input-field w-full"
placeholder="First name"
value={profile.firstName}
onChange={(e) => setProfile({ ...profile, firstName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Last Name *</label>
<input
className="input-field w-full"
placeholder="Last name"
value={profile.lastName}
onChange={(e) => setProfile({ ...profile, lastName: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Email</label>
<input
className="input-field w-full bg-gray-100 dark:bg-slate-800 cursor-not-allowed"
value={profile.email}
disabled
/>
<p className="text-xs text-gray-500 mt-1">Email cannot be changed</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Gender</label>
<select
className="input-field w-full"
value={profile.gender}
onChange={(e) => setProfile({ ...profile, gender: e.target.value })}
>
<option value="">Select gender</option>
<option value="FEMALE">Female</option>
<option value="MALE">Male</option>
<option value="OTHER">Other</option>
<option value="PREFER_NOT_TO_SAY">Prefer not to say</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Date of Birth</label>
<input
className="input-field w-full"
type="date"
value={profile.dob}
onChange={(e) => setProfile({ ...profile, dob: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Address</label>
<textarea
className="input-field w-full"
rows={3}
placeholder="Enter your address"
value={profile.address}
onChange={(e) => setProfile({ ...profile, address: e.target.value })}
/>
</div>
<div className="flex justify-end">
<button onClick={saveProfile} className="btn-primary">
💾 Save Profile
</button>
</div>
</div>
</div>
<div className="card border border-slate-200/60 dark:border-slate-700/60 shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-secondary text-white flex items-center justify-center shadow-md">
🔐
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Change Password</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Update your password</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Current Password *</label>
<input
className="input-field w-full"
placeholder="Enter current password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">New Password *</label>
<input
className="input-field w-full"
placeholder="Enter new password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{newPassword && (
<div className="mt-4 p-4 bg-gray-50 dark:bg-slate-900/50 rounded-lg border border-gray-200 dark:border-slate-700">
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Password must include:</p>
<div className="space-y-2">
{passwordRequirements.map((req, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className={`flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-full text-xs font-bold ${req.met ? "bg-success/20 text-success" : "bg-danger/20 text-danger"}`}>
{req.met ? "✓" : "✕"}
</span>
<span className={`text-sm ${req.met ? "text-success font-medium" : "text-danger"}`}>
{req.name}
</span>
</div>
))}
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-semibold mb-2 text-slate-700 dark:text-slate-300">Confirm New Password *</label>
<input
className="input-field w-full"
placeholder="Confirm new password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-xs text-danger mt-1">Passwords do not match</p>
)}
</div>
<div className="flex justify-end">
<button onClick={submit} className="btn-primary">
🔐 Update Password
</button>
</div>
</div>
</div>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.includes("✅") ? "bg-success/10 text-success" : "bg-danger/10 text-danger"}`}>
{msg}
</div>
)}
</div>
);
}