Initial commit

This commit is contained in:
Developer
2026-02-06 21:44:04 -06:00
commit f85e93c7a6
151 changed files with 22916 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
"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>
);
}