5483dd291e
- Auth system (login/register/profile) - Nur AI chat with persona system - Souq marketplace with FLH economy - Forum community with Shariah moderation - Wallet & FLH top-up - Premium/Pro tier upgrade with Polar.sh - Halal Monitor with map & bookmarks - Home dashboard with daily verse & streaks - Health endpoints Next.js 16.2.7, Prisma v5 SQLite, JWT auth, Tailwind CSS v4
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, FormEvent } from "react";
|
|
import { useAuth } from "@/lib/AuthContext";
|
|
import { useRouter } from "next/navigation";
|
|
import { getUserTier } from "@/lib/tiers";
|
|
import { PERSONAS } from "@/lib/personas";
|
|
import {
|
|
User,
|
|
Crown,
|
|
Zap,
|
|
LogOut,
|
|
ChevronRight,
|
|
Save,
|
|
Sparkles,
|
|
Shield,
|
|
} from "lucide-react";
|
|
|
|
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
|
|
|
|
export default function ProfilePage() {
|
|
const { user, token, loading, logout, refreshUser } = useAuth();
|
|
const router = useRouter();
|
|
|
|
// Editable fields
|
|
const [name, setName] = useState("");
|
|
const [preferredName, setPreferredName] = useState("");
|
|
const [coachPersona, setCoachPersona] = useState("");
|
|
const [madhab, setMadhab] = useState("");
|
|
const [coachingGoals, setCoachingGoals] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveMessage, setSaveMessage] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!loading && !token) router.push("/login");
|
|
}, [loading, token, router]);
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setName(user.name || "");
|
|
setPreferredName(user.preferredName || "");
|
|
setCoachPersona(user.coachPersona || "");
|
|
setMadhab(user.madhab || "");
|
|
setCoachingGoals(user.coachingGoals || "");
|
|
}
|
|
}, [user]);
|
|
|
|
const tier = user ? getUserTier(user.isPremium, user.isPro) : "free";
|
|
|
|
const tierColors = {
|
|
free: { bg: "bg-gray-900/50", text: "text-gray-400", border: "border-gray-700/50", icon: User },
|
|
premium: { bg: "bg-[#D4AF37]/10", text: "text-[#D4AF37]", border: "border-[#D4AF37]/30", icon: Sparkles },
|
|
pro: { bg: "bg-purple-900/20", text: "text-purple-400", border: "border-purple-500/30", icon: Crown },
|
|
};
|
|
|
|
const tc = tierColors[tier];
|
|
|
|
const handleSave = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
if (!token) return;
|
|
setSaving(true);
|
|
setSaveMessage("");
|
|
|
|
try {
|
|
const res = await fetch("/api/auth/profile", {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
name: name.trim() || undefined,
|
|
preferredName: preferredName.trim() || undefined,
|
|
coachPersona: coachPersona || undefined,
|
|
madhab: madhab || undefined,
|
|
coachingGoals: coachingGoals.trim() || undefined,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Save failed");
|
|
}
|
|
|
|
setSaveMessage("Profile updated successfully");
|
|
await refreshUser();
|
|
|
|
setTimeout(() => setSaveMessage(""), 3000);
|
|
} catch (err: unknown) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Failed to save profile";
|
|
setSaveMessage(message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
router.push("/login");
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
|
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
|
{/* Header */}
|
|
<div className="px-4 pt-6 pb-2">
|
|
<h1 className="text-xl font-bold text-white">Profile</h1>
|
|
</div>
|
|
|
|
<div className="px-4 space-y-5 animate-fade-in">
|
|
{/* User Card */}
|
|
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-14 h-14 rounded-full bg-[#D4AF37]/15 flex items-center justify-center">
|
|
<User size={24} className="text-[#D4AF37]" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-lg font-bold text-white">
|
|
{user.preferredName || user.name}
|
|
</h2>
|
|
<p className="text-sm text-gray-500">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tier Badge */}
|
|
<div
|
|
className={`mt-4 flex items-center justify-between ${tc.bg} ${tc.border} border rounded-xl px-4 py-2.5`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<tc.icon size={16} className={tc.text} />
|
|
<span className={`text-sm font-semibold ${tc.text}`}>
|
|
{tier === "free"
|
|
? "Free"
|
|
: tier === "premium"
|
|
? "Premium"
|
|
: "Pro"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Zap size={14} className="text-gray-600" />
|
|
<span className="text-sm text-gray-400">
|
|
{tier === "pro"
|
|
? "Unlimited"
|
|
: `${user.dailyMsgCount}/10 AI today`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* FLH Balance */}
|
|
<div className="mt-3 flex items-center justify-between bg-[#1a1a24] rounded-xl px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-400">
|
|
FLH Balance
|
|
</span>
|
|
</div>
|
|
<span className="text-lg font-bold text-[#D4AF37]">
|
|
{user.flhBalance.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Edit Profile Form */}
|
|
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
|
<h3 className="text-sm font-semibold text-white mb-4">
|
|
Edit Profile
|
|
</h3>
|
|
|
|
{saveMessage && (
|
|
<div
|
|
className={`mb-4 p-3 rounded-xl text-sm text-center ${
|
|
saveMessage === "Profile updated successfully"
|
|
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
|
|
: "bg-red-900/20 border border-red-800/40 text-red-400"
|
|
}`}
|
|
>
|
|
{saveMessage}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
|
Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
|
/>
|
|
</div>
|
|
|
|
{/* Preferred Name */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
|
Preferred Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={preferredName}
|
|
onChange={(e) => setPreferredName(e.target.value)}
|
|
placeholder="How Nur AI should address you"
|
|
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
|
/>
|
|
</div>
|
|
|
|
{/* Coach Persona */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
|
Coach Persona
|
|
</label>
|
|
<select
|
|
value={coachPersona}
|
|
onChange={(e) => setCoachPersona(e.target.value)}
|
|
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition appearance-none"
|
|
>
|
|
<option value="">Default</option>
|
|
{PERSONAS.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.icon} {p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Madhab */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
|
Madhab (School of Thought)
|
|
</label>
|
|
<select
|
|
value={madhab}
|
|
onChange={(e) => setMadhab(e.target.value)}
|
|
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition appearance-none"
|
|
>
|
|
<option value="">Not specified</option>
|
|
{VALID_MADHABS.map((m) => (
|
|
<option key={m} value={m}>
|
|
{m}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Coaching Goals */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1.5 uppercase tracking-wider">
|
|
Coaching Goals
|
|
</label>
|
|
<textarea
|
|
value={coachingGoals}
|
|
onChange={(e) => setCoachingGoals(e.target.value)}
|
|
placeholder="What would you like to work on?"
|
|
rows={3}
|
|
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm active:opacity-80 disabled:opacity-50 transition"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<span className="w-4 h-4 rounded-full border-2 border-[#0a0a0f]/30 border-t-[#0a0a0f] animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save size={16} />
|
|
Save Changes
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Account Info */}
|
|
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
|
<h3 className="text-sm font-semibold text-white mb-3">
|
|
Account Details
|
|
</h3>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Member since</span>
|
|
<span className="text-gray-300">
|
|
{new Date(user.createdAt ?? "").toLocaleDateString("en-MY", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
})}
|
|
</span>
|
|
</div>
|
|
{user.trialEndsAt && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Trial ends</span>
|
|
<span className="text-gray-300">
|
|
{new Date(user.trialEndsAt).toLocaleDateString("en-MY", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{user.madhab && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Madhab</span>
|
|
<span className="text-gray-300">{user.madhab}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full flex items-center justify-between py-3.5 px-5 rounded-xl bg-red-900/10 border border-red-800/30 text-red-400 active:bg-red-900/20 transition"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<LogOut size={16} />
|
|
<span className="text-sm font-medium">Sign Out</span>
|
|
</div>
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
|
|
<div className="h-4" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|