Initial commit
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { z } from 'zod';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Mail,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { getAuth, sendPasswordResetEmail, updateProfile } from 'firebase/auth';
|
||||
import { app } from '@/lib/firebase';
|
||||
import { getPurchasesByUser, updateUserOnboardingCompleted } from '@/lib/firestore';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const profileSchema = z.object({
|
||||
displayName: z.string().trim().min(2, 'Display name must be at least 2 characters.'),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((value) => (value ? value : undefined))
|
||||
.refine(
|
||||
(value) => !value || /^\+?[0-9\s\-()]{7,20}$/.test(value),
|
||||
'Enter a valid phone number.'
|
||||
),
|
||||
});
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.08 } },
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 14 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const auth = getAuth(app);
|
||||
const firebaseUser = auth.currentUser;
|
||||
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
displayName: user?.name || '',
|
||||
phone: user?.phoneNumber || '',
|
||||
});
|
||||
const [profileErrors, setProfileErrors] = useState<{ displayName?: string; phone?: string }>(
|
||||
{}
|
||||
);
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [resetStatus, setResetStatus] = useState<string | null>(null);
|
||||
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
const [callAlerts, setCallAlerts] = useState(true);
|
||||
const [weeklyReports, setWeeklyReports] = useState(false);
|
||||
|
||||
const [totalCreditsPurchased, setTotalCreditsPurchased] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push('/auth');
|
||||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
getPurchasesByUser(user.uid)
|
||||
.then((purchases) => {
|
||||
const total = purchases.reduce((sum, purchase) => sum + (purchase.credits || 0), 0);
|
||||
setTotalCreditsPurchased(total);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load purchases', error);
|
||||
toast.error('Unable to load purchases.');
|
||||
setTotalCreditsPurchased(0);
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const creationDate = useMemo(() => {
|
||||
const createdAt = firebaseUser?.metadata?.creationTime;
|
||||
if (!createdAt) return 'Unknown';
|
||||
return new Date(createdAt).toLocaleDateString();
|
||||
}, [firebaseUser]);
|
||||
|
||||
const handleProfileChange = (key: 'displayName' | 'phone', value: string) => {
|
||||
setProfileForm((prev) => ({ ...prev, [key]: value }));
|
||||
setProfileErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!firebaseUser) return;
|
||||
const result = profileSchema.safeParse(profileForm);
|
||||
if (!result.success) {
|
||||
const errors: { displayName?: string; phone?: string } = {};
|
||||
result.error.issues.forEach((issue) => {
|
||||
const field = issue.path[0] as 'displayName' | 'phone';
|
||||
errors[field] = issue.message;
|
||||
});
|
||||
setProfileErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
await updateProfile(firebaseUser, { displayName: result.data.displayName.trim() });
|
||||
setProfileForm((prev) => ({ ...prev, displayName: result.data.displayName.trim() }));
|
||||
toast.success('Profile updated.');
|
||||
} catch (error) {
|
||||
console.error('Error updating profile', error);
|
||||
setProfileErrors({ displayName: 'Failed to update profile. Try again.' });
|
||||
toast.error('Unable to update profile.');
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordReset = async () => {
|
||||
if (!firebaseUser?.email) return;
|
||||
setResetStatus(null);
|
||||
try {
|
||||
await sendPasswordResetEmail(auth, firebaseUser.email);
|
||||
setResetStatus('Password reset email sent.');
|
||||
toast.success('Password reset email sent.');
|
||||
} catch (error) {
|
||||
console.error('Password reset failed', error);
|
||||
setResetStatus('Unable to send reset email. Try again.');
|
||||
toast.error('Unable to send reset email.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartTour = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await updateUserOnboardingCompleted(user.uid, false);
|
||||
toast.success('Tour restarted. Redirecting...');
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to restart tour', error);
|
||||
toast.error('Unable to restart tour.');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
||||
<div className="animate-pulse text-lg">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls' },
|
||||
{ label: 'Voice Agent', href: '/dashboard/agent-settings' },
|
||||
{ label: 'Credits', href: '/dashboard/credits' },
|
||||
{ label: 'Notifications', href: '/dashboard/notifications' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white px-6 py-10">
|
||||
<motion.div
|
||||
className="max-w-5xl mx-auto space-y-6"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
<motion.header
|
||||
variants={cardVariants}
|
||||
className="flex flex-wrap items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
||||
<SlidersHorizontal className="h-5 w-5 text-[#8b5cf6]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<p className="text-xs text-[#9ca3af]">Manage preferences and account details</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = pathname === link.href;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`px-3 py-1.5 rounded-full border text-xs transition ${
|
||||
isActive
|
||||
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40 text-white'
|
||||
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<motion.div variants={cardVariants} className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-[#8b5cf6]" />
|
||||
User Profile
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4">
|
||||
<p className="text-xs uppercase text-[#9ca3af] flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</p>
|
||||
<p className="text-sm mt-2">{user.email}</p>
|
||||
</div>
|
||||
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4">
|
||||
<p className="text-xs uppercase text-[#9ca3af] flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Display Name
|
||||
</p>
|
||||
<input
|
||||
value={profileForm.displayName}
|
||||
onChange={(event) => handleProfileChange('displayName', event.target.value)}
|
||||
className="mt-2 w-full rounded-xl bg-transparent border border-white/10 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
{profileErrors.displayName && (
|
||||
<p className="text-xs text-red-300 mt-2">{profileErrors.displayName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4 md:col-span-2">
|
||||
<p className="text-xs uppercase text-[#9ca3af] flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" />
|
||||
Phone
|
||||
</p>
|
||||
<input
|
||||
value={profileForm.phone}
|
||||
onChange={(event) => handleProfileChange('phone', event.target.value)}
|
||||
className="mt-2 w-full rounded-xl bg-transparent border border-white/10 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]"
|
||||
placeholder="+34 600 000 000"
|
||||
/>
|
||||
{profileErrors.phone && (
|
||||
<p className="text-xs text-red-300 mt-2">{profileErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isSavingProfile}
|
||||
className="px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition disabled:opacity-60"
|
||||
>
|
||||
{isSavingProfile ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-[#8b5cf6]" />
|
||||
Security
|
||||
</h2>
|
||||
<p className="text-sm text-[#9ca3af]">
|
||||
Reset your password to keep your account secure.
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePasswordReset}
|
||||
className="w-full md:w-auto px-4 py-2 rounded-2xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition flex items-center gap-2"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
{resetStatus && <p className="text-xs text-[#9ca3af]">{resetStatus}</p>}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-[#8b5cf6]" />
|
||||
Notification Preferences
|
||||
</h2>
|
||||
{[
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email notifications',
|
||||
value: emailNotifications,
|
||||
setter: setEmailNotifications,
|
||||
},
|
||||
{
|
||||
key: 'calls',
|
||||
label: 'Call alerts',
|
||||
value: callAlerts,
|
||||
setter: setCallAlerts,
|
||||
},
|
||||
{
|
||||
key: 'reports',
|
||||
label: 'Weekly usage reports',
|
||||
value: weeklyReports,
|
||||
setter: setWeeklyReports,
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<p className="text-sm">{item.label}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => item.setter(!item.value)}
|
||||
className={`h-6 w-12 rounded-full transition flex items-center ${
|
||||
item.value ? 'bg-[#8b5cf6]' : 'bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-5 w-5 rounded-full bg-white shadow transform transition ${
|
||||
item.value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-[#9ca3af]">
|
||||
Preferences save locally for now. TODO: persist in Firestore.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={cardVariants} className="space-y-6">
|
||||
<div className="bg-[#1a1625] border border-white/10 rounded-3xl p-6 shadow-2xl space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-[#8b5cf6]" />
|
||||
Account Info
|
||||
</h2>
|
||||
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4 space-y-2">
|
||||
<p className="text-xs uppercase text-[#9ca3af]">Member Since</p>
|
||||
<p className="text-sm">{creationDate}</p>
|
||||
</div>
|
||||
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4 space-y-2">
|
||||
<p className="text-xs uppercase text-[#9ca3af]">Total Credits Purchased</p>
|
||||
<p className="text-sm">
|
||||
{totalCreditsPurchased === null ? 'Loading…' : totalCreditsPurchased}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/credits')}
|
||||
className="w-full px-4 py-2 rounded-2xl bg-[#8b5cf6] text-white font-semibold shadow-2xl hover:bg-[#7c3aed] transition"
|
||||
>
|
||||
Go to Billing & Credits
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRestartTour}
|
||||
className="w-full px-4 py-2 rounded-2xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition"
|
||||
>
|
||||
Restart Tour
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user