5fef584d7d
- Added visually hidden labels, IDs, and names to form inputs across all key components and pages (modals, directories, chatbots) - Added tabIndex and onKeyDown handlers to clickable table rows in OwnerProjectDetail for keyboard accessibility - Validated existing ARIA roles and Escape key bindings on modal components
332 lines
21 KiB
React
332 lines
21 KiB
React
import React, { useState, useEffect } from 'react';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useMockStore } from '../data/mockStore';
|
|
import { SpotlightCard } from '../components/SpotlightCard';
|
|
import { User, Calendar, Clock, DollarSign, Save, Loader2, CheckCircle, History, Home, ShieldCheck } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
const CustomerProfile = () => {
|
|
const { user, updateProfile } = useAuth();
|
|
const { meetings, properties } = useMockStore();
|
|
const [activeTab, setActiveTab] = useState('details');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// Form State
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
address: ''
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setFormData({
|
|
name: user.name || '',
|
|
email: user.username || '', // Assuming username is email for customers
|
|
phone: user.phone || '555-0123', // Mock default if missing
|
|
address: user.address || '123 Maple Ave, Plano, TX' // Mock default
|
|
});
|
|
}
|
|
}, [user]);
|
|
|
|
const handleSaveProfile = async (e) => {
|
|
e.preventDefault();
|
|
setIsLoading(true);
|
|
try {
|
|
// Simulate API delay
|
|
await new Promise(resolve => setTimeout(resolve, 800));
|
|
|
|
updateProfile({
|
|
name: formData.name,
|
|
username: formData.email,
|
|
phone: formData.phone,
|
|
address: formData.address
|
|
});
|
|
|
|
toast.success("Profile updated successfully!");
|
|
} catch (error) {
|
|
toast.error("Failed to update profile.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Filter Meetings
|
|
const myMeetings = meetings.filter(m =>
|
|
(m.customerId === user?.id) ||
|
|
(m.customerName === user?.name) // Fallback for legacy data
|
|
);
|
|
|
|
// Get My Property
|
|
const myProperty = properties.find(p => p.propertyData.propertyId === user?.propertyId);
|
|
|
|
const upcomingVisits = myMeetings.filter(m => {
|
|
const meetingDate = new Date(m.date);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
return meetingDate >= today && m.status !== 'Completed' && m.status !== 'Cancelled';
|
|
}).sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
|
|
const pastVisits = myMeetings.filter(m => {
|
|
const meetingDate = new Date(m.date);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
return meetingDate < today || m.status === 'Completed';
|
|
}).sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-8 pt-24 transition-colors duration-300">
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center space-x-4 mb-8">
|
|
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg">
|
|
{user?.name?.charAt(0) || 'C'}
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60">
|
|
My Profile
|
|
</h1>
|
|
<p className="text-zinc-500 dark:text-zinc-400">Manage your account and view visit history.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex space-x-1 border-b border-zinc-200 dark:border-zinc-800 mb-6 overflow-x-auto scrollbar-hide">
|
|
<button
|
|
onClick={() => setActiveTab('details')}
|
|
className={`px-4 md:px-6 py-3 text-xs md:text-sm font-bold uppercase tracking-wider border-b-2 transition-all whitespace-nowrap ${activeTab === 'details'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
Personal Details
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('property')}
|
|
className={`px-4 md:px-6 py-3 text-xs md:text-sm font-bold uppercase tracking-wider border-b-2 transition-all whitespace-nowrap ${activeTab === 'property'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
My Property
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('visits')}
|
|
className={`px-4 md:px-6 py-3 text-xs md:text-sm font-bold uppercase tracking-wider border-b-2 transition-all whitespace-nowrap ${activeTab === 'visits'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
Visit Management
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
{activeTab === 'details' ? (
|
|
<SpotlightCard>
|
|
<form onSubmit={handleSaveProfile} className="p-8 space-y-6">
|
|
<FormInput id="customer-name" name="name" label="Full Name" icon={User} value={formData.name} onChange={v => setFormData({ ...formData, name: v })} required />
|
|
<FormInput id="customer-email" name="email" label="Email Address" icon={null} prefix="@" type="email" value={formData.email} onChange={v => setFormData({ ...formData, email: v })} required />
|
|
<FormInput id="customer-phone" name="phone" label="Phone Number" value={formData.phone} onChange={v => setFormData({ ...formData, phone: v })} required />
|
|
<div className="space-y-2 md:col-span-2">
|
|
<label htmlFor="customer-address" className="text-xs font-bold uppercase text-zinc-500">Address</label>
|
|
<textarea
|
|
id="customer-address"
|
|
name="address"
|
|
value={formData.address}
|
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all h-24 resize-none"
|
|
placeholder="1234 Main St..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
className="flex items-center px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? (
|
|
<><Loader2 className="animate-spin mr-2" size={18} /> Saving...</>
|
|
) : (
|
|
<><Save className="mr-2" size={18} /> Save Changes</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SpotlightCard>
|
|
) : activeTab === 'property' ? (
|
|
<SpotlightCard>
|
|
<form onSubmit={(e) => { e.preventDefault(); toast.success("Property details updated!"); }} className="p-8 space-y-8">
|
|
|
|
{/* Property Basics */}
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-2 pb-4 border-b border-zinc-200 dark:border-white/5 text-blue-600 dark:text-blue-400">
|
|
<Home size={20} /> <h3 className="text-sm font-black uppercase tracking-widest">Property Basics</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="md:col-span-2">
|
|
<FormInput id="prop-address" name="prop-address" label="Address" value="2612 Dunwick Dr, Plano, TX 75023" onChange={() => { }} />
|
|
</div>
|
|
<FormInput id="prop-type" name="prop-type" label="Type" value="Residential" onChange={() => { }} />
|
|
<FormInput id="prop-year" name="prop-year" label="Year Built" value="1971" onChange={() => { }} />
|
|
<FormInput id="prop-area-sqft" name="prop-area-sqft" label="Built-Up Area (sqft)" value="2450" onChange={() => { }} />
|
|
<FormInput id="prop-lot-sqft" name="prop-lot-sqft" label="Lot Size (sqft)" value="8500" onChange={() => { }} />
|
|
<div className="grid grid-cols-3 gap-4 md:col-span-2">
|
|
<FormInput id="prop-beds" name="prop-beds" label="Beds" value="4" onChange={() => { }} />
|
|
<FormInput id="prop-baths" name="prop-baths" label="Baths" value="3" onChange={() => { }} />
|
|
<FormInput id="prop-parking" name="prop-parking" label="Parking" value="2" onChange={() => { }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Insurance Information */}
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-2 pb-4 border-b border-zinc-200 dark:border-white/5 text-purple-600 dark:text-purple-400">
|
|
<ShieldCheck size={20} /> <h3 className="text-sm font-black uppercase tracking-widest">Insurance Information</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<FormInput id="ins-company" name="ins-company" label="Insurance Company" value="Farmers" onChange={() => { }} />
|
|
<FormInput id="ins-policy" name="ins-policy" label="Policy Number" value="POL-987654321" onChange={() => { }} />
|
|
<FormInput id="ins-claim-filed" name="ins-claim-filed" label="Claim Filed?" value="No" onChange={() => { }} />
|
|
<FormInput id="ins-adjuster" name="ins-adjuster" label="Adjuster Name" value="" placeholder="N/A" onChange={() => { }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
|
<button
|
|
type="submit"
|
|
className="flex items-center px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all"
|
|
>
|
|
<Save className="mr-2" size={18} /> Save Details
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SpotlightCard>
|
|
) : (
|
|
<div className="space-y-8">
|
|
{/* Upcoming Visits */}
|
|
<section>
|
|
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
|
|
<Calendar className="mr-2 text-blue-500" size={20} /> Upcoming Visits
|
|
</h3>
|
|
{upcomingVisits.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{upcomingVisits.map(visit => (
|
|
<SpotlightCard key={visit.id} className="p-6">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex flex-col items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20">
|
|
<span className="text-[10px] uppercase font-bold">{new Date(visit.date).toLocaleString('default', { month: 'short' })}</span>
|
|
<span className="text-lg font-bold leading-none">{new Date(visit.date).getDate()}</span>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-bold text-lg">{visit.agentName || 'Assigned Agent'}</h4>
|
|
<div className="flex items-center text-sm text-zinc-500 dark:text-zinc-400">
|
|
<Clock size={14} className="mr-1" /> {visit.time}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="px-3 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 text-xs font-bold uppercase rounded-full">
|
|
{visit.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</SpotlightCard>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="p-8 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
|
No upcoming visits scheduled.
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Visit History */}
|
|
<section>
|
|
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
|
|
<History className="mr-2 text-zinc-500" size={20} /> Visit History
|
|
</h3>
|
|
{pastVisits.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{pastVisits.map(visit => (
|
|
<SpotlightCard key={visit.id} className="p-6 opacity-80 hover:opacity-100 transition-opacity">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500">
|
|
<CheckCircle size={20} />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-bold">{visit.agentName || 'Agent'}</h4>
|
|
<span className="text-xs text-zinc-400">• {new Date(visit.date).toLocaleDateString()}</span>
|
|
</div>
|
|
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
|
{visit.notes || 'No notes available.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{visit.dealValue !== undefined && (
|
|
<div className="flex items-center space-x-2 bg-emerald-50 dark:bg-emerald-900/10 px-4 py-2 rounded-lg border border-emerald-100 dark:border-emerald-500/20">
|
|
<DollarSign size={16} className="text-emerald-600 dark:text-emerald-400" />
|
|
<span className="font-bold text-emerald-700 dark:text-emerald-300">
|
|
${visit.dealValue.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SpotlightCard>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="p-8 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
|
No history available.
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Helper Components
|
|
const ReadOnlyField = ({ label, value }) => (
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-bold uppercase text-zinc-500 tracking-wider">{label}</label>
|
|
<div className="text-sm font-medium text-zinc-900 dark:text-zinc-200 border-b border-zinc-200 dark:border-white/10 pb-1">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const FormInput = ({ id, name, label, icon: Icon, prefix, value, onChange, type = "text", required }) => (
|
|
<div className="space-y-2">
|
|
<label htmlFor={id} className="text-xs font-bold uppercase text-zinc-500">{label} {required && <span className="text-red-500">*</span>}</label>
|
|
<div className="relative">
|
|
{Icon && <Icon className="absolute left-3 top-3 text-zinc-400" size={16} />}
|
|
{prefix && <span className="absolute left-3 top-3 text-zinc-400 text-xs">{prefix}</span>}
|
|
<input
|
|
id={id}
|
|
name={name || id}
|
|
type={type}
|
|
required={required}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={`w-full ${Icon || prefix ? 'pl-10' : 'px-4'} pr-4 py-2 bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export default CustomerProfile;
|