Initial commit: SmoothSchedule multi-tenant scheduling platform

This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Customer, User } from '../types';
import { useCustomers, useCreateCustomer } from '../hooks/useCustomers';
import {
Search,
Plus,
MoreHorizontal,
Filter,
ArrowUpDown,
Mail,
Phone,
X,
Eye
} from 'lucide-react';
import Portal from '../components/Portal';
interface CustomersProps {
onMasquerade: (user: User) => void;
effectiveUser: User;
}
const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<{ key: keyof Customer; direction: 'asc' | 'desc' }>({
key: 'name',
direction: 'asc'
});
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
tags: '',
city: '',
state: '',
zip: ''
});
const { data: customers = [], isLoading, error } = useCustomers();
const createCustomerMutation = useCreateCustomer();
const handleSort = (key: keyof Customer) => {
setSortConfig(current => ({
key,
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc',
}));
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleAddCustomer = (e: React.FormEvent) => {
e.preventDefault();
const newCustomer: Partial<Customer> = {
phone: formData.phone,
city: formData.city,
state: formData.state,
zip: formData.zip,
status: 'Active',
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
};
createCustomerMutation.mutate(newCustomer);
setIsAddModalOpen(false);
setFormData({ name: '', email: '', phone: '', tags: '', city: '', state: '', zip: '' });
};
const filteredCustomers = useMemo(() => {
let sorted = [...customers];
if (searchTerm) {
const lowerTerm = searchTerm.toLowerCase();
sorted = sorted.filter(c =>
c.name.toLowerCase().includes(lowerTerm) ||
c.email.toLowerCase().includes(lowerTerm) ||
c.phone.includes(searchTerm)
);
}
sorted.sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [customers, searchTerm, sortConfig]);
const canMasquerade = ['owner', 'manager', 'staff'].includes(effectiveUser.role);
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">{t('customers.errorLoading')}: {(error as Error).message}</p>
</div>
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('customers.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('customers.description')}</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('customers.addCustomer')}
</button>
</div>
<div className="flex items-center justify-between gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-200">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder={t('customers.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors duration-200 placeholder-gray-400 dark:placeholder-gray-500"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors duration-200">
<Filter size={16} />
{t('customers.filters')}
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('name')}>
<div className="flex items-center gap-1">{t('customers.customer')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium">{t('customers.contactInfo')}</th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('status')}>
<div className="flex items-center gap-1">{t('customers.status')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors text-right" onClick={() => handleSort('totalSpend')}>
<div className="flex items-center justify-end gap-1">{t('customers.totalSpend')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors text-right" onClick={() => handleSort('lastVisit')}>
<div className="flex items-center justify-end gap-1">{t('customers.lastVisit')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{filteredCustomers.map((customer: any) => {
const customerUser = customer.user_data;
return (
<tr key={customer.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
{customer.avatarUrl ? <img src={customer.avatarUrl} alt={customer.name} className="w-full h-full object-cover" /> : <span className="font-semibold text-gray-500 dark:text-gray-400">{customer.name.substring(0, 2).toUpperCase()}</span>}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{customer.name}</div>
{customer.tags && (<div className="flex gap-1 mt-1">{customer.tags.map(tag => (<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">{tag}</span>))}</div>)}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400"><Mail size={14} className="text-gray-400" />{customer.email}</div>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400"><Phone size={14} className="text-gray-400" />{customer.phone}</div>
</div>
</td>
<td className="px-6 py-4"><span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${customer.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : customer.status === 'Inactive' ? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}`}>{customer.status}</span></td>
<td className="px-6 py-4 text-right font-medium text-gray-900 dark:text-white">${customer.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{canMasquerade && customerUser && (
<button
onClick={() => onMasquerade(customerUser)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
title={t('common.masqueradeAsUser')}
>
<Eye size={14} /> {t('common.masquerade')}
</button>
)}
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{filteredCustomers.length === 0 && (<div className="p-12 text-center"><p className="text-gray-500 dark:text-gray-400">{t('customers.noCustomersFound')}</p></div>)}
</div>
</div>
{isAddModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-xl bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('customers.addNewCustomer')}</h3>
<button onClick={() => setIsAddModalOpen(false)} className="p-1 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleAddCustomer} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.fullName')} <span className="text-red-500">*</span></label>
<input type="text" name="name" required value={formData.name} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder="e.g. John Doe" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.emailAddress')} <span className="text-red-500">*</span></label>
<input type="email" name="email" required value={formData.email} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder="e.g. john@example.com" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.phoneNumber')} <span className="text-red-500">*</span></label>
<input type="tel" name="phone" required value={formData.phone} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder="e.g. (555) 123-4567" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.city')}</label>
<input type="text" name="city" value={formData.city} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.city')} />
</div>
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.state')}</label>
<input type="text" name="state" value={formData.state} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.state')} />
</div>
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.zipCode')}</label>
<input type="text" name="zip" value={formData.zip} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.zipCode')} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.tagsCommaSeparated')}</label>
<input type="text" name="tags" value={formData.tags} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.tagsPlaceholder')} />
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={() => setIsAddModalOpen(false)} className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">{t('common.cancel')}</button>
<button type="submit" className="flex-1 px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm">{t('customers.createCustomer')}</button>
</div>
</form>
</div>
</div>
</Portal>
)}
</div>
);
};
export default Customers;

View File

@@ -0,0 +1,209 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line
} from 'recharts';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { useServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { useAppointments } from '../hooks/useAppointments';
import { useCustomers } from '../hooks/useCustomers';
import QuickAddAppointment from '../components/QuickAddAppointment';
interface Metric {
label: string;
value: string;
trend: 'up' | 'down' | 'neutral';
change: string;
}
const Dashboard: React.FC = () => {
const { t } = useTranslation();
const { data: services, isLoading: servicesLoading } = useServices();
const { data: resources, isLoading: resourcesLoading } = useResources();
const { data: appointments, isLoading: appointmentsLoading } = useAppointments();
const { data: customers, isLoading: customersLoading } = useCustomers();
const isLoading = servicesLoading || resourcesLoading || appointmentsLoading || customersLoading;
// Calculate metrics from real data
const metrics: Metric[] = useMemo(() => {
if (!appointments || !customers || !services || !resources) {
return [
{ label: t('dashboard.totalAppointments'), value: '0', trend: 'neutral', change: '0%' },
{ label: t('customers.title'), value: '0', trend: 'neutral', change: '0%' },
{ label: t('services.title'), value: '0', trend: 'neutral', change: '0%' },
{ label: t('resources.title'), value: '0', trend: 'neutral', change: '0%' },
];
}
const activeCustomers = customers.filter(c => c.status === 'Active').length;
return [
{ label: t('dashboard.totalAppointments'), value: appointments.length.toString(), trend: 'up', change: '+12%' },
{ label: t('customers.title'), value: activeCustomers.toString(), trend: 'up', change: '+8%' },
{ label: t('services.title'), value: services.length.toString(), trend: 'neutral', change: '0%' },
{ label: t('resources.title'), value: resources.length.toString(), trend: 'up', change: '+3%' },
];
}, [appointments, customers, services, resources, t]);
// Calculate weekly data from appointments
const weeklyData = useMemo(() => {
if (!appointments) {
return [
{ name: 'Mon', revenue: 0, appointments: 0 },
{ name: 'Tue', revenue: 0, appointments: 0 },
{ name: 'Wed', revenue: 0, appointments: 0 },
{ name: 'Thu', revenue: 0, appointments: 0 },
{ name: 'Fri', revenue: 0, appointments: 0 },
{ name: 'Sat', revenue: 0, appointments: 0 },
{ name: 'Sun', revenue: 0, appointments: 0 },
];
}
// Group appointments by day of week
const dayMap: { [key: string]: { revenue: number; count: number } } = {
'Mon': { revenue: 0, count: 0 },
'Tue': { revenue: 0, count: 0 },
'Wed': { revenue: 0, count: 0 },
'Thu': { revenue: 0, count: 0 },
'Fri': { revenue: 0, count: 0 },
'Sat': { revenue: 0, count: 0 },
'Sun': { revenue: 0, count: 0 },
};
appointments.forEach(appt => {
const date = new Date(appt.startTime);
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayName = dayNames[date.getDay()];
dayMap[dayName].count++;
// Use price from appointment or default to 0
dayMap[dayName].revenue += appt.price || 0;
});
return [
{ name: 'Mon', revenue: dayMap['Mon'].revenue, appointments: dayMap['Mon'].count },
{ name: 'Tue', revenue: dayMap['Tue'].revenue, appointments: dayMap['Tue'].count },
{ name: 'Wed', revenue: dayMap['Wed'].revenue, appointments: dayMap['Wed'].count },
{ name: 'Thu', revenue: dayMap['Thu'].revenue, appointments: dayMap['Thu'].count },
{ name: 'Fri', revenue: dayMap['Fri'].revenue, appointments: dayMap['Fri'].count },
{ name: 'Sat', revenue: dayMap['Sat'].revenue, appointments: dayMap['Sat'].count },
{ name: 'Sun', revenue: dayMap['Sun'].revenue, appointments: dayMap['Sun'].count },
];
}, [appointments]);
if (isLoading) {
return (
<div className="p-8 space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('dashboard.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('common.loading')}</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm animate-pulse">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
))}
</div>
</div>
);
}
return (
<div className="p-8 space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('dashboard.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('dashboard.todayOverview')}</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric, index) => (
<div key={index} className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{metric.label}</p>
<div className="flex items-baseline gap-2 mt-2">
<span className="text-2xl font-bold text-gray-900 dark:text-white">{metric.value}</span>
<span className={`flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${
metric.trend === 'up' ? 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400' :
metric.trend === 'down' ? 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400' : 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300'
}`}>
{metric.trend === 'up' && <TrendingUp size={12} className="mr-1" />}
{metric.trend === 'down' && <TrendingDown size={12} className="mr-1" />}
{metric.trend === 'neutral' && <Minus size={12} className="mr-1" />}
{metric.change}
</span>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Quick Add Appointment */}
<div className="lg:col-span-1">
<QuickAddAppointment />
</div>
{/* Revenue Chart */}
<div className="lg:col-span-2 p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.totalRevenue')}</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<YAxis axisLine={false} tickLine={false} tickFormatter={(value) => `$${value}`} tick={{ fill: '#9CA3AF' }} />
<Tooltip
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6'
}}
/>
<Bar dataKey="revenue" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Appointments Chart - Full Width */}
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.upcomingAppointments')}</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={weeklyData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6'
}}
/>
<Line type="monotone" dataKey="appointments" stroke="#10b981" strokeWidth={3} dot={{ r: 4, fill: '#10b981' }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,201 @@
/**
* Email Verification Required Page
*
* Displayed when a user needs to verify their email address before accessing the application.
* Provides options to resend verification email and log out.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCurrentUser } from '../hooks/useAuth';
import apiClient from '../api/client';
import { useLogout } from '../hooks/useAuth';
const EmailVerificationRequired: React.FC = () => {
const { t } = useTranslation();
const { data: user } = useCurrentUser();
const logoutMutation = useLogout();
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleResendEmail = async () => {
setSending(true);
setError(null);
setSent(false);
try {
await apiClient.post('/api/auth/email/verify/send/');
setSent(true);
setTimeout(() => setSent(false), 5000); // Hide success message after 5 seconds
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to send verification email');
} finally {
setSending(false);
}
};
const handleLogout = () => {
logoutMutation.mutate();
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 px-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8">
{/* Icon */}
<div className="flex justify-center mb-6">
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
<svg
className="w-10 h-10 text-amber-600 dark:text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">
Email Verification Required
</h1>
{/* Message */}
<p className="text-center text-gray-600 dark:text-gray-400 mb-6">
Please verify your email address to access your account.
</p>
{/* Email Display */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
Verification email sent to:
</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{user?.email}
</p>
</div>
{/* Instructions */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-800 dark:text-blue-300">
Check your inbox for a verification email and click the link to verify your account.
Don't forget to check your spam folder if you don't see it.
</p>
</div>
{/* Success Message */}
{sent && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-4">
<p className="text-sm text-green-800 dark:text-green-300 text-center">
Verification email sent successfully! Check your inbox.
</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<p className="text-sm text-red-800 dark:text-red-300 text-center">
{error}
</p>
</div>
)}
{/* Actions */}
<div className="space-y-3">
{/* Resend Email Button */}
<button
onClick={handleResendEmail}
disabled={sending || sent}
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
>
{sending ? (
<>
<svg
className="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Sending...
</>
) : sent ? (
<>
<svg
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
Email Sent
</>
) : (
<>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Resend Verification Email
</>
)}
</button>
{/* Logout Button */}
<button
onClick={handleLogout}
className="w-full px-4 py-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors duration-200"
>
Log Out
</button>
</div>
{/* Help Text */}
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
Need help? Contact support at{' '}
<a
href="mailto:support@smoothschedule.com"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
support@smoothschedule.com
</a>
</p>
</div>
</div>
);
};
export default EmailVerificationRequired;

View File

@@ -0,0 +1,249 @@
/**
* Login Page Component
* Professional login form connected to the API with visual improvements
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLogin } from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
import OAuthButtons from '../components/OAuthButtons';
import LanguageSelector from '../components/LanguageSelector';
import { AlertCircle, Loader2, User, Lock, ArrowRight } from 'lucide-react';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const loginMutation = useLogin();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
loginMutation.mutate(
{ username, password },
{
onSuccess: (data) => {
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
// Check if we're on the root domain (no subdomain)
const isRootDomain = currentHostname === 'lvh.me' || currentHostname === 'localhost';
// Roles allowed to login at the root domain
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner'];
// If on root domain, only allow specific roles
if (isRootDomain && !rootAllowedRoles.includes(user.role)) {
setError(t('auth.loginAtSubdomain'));
return;
}
// Determine the correct subdomain based on user role
let targetSubdomain: string | null = null;
// Platform users (superuser, platform_manager, platform_support)
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
}
// Business users - redirect to their business subdomain
else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
// Check if we need to redirect to a different subdomain
// Need to redirect if we have a target subdomain AND we're not already on it
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
if (needsRedirect) {
// Pass tokens in URL to ensure they're available immediately on the new subdomain
// This avoids race conditions where cookies might not be set before the page loads
const portStr = currentPort ? `:${currentPort}` : '';
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
return;
}
// Already on correct subdomain - navigate to dashboard
navigate('/');
},
onError: (err: any) => {
setError(err.response?.data?.error || t('auth.invalidCredentials'));
},
}
);
};
return (
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
{/* Left Side - Image & Branding (Hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative bg-gray-900 text-white overflow-hidden">
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1497215728101-856f4ea42174?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1950&q=80')] bg-cover bg-center opacity-40"></div>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent to-gray-900/50"></div>
<div className="relative z-10 flex flex-col justify-between w-full p-12">
<div>
<div className="flex items-center gap-3 text-white/90">
<SmoothScheduleLogo className="w-8 h-8 text-brand-500" />
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
</div>
</div>
<div className="space-y-6 max-w-md">
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
{t('marketing.tagline')}
</h1>
<p className="text-lg text-gray-300">
{t('marketing.description')}
</p>
<div className="flex gap-2 pt-4">
<div className="h-1 w-12 bg-brand-500 rounded-full"></div>
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
</div>
</div>
<div className="text-sm text-gray-500">
© {new Date().getFullYear()} {t('marketing.copyright')}
</div>
</div>
</div>
{/* Right Side - Login Form */}
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900">
<div className="mx-auto w-full max-w-sm lg:max-w-md">
<div className="text-center lg:text-left mb-10">
<div className="lg:hidden flex justify-center mb-6">
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
{t('auth.welcomeBack')}
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t('auth.pleaseEnterDetails')}
</p>
</div>
{error && (
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50 animate-in fade-in slide-in-from-top-2">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
{t('auth.authError')}
</h3>
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
{error}
</div>
</div>
</div>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('auth.username')}
</label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
placeholder={t('auth.enterUsername')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('auth.password')}
</label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70 disabled:cursor-not-allowed transition-all duration-200 ease-in-out transform active:scale-[0.98]"
>
{loginMutation.isPending ? (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-5 w-5" />
{t('auth.signingIn')}
</span>
) : (
<span className="flex items-center gap-2">
{t('auth.signIn')}
<ArrowRight className="h-4 w-4" />
</span>
)}
</button>
</form>
{/* OAuth Divider and Buttons */}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">
{t('auth.orContinueWith')}
</span>
</div>
</div>
<div className="mt-6">
<OAuthButtons
disabled={loginMutation.isPending}
/>
</div>
</div>
{/* Language Selector */}
<div className="mt-8 flex justify-center">
<LanguageSelector />
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,258 @@
/**
* OAuth Callback Page
* Handles OAuth provider redirects and completes authentication
*/
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { handleOAuthCallback } from '../api/oauth';
import { setCookie } from '../utils/cookies';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
const OAuthCallback: React.FC = () => {
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState('');
const navigate = useNavigate();
const { provider } = useParams<{ provider: string }>();
const location = useLocation();
useEffect(() => {
const processCallback = async () => {
try {
// Check if we're in a popup window
const isPopup = window.opener && window.opener !== window;
// Extract OAuth callback parameters
// Try both query params and hash params (some providers use hash)
const searchParams = new URLSearchParams(location.search);
const hashParams = new URLSearchParams(location.hash.substring(1));
const code = searchParams.get('code') || hashParams.get('code');
const state = searchParams.get('state') || hashParams.get('state');
const error = searchParams.get('error') || hashParams.get('error');
const errorDescription = searchParams.get('error_description') || hashParams.get('error_description');
// Check for OAuth errors
if (error) {
const message = errorDescription || error || 'Authentication failed';
throw new Error(message);
}
// Validate required parameters
if (!code || !state) {
throw new Error('Missing required OAuth parameters');
}
if (!provider) {
throw new Error('Missing OAuth provider');
}
// Exchange code for tokens
const response = await handleOAuthCallback(provider, code, state);
// Store tokens in cookies (accessible across subdomains)
setCookie('access_token', response.access, 7);
setCookie('refresh_token', response.refresh, 7);
// Clear session cookie to prevent interference with JWT
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
setStatus('success');
// Determine redirect URL based on user role
const user = response.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetUrl = '/';
let needsRedirect = false;
// Platform users (superuser, platform_manager, platform_support)
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
const targetHostname = 'platform.lvh.me';
needsRedirect = currentHostname !== targetHostname;
if (needsRedirect) {
const portStr = currentPort ? `:${currentPort}` : '';
targetUrl = `http://${targetHostname}${portStr}/`;
}
}
// Business users - redirect to their business subdomain
else if (user.business_subdomain) {
const targetHostname = `${user.business_subdomain}.lvh.me`;
needsRedirect = currentHostname !== targetHostname;
if (needsRedirect) {
const portStr = currentPort ? `:${currentPort}` : '';
targetUrl = `http://${targetHostname}${portStr}/`;
}
}
// Handle popup vs redirect flows
if (isPopup) {
// Post message to parent window
window.opener.postMessage(
{
type: 'oauth-success',
provider,
user: response.user,
needsRedirect,
targetUrl,
},
window.location.origin
);
// Close popup after short delay
setTimeout(() => {
window.close();
}, 1000);
} else {
// Standard redirect flow
setTimeout(() => {
if (needsRedirect) {
// Redirect to different subdomain
window.location.href = targetUrl;
} else {
// Navigate to dashboard on same subdomain
navigate(targetUrl);
}
}, 1500);
}
} catch (err: any) {
console.error('OAuth callback error:', err);
setStatus('error');
setErrorMessage(err.message || 'Authentication failed. Please try again.');
// If in popup, post error to parent
if (window.opener && window.opener !== window) {
window.opener.postMessage(
{
type: 'oauth-error',
provider,
error: err.message || 'Authentication failed',
},
window.location.origin
);
// Close popup after delay
setTimeout(() => {
window.close();
}, 3000);
}
}
};
processCallback();
}, [provider, location, navigate]);
const handleTryAgain = () => {
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
const portStr = currentPort ? `:${currentPort}` : '';
// Redirect to login page
if (currentHostname.includes('platform.lvh.me')) {
window.location.href = `http://platform.lvh.me${portStr}/login`;
} else if (currentHostname.includes('.lvh.me')) {
// On business subdomain - go to their login
window.location.href = `http://${currentHostname}${portStr}/login`;
} else {
// Fallback
navigate('/login');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full px-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
{/* Logo */}
<div className="flex justify-center mb-6">
<div className="flex items-center gap-3">
<SmoothScheduleLogo className="w-10 h-10 text-brand-500" />
<span className="font-bold text-xl tracking-tight text-gray-900 dark:text-white">
Smooth Schedule
</span>
</div>
</div>
{/* Processing State */}
{status === 'processing' && (
<div className="text-center">
<div className="flex justify-center mb-4">
<Loader2 className="w-12 h-12 text-blue-600 animate-spin" />
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Completing Sign In...
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we authenticate your account
</p>
</div>
)}
{/* Success State */}
{status === 'success' && (
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-green-100 dark:bg-green-900/30 p-3">
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Authentication Successful!
</h2>
<p className="text-gray-600 dark:text-gray-400">
Redirecting to your dashboard...
</p>
</div>
)}
{/* Error State */}
{status === 'error' && (
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-red-100 dark:bg-red-900/30 p-3">
<AlertCircle className="w-12 h-12 text-red-600 dark:text-red-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Authentication Failed
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{errorMessage}
</p>
<button
onClick={handleTryAgain}
className="w-full px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Try Again
</button>
</div>
)}
{/* Provider Info */}
{provider && status === 'processing' && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
Authenticating with{' '}
<span className="font-medium capitalize">{provider}</span>
</p>
</div>
)}
</div>
{/* Additional Help Text */}
{status === 'error' && (
<div className="mt-4 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
If the problem persists, please contact support
</p>
</div>
)}
</div>
</div>
);
};
export default OAuthCallback;

View File

@@ -0,0 +1,990 @@
/**
* Owner Scheduler - Horizontal timeline view for owner/manager/staff users
*/
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Appointment, User, Business } from '../types';
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight } from 'lucide-react';
import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
import Portal from '../components/Portal';
// Time settings
const START_HOUR = 0; // Midnight
const END_HOUR = 24; // Midnight next day
const PIXELS_PER_MINUTE = 2.5;
const HEADER_HEIGHT = 48;
const SIDEBAR_WIDTH = 250;
// Format duration as hours and minutes when >= 60 min
const formatDuration = (minutes: number): string => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${minutes} min`;
};
// Layout settings
const MIN_ROW_HEIGHT = 104;
const EVENT_HEIGHT = 88;
const EVENT_GAP = 8;
interface OwnerSchedulerProps {
user: User;
business: Business;
}
const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
type ViewMode = 'day' | 'week' | 'month';
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [viewDate, setViewDate] = useState(new Date());
// Calculate date range for fetching appointments based on current view
const dateRange = useMemo(() => {
const getStartOfWeek = (date: Date): Date => {
const d = new Date(date);
const day = d.getDay();
d.setDate(d.getDate() - day);
d.setHours(0, 0, 0, 0);
return d;
};
if (viewMode === 'day') {
const start = new Date(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { startDate: start, endDate: end };
} else if (viewMode === 'week') {
const start = getStartOfWeek(viewDate);
const end = new Date(start);
end.setDate(end.getDate() + 7);
return { startDate: start, endDate: end };
} else {
// Month view
const start = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
const end = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1);
return { startDate: start, endDate: end };
}
}, [viewMode, viewDate]);
// Fetch only appointments in the visible date range (plus pending ones)
const { data: appointments = [] } = useAppointments(dateRange);
const { data: resources = [] } = useResources();
const { data: services = [] } = useServices();
const updateMutation = useUpdateAppointment();
const deleteMutation = useDeleteAppointment();
// Connect to WebSocket for real-time updates
useAppointmentWebSocket();
const [zoomLevel, setZoomLevel] = useState(1);
const [draggedAppointmentId, setDraggedAppointmentId] = useState<string | null>(null);
const [dragOffsetMinutes, setDragOffsetMinutes] = useState<number>(0); // Track where on appointment drag started
const [previewState, setPreviewState] = useState<{ resourceId: string; startTime: Date; } | null>(null);
const [resizeState, setResizeState] = useState<{ appointmentId: string; direction: 'start' | 'end'; startX: number; originalStart: Date; originalDuration: number; newStart?: Date; newDuration?: number; } | null>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
// State for editing appointments
const [editDateTime, setEditDateTime] = useState('');
const [editResource, setEditResource] = useState('');
const [editDuration, setEditDuration] = useState(0);
// Update edit state when selected appointment changes
useEffect(() => {
if (selectedAppointment) {
setEditDateTime(new Date(selectedAppointment.startTime).toISOString().slice(0, 16));
setEditResource(selectedAppointment.resourceId || '');
setEditDuration(selectedAppointment.durationMinutes);
}
}, [selectedAppointment]);
// Undo/Redo history
type HistoryAction = {
type: 'move' | 'resize';
appointmentId: string;
before: { startTime: Date; resourceId: string | null; durationMinutes?: number };
after: { startTime: Date; resourceId: string | null; durationMinutes?: number };
};
const [history, setHistory] = useState<HistoryAction[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Keyboard shortcuts for undo/redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
redo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [historyIndex, history]);
// Scroll to current time on mount (centered in view)
useEffect(() => {
if (!scrollContainerRef.current) return;
const now = new Date();
const today = new Date(viewDate);
today.setHours(0, 0, 0, 0);
const nowDay = new Date(now);
nowDay.setHours(0, 0, 0, 0);
// Only scroll if today is in the current view
if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return;
const container = scrollContainerRef.current;
const containerWidth = container.clientWidth;
// Calculate current time offset in pixels
const startOfDay = new Date(now);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE * zoomLevel;
// Scroll so current time is centered
const scrollPosition = currentTimeOffset - (containerWidth / 2);
container.scrollLeft = Math.max(0, scrollPosition);
}, []);
const addToHistory = (action: HistoryAction) => {
// Remove any history after current index (when doing new action after undo)
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(action);
// Limit history to 50 actions
if (newHistory.length > 50) {
newHistory.shift();
} else {
setHistoryIndex(historyIndex + 1);
}
setHistory(newHistory);
};
const undo = () => {
if (historyIndex < 0) return;
const action = history[historyIndex];
const appointment = appointments.find(a => a.id === action.appointmentId);
if (!appointment) return;
// Revert to "before" state
updateMutation.mutate({
id: action.appointmentId,
updates: {
startTime: action.before.startTime,
resourceId: action.before.resourceId,
...(action.before.durationMinutes !== undefined && { durationMinutes: action.before.durationMinutes })
}
});
setHistoryIndex(historyIndex - 1);
};
const redo = () => {
if (historyIndex >= history.length - 1) return;
const action = history[historyIndex + 1];
const appointment = appointments.find(a => a.id === action.appointmentId);
if (!appointment) return;
// Apply "after" state
updateMutation.mutate({
id: action.appointmentId,
updates: {
startTime: action.after.startTime,
resourceId: action.after.resourceId,
...(action.after.durationMinutes !== undefined && { durationMinutes: action.after.durationMinutes })
}
});
setHistoryIndex(historyIndex + 1);
};
// Date navigation helpers
const getStartOfWeek = (date: Date): Date => {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day; // Sunday as start of week
return new Date(d.setDate(diff));
};
const getStartOfMonth = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), 1);
};
const getEndOfMonth = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
};
const navigateDate = (direction: 'prev' | 'next') => {
const newDate = new Date(viewDate);
if (viewMode === 'day') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
} else if (viewMode === 'week') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
} else if (viewMode === 'month') {
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
}
setViewDate(newDate);
};
const getDateRangeLabel = (): string => {
if (viewMode === 'day') {
return viewDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
} else if (viewMode === 'week') {
const weekStart = getStartOfWeek(viewDate);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
return `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
} else {
return viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
};
// Get the date range for filtering appointments
const getDateRange = (): { start: Date; end: Date; days: Date[] } => {
if (viewMode === 'day') {
const start = new Date(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end, days: [start] };
} else if (viewMode === 'week') {
const start = getStartOfWeek(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 7);
const days = Array.from({ length: 7 }, (_, i) => {
const day = new Date(start);
day.setDate(day.getDate() + i);
return day;
});
return { start, end, days };
} else {
const start = getStartOfMonth(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(getEndOfMonth(viewDate));
end.setDate(end.getDate() + 1);
end.setHours(0, 0, 0, 0);
const daysInMonth = end.getDate() - start.getDate();
const days = Array.from({ length: daysInMonth }, (_, i) => {
const day = new Date(start);
day.setDate(day.getDate() + i);
return day;
});
return { start, end, days };
}
};
const handleResizeStart = (
e: React.MouseEvent,
appointment: Appointment,
direction: 'start' | 'end'
) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeState({
appointmentId: appointment.id,
direction,
startX: e.clientX,
originalStart: new Date(appointment.startTime),
originalDuration: appointment.durationMinutes,
});
};
useEffect(() => {
if (!resizeState) return;
const handleMouseMove = (e: MouseEvent) => {
const pixelDelta = e.clientX - resizeState.startX;
const minuteDelta = pixelDelta / (PIXELS_PER_MINUTE * zoomLevel);
const snappedMinutes = Math.round(minuteDelta / 15) * 15;
if (snappedMinutes === 0 && resizeState.direction === 'end') return;
const appointment = appointments.find(apt => apt.id === resizeState.appointmentId);
if (!appointment) return;
let newStart = new Date(resizeState.originalStart);
let newDuration = resizeState.originalDuration;
if (resizeState.direction === 'end') {
newDuration = Math.max(15, resizeState.originalDuration + snappedMinutes);
} else {
if (resizeState.originalDuration - snappedMinutes >= 15) {
newStart = new Date(resizeState.originalStart.getTime() + snappedMinutes * 60000);
newDuration = resizeState.originalDuration - snappedMinutes;
}
}
setResizeState(prev => prev ? { ...prev, newStart, newDuration } : null);
};
const handleMouseUp = () => {
if (resizeState && 'newStart' in resizeState && 'newDuration' in resizeState) {
const appointment = appointments.find(a => a.id === resizeState.appointmentId);
if (appointment) {
// Add to history
addToHistory({
type: 'resize',
appointmentId: resizeState.appointmentId,
before: {
startTime: resizeState.originalStart,
resourceId: appointment.resourceId,
durationMinutes: resizeState.originalDuration
},
after: {
startTime: resizeState.newStart as Date,
resourceId: appointment.resourceId,
durationMinutes: resizeState.newDuration as number
}
});
updateMutation.mutate({
id: resizeState.appointmentId,
updates: {
startTime: resizeState.newStart as Date,
durationMinutes: resizeState.newDuration as number
}
});
}
}
setResizeState(null);
// Reset isResizing after a brief delay to prevent click handler from firing
setTimeout(() => setIsResizing(false), 100);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [resizeState, zoomLevel, appointments, updateMutation]);
const getOffset = (date: Date) => {
const { days } = getDateRange();
// Find which day this appointment belongs to
const appointmentDate = new Date(date);
appointmentDate.setHours(0, 0, 0, 0);
let dayIndex = 0;
for (let i = 0; i < days.length; i++) {
const day = new Date(days[i]);
day.setHours(0, 0, 0, 0);
if (day.getTime() === appointmentDate.getTime()) {
dayIndex = i;
break;
}
}
// Calculate offset within the day
const startOfDay = new Date(date);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60);
const offsetWithinDay = Math.max(0, diffMinutes * (PIXELS_PER_MINUTE * zoomLevel));
// Add the day offset
const dayOffset = dayIndex * dayWidth;
return dayOffset + offsetWithinDay;
};
const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel);
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
if (status === 'COMPLETED' || status === 'NO_SHOW') return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
const now = new Date();
if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
if (now >= startTime && now <= endTime) return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200';
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
};
// Filter appointments by date range (but include all pending requests regardless of date)
const { start: rangeStart, end: rangeEnd } = getDateRange();
const filteredAppointments = useMemo(() => {
return appointments.filter(apt => {
// Always include pending requests (no resourceId)
if (!apt.resourceId) return true;
// Filter scheduled appointments by date range
const aptDate = new Date(apt.startTime);
return aptDate >= rangeStart && aptDate < rangeEnd;
});
}, [appointments, rangeStart, rangeEnd]);
const resourceLayouts = useMemo(() => {
return resources.map(resource => {
const allResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id);
const layoutApps = allResourceApps.filter(a => a.id !== draggedAppointmentId);
// Add preview for dragged appointment
if (previewState && previewState.resourceId === resource.id && draggedAppointmentId) {
const original = filteredAppointments.find(a => a.id === draggedAppointmentId);
if (original) {
layoutApps.push({ ...original, startTime: previewState.startTime, id: 'PREVIEW' });
}
}
// Apply resize state to appointments for live preview
const layoutAppsWithResize = layoutApps.map(apt => {
if (resizeState && apt.id === resizeState.appointmentId && resizeState.newStart && resizeState.newDuration) {
return { ...apt, startTime: resizeState.newStart, durationMinutes: resizeState.newDuration };
}
return apt;
});
layoutAppsWithResize.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes);
const lanes: number[] = [];
const visibleAppointments = layoutAppsWithResize.map(apt => {
const start = new Date(apt.startTime).getTime();
const end = start + apt.durationMinutes * 60000;
let laneIndex = -1;
for (let i = 0; i < lanes.length; i++) {
if (lanes[i] <= start) {
laneIndex = i;
lanes[i] = end;
break;
}
}
if (laneIndex === -1) {
lanes.push(end);
laneIndex = lanes.length - 1;
}
return { ...apt, laneIndex };
});
const laneCount = Math.max(1, lanes.length);
const requiredHeight = Math.max(MIN_ROW_HEIGHT, (laneCount * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP);
const finalAppointments = [...visibleAppointments, ...allResourceApps.filter(a => a.id === draggedAppointmentId).map(a => ({ ...a, laneIndex: 0 }))];
return { resource, height: requiredHeight, appointments: finalAppointments, laneCount };
});
}, [filteredAppointments, draggedAppointmentId, previewState, resources, resizeState]);
const handleDragStart = (e: React.DragEvent, appointmentId: string) => {
if (resizeState) return e.preventDefault();
setIsDragging(true);
e.dataTransfer.setData('appointmentId', appointmentId);
e.dataTransfer.effectAllowed = 'move';
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself
const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
setDragOffsetMinutes(offsetMinutes);
setTimeout(() => setDraggedAppointmentId(appointmentId), 0);
};
const handleDragEnd = () => {
setDraggedAppointmentId(null);
setPreviewState(null);
// Reset isDragging after a short delay to allow click detection
setTimeout(() => setIsDragging(false), 100);
};
const handleAppointmentClick = (appointment: Appointment) => {
// Only open modal if we didn't actually drag or resize
if (!isDragging && !isResizing) {
setSelectedAppointment(appointment);
}
};
const handleSaveAppointment = () => {
if (!selectedAppointment) return;
// Validate duration is at least 15 minutes
const validDuration = editDuration >= 15 ? editDuration : 15;
const updates: any = {
startTime: new Date(editDateTime),
durationMinutes: validDuration,
};
if (editResource) {
updates.resourceId = editResource;
}
updateMutation.mutate({
id: selectedAppointment.id,
updates
});
setSelectedAppointment(null);
};
const handleTimelineDragOver = (e: React.DragEvent) => {
if (resizeState) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (!scrollContainerRef.current || !draggedAppointmentId) return;
const container = scrollContainerRef.current;
const rect = container.getBoundingClientRect();
const offsetX = e.clientX - rect.left + container.scrollLeft;
const offsetY = e.clientY - rect.top + container.scrollTop - HEADER_HEIGHT;
if (offsetY < 0) return;
let targetResourceId: string | null = null;
for (let i = 0, currentTop = 0; i < resourceLayouts.length; i++) {
if (offsetY >= currentTop && offsetY < currentTop + resourceLayouts[i].height) {
targetResourceId = resourceLayouts[i].resource.id; break;
}
currentTop += resourceLayouts[i].height;
}
if (!targetResourceId) return;
// Calculate new start time, accounting for where on the appointment the drag started
const mouseMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
const newStartMinutes = mouseMinutes - dragOffsetMinutes;
const newStartTime = new Date(viewDate);
newStartTime.setHours(START_HOUR, 0, 0, 0);
newStartTime.setTime(newStartTime.getTime() + newStartMinutes * 60000);
if (!previewState || previewState.resourceId !== targetResourceId || previewState.startTime.getTime() !== newStartTime.getTime()) {
setPreviewState({ resourceId: targetResourceId, startTime: newStartTime });
}
};
const handleTimelineDrop = (e: React.DragEvent) => {
e.preventDefault(); if (resizeState) return;
const appointmentId = e.dataTransfer.getData('appointmentId');
if (appointmentId && previewState) {
const appointment = appointments.find(a => a.id === appointmentId);
if (appointment) {
// Add to history
addToHistory({
type: 'move',
appointmentId,
before: {
startTime: new Date(appointment.startTime),
resourceId: appointment.resourceId,
durationMinutes: appointment.durationMinutes
},
after: {
startTime: previewState.startTime,
resourceId: previewState.resourceId,
durationMinutes: appointment.durationMinutes
}
});
updateMutation.mutate({
id: appointmentId,
updates: {
startTime: previewState.startTime,
durationMinutes: appointment.durationMinutes,
resourceId: previewState.resourceId,
status: appointment.status === 'PENDING' ? 'CONFIRMED' : appointment.status
}
});
}
}
setDraggedAppointmentId(null); setPreviewState(null);
};
const handleDropToPending = (e: React.DragEvent) => {
e.preventDefault();
const appointmentId = e.dataTransfer.getData('appointmentId');
if (appointmentId) {
updateMutation.mutate({
id: appointmentId,
updates: { resourceId: null, status: 'PENDING' }
});
}
setDraggedAppointmentId(null); setPreviewState(null);
};
const handleDropToArchive = (e: React.DragEvent) => {
e.preventDefault();
const appointmentId = e.dataTransfer.getData('appointmentId');
if (appointmentId) {
deleteMutation.mutate(appointmentId);
}
setDraggedAppointmentId(null); setPreviewState(null);
};
const handleSidebarDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (previewState) setPreviewState(null);
};
const { days } = getDateRange();
const dayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel);
const timelineWidth = dayWidth * days.length;
const timeMarkers = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const pendingAppointments = filteredAppointments.filter(a => !a.resourceId);
return (
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm shrink-0 z-10 transition-colors duration-200">
<div className="flex items-center gap-4">
{/* Date Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => navigateDate('prev')}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Previous"
>
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-md text-gray-700 dark:text-gray-200 font-medium transition-colors duration-200 w-[320px] justify-center">
<CalendarIcon size={16} />
<span className="text-center">{getDateRangeLabel()}</span>
</div>
<button
onClick={() => navigateDate('next')}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Next"
>
<ChevronRight size={20} />
</button>
</div>
{/* View Mode Switcher */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button
onClick={() => setViewMode('day')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'day'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Day
</button>
<button
onClick={() => setViewMode('week')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'week'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Week
</button>
<button
onClick={() => setViewMode('month')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'month'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Month
</button>
</div>
<div className="flex items-center gap-2">
<button className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" onClick={() => setZoomLevel(Math.max(0.5, zoomLevel - 0.25))}>-</button>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Zoom</span>
<button className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" onClick={() => setZoomLevel(Math.min(2, zoomLevel + 0.25))}>+</button>
</div>
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button
onClick={undo}
disabled={historyIndex < 0}
className={`p-2 rounded transition-colors ${
historyIndex < 0
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Undo (Ctrl+Z)"
>
<Undo size={18} />
</button>
<button
onClick={redo}
disabled={historyIndex >= history.length - 1}
className={`p-2 rounded transition-colors ${
historyIndex >= history.length - 1
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Redo (Ctrl+Y)"
>
<Redo size={18} />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
+ New Appointment
</button>
<button className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 transition-colors">
<Filter size={18} />
</button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: SIDEBAR_WIDTH }}>
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="overflow-y-auto flex-1">
{resourceLayouts.map(layout => (
<div key={layout.resource.id} className="flex items-center px-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group" style={{ height: layout.height }}>
<div className="flex items-center gap-3 w-full">
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0"><GripVertical size={16} /></div>
<div>
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resource.name}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && <span className="text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/50 px-1 rounded text-[10px]">{layout.laneCount} lanes</span>}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className={`border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToPending}>
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0"><Clock size={12} /> Pending Requests ({pendingAppointments.length})</h3>
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
{pendingAppointments.length === 0 && !draggedAppointmentId && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>)}
{draggedAppointmentId && (<div className="border-2 border-dashed border-blue-300 dark:border-blue-700 rounded-lg p-4 text-center mb-2 bg-blue-50 dark:bg-blue-900/30"><span className="text-sm text-blue-600 dark:text-blue-400 font-medium">Drop here to unassign</span></div>)}
{pendingAppointments.map(apt => {
const service = services.find(s => s.id === apt.serviceId);
return (
<div
key={apt.id}
className={`p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${draggedAppointmentId === apt.id ? 'opacity-50' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, apt.id)}
onDragEnd={handleDragEnd}
onClick={() => handleAppointmentClick(apt)}
>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{apt.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{service?.name}</p>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
</div>
</div>
)
})}
</div>
<div className={`shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 transition-all duration-200 ${draggedAppointmentId ? 'opacity-100 translate-y-0' : 'opacity-50 translate-y-0'}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToArchive}><div className={`flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed transition-colors ${draggedAppointmentId ? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400' : 'border-gray-200 dark:border-gray-700 bg-transparent text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-500'}`}><Trash2 size={16} /><span className="text-xs font-medium">Drop here to archive</span></div></div>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
<div className="flex-1 overflow-auto timeline-scroll" ref={scrollContainerRef} onDragOver={handleTimelineDragOver} onDrop={handleTimelineDrop}>
<div style={{ width: timelineWidth, minWidth: '100%' }} className="relative min-h-full">
{/* Timeline Header */}
<div className="sticky top-0 z-10 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
{viewMode !== 'day' && (
<div className="flex border-b border-gray-200 dark:border-gray-700">
{days.map((day, dayIndex) => (
<div
key={dayIndex}
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50"
style={{ width: dayWidth }}
>
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
</div>
))}
</div>
)}
<div className="flex" style={{ height: HEADER_HEIGHT }}>
{days.map((day, dayIndex) => (
<div key={dayIndex} className="flex flex-shrink-0" style={{ width: dayWidth }}>
{timeMarkers.map(hour => (
<div
key={`${dayIndex}-${hour}`}
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-xs font-medium text-gray-400 select-none"
style={{ width: 60 * (PIXELS_PER_MINUTE * zoomLevel) }}
>
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
</div>
))}
</div>
))}
</div>
</div>
{/* Current time indicator - only show if current day is in view */}
{days.some(day => {
const today = new Date();
const dayDate = new Date(day);
return today.toDateString() === dayDate.toDateString();
}) && (
<div
className="absolute top-0 bottom-0 border-l-2 border-red-500 z-30 pointer-events-none"
style={{ left: getOffset(new Date()), marginTop: viewMode === 'day' ? HEADER_HEIGHT : HEADER_HEIGHT * 2 }}
>
<div className="absolute -top-1 -left-1.5 w-3 h-3 bg-red-500 rounded-full"></div>
</div>
)}
<div className="relative">
{/* Vertical grid lines for each day */}
<div className="absolute inset-0 pointer-events-none">
{days.map((day, dayIndex) => (
<React.Fragment key={dayIndex}>
{timeMarkers.map(hour => (
<div
key={`${dayIndex}-${hour}`}
className="absolute top-0 bottom-0 border-r border-dashed border-gray-100 dark:border-gray-800"
style={{ left: (dayIndex * dayWidth) + ((hour - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel)) }}
></div>
))}
</React.Fragment>
))}
</div>
{resourceLayouts.map(layout => (<div key={layout.resource.id} className="relative border-b border-gray-100 dark:border-gray-800 transition-colors" style={{ height: layout.height }}>{layout.appointments.map(apt => {
const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
const service = services.find(s => s.id === apt.serviceId);
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}>
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0"></span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
</div>);
})}</div>))}
</div>
</div>
</div>
</div>
</div>
{/* Appointment Detail/Edit Modal */}
{selectedAppointment && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setSelectedAppointment(null)}>
<div className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Edit Appointment'}
</h3>
<button onClick={() => setSelectedAppointment(null)} className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
{/* Customer Info */}
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
<UserIcon size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Customer</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">{selectedAppointment.customerName}</p>
{selectedAppointment.customerEmail && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
<Mail size={14} />
<span>{selectedAppointment.customerEmail}</span>
</div>
)}
{selectedAppointment.customerPhone && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
<Phone size={14} />
<span>{selectedAppointment.customerPhone}</span>
</div>
)}
</div>
</div>
{/* Service & Status */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Service</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{services.find(s => s.id === selectedAppointment.serviceId)?.name}</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Status</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white capitalize">{selectedAppointment.status.toLowerCase().replace('_', ' ')}</p>
</div>
</div>
{/* Editable Fields */}
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Schedule Details</h4>
{/* Date & Time Picker */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Date & Time
</label>
<input
type="datetime-local"
value={editDateTime}
onChange={(e) => setEditDateTime(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
{/* Resource Selector */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Assign to Resource {!selectedAppointment.resourceId && <span className="text-red-500">*</span>}
</label>
<select
value={editResource}
onChange={(e) => setEditResource(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">Unassigned</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Duration Input */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Duration (minutes) {!selectedAppointment.resourceId && <span className="text-red-500">*</span>}
</label>
<input
type="number"
min="15"
step="15"
value={editDuration || 15}
onChange={(e) => {
const value = parseInt(e.target.value);
setEditDuration(value >= 15 ? value : 15);
}}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
{/* Notes */}
{selectedAppointment.notes && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Notes</p>
<p className="text-sm text-gray-700 dark:text-gray-200">{selectedAppointment.notes}</p>
</div>
)}
{/* Action Buttons */}
<div className="pt-4 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setSelectedAppointment(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveAppointment}
disabled={!selectedAppointment.resourceId && (!editResource || !editDuration || editDuration < 15)}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Save Changes'}
</button>
</div>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default OwnerScheduler;

View File

@@ -0,0 +1,870 @@
import React, { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
CreditCard,
Plus,
Trash2,
Star,
X,
TrendingUp,
DollarSign,
ArrowUpRight,
ArrowDownRight,
Download,
Filter,
Calendar,
Wallet,
BarChart3,
RefreshCcw,
FileSpreadsheet,
FileText,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
CheckCircle,
Clock,
XCircle,
ExternalLink,
Eye,
} from 'lucide-react';
import { User, Business, PaymentMethod, Customer } from '../types';
import { CUSTOMERS } from '../mockData';
import PaymentSettingsSection from '../components/PaymentSettingsSection';
import TransactionDetailModal from '../components/TransactionDetailModal';
import Portal from '../components/Portal';
import {
useTransactions,
useTransactionSummary,
useStripeBalance,
useStripePayouts,
useStripeCharges,
useExportTransactions,
} from '../hooks/useTransactionAnalytics';
import { usePaymentConfig } from '../hooks/usePayments';
import { TransactionFilters } from '../api/payments';
type TabType = 'overview' | 'transactions' | 'payouts' | 'settings';
const Payments: React.FC = () => {
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager';
const isCustomer = effectiveUser.role === 'customer';
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Filter state
const [filters, setFilters] = useState<TransactionFilters>({
status: 'all',
transaction_type: 'all',
page: 1,
page_size: 20,
});
const [showFilters, setShowFilters] = useState(false);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' });
// Export modal state
const [showExportModal, setShowExportModal] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'xlsx' | 'pdf' | 'quickbooks'>('csv');
// Transaction detail modal state
const [selectedTransactionId, setSelectedTransactionId] = useState<number | null>(null);
// Data hooks
const { data: paymentConfig } = usePaymentConfig();
const canAcceptPayments = paymentConfig?.can_accept_payments || false;
const activeFilters: TransactionFilters = {
...filters,
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
};
const { data: transactions, isLoading: transactionsLoading, refetch: refetchTransactions } = useTransactions(activeFilters);
const { data: summary, isLoading: summaryLoading } = useTransactionSummary({
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
});
const { data: balance, isLoading: balanceLoading } = useStripeBalance();
const { data: payoutsData, isLoading: payoutsLoading } = useStripePayouts(20);
const { data: chargesData } = useStripeCharges(10);
const exportMutation = useExportTransactions();
// Customer view state (for customer-facing)
const [customerProfile, setCustomerProfile] = useState<Customer | undefined>(
CUSTOMERS.find(c => c.userId === effectiveUser.id)
);
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
// Customer handlers
const handleSetDefault = (pmId: string) => {
if (!customerProfile) return;
const updatedMethods = customerProfile.paymentMethods.map(pm => ({
...pm,
isDefault: pm.id === pmId
}));
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
};
const handleDeleteMethod = (pmId: string) => {
if (!customerProfile) return;
if (window.confirm("Are you sure you want to delete this payment method?")) {
const updatedMethods = customerProfile.paymentMethods.filter(pm => pm.id !== pmId);
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
updatedMethods[0].isDefault = true;
}
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
}
};
const handleAddCard = (e: React.FormEvent) => {
e.preventDefault();
if (!customerProfile) return;
const newCard: PaymentMethod = {
id: `pm_${Date.now()}`,
brand: 'Visa',
last4: String(Math.floor(1000 + Math.random() * 9000)),
isDefault: customerProfile.paymentMethods.length === 0
};
const updatedMethods = [...customerProfile.paymentMethods, newCard];
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
setIsAddCardModalOpen(false);
};
// Export handler
const handleExport = () => {
exportMutation.mutate({
format: exportFormat,
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
});
setShowExportModal(false);
};
// Status badge helper
const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: <CheckCircle size={12} /> },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={12} /> },
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={12} /> },
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={12} /> },
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={12} /> },
};
const style = styles[status] || styles.pending;
const displayStatus = status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${style.bg} ${style.text}`}>
{style.icon}
{displayStatus}
</span>
);
};
// Format date helper
const formatDate = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
// Format time helper
const formatDateTime = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Business Owner/Manager View
if (isBusiness) {
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Payments & Analytics</h2>
<p className="text-gray-500 dark:text-gray-400">Manage payments and view transaction analytics</p>
</div>
{canAcceptPayments && (
<button
onClick={() => setShowExportModal(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Download size={16} />
Export Data
</button>
)}
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'transactions', label: 'Transactions', icon: CreditCard },
{ id: 'payouts', label: 'Payouts', icon: Wallet },
{ id: 'settings', label: 'Settings', icon: CreditCard },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-brand-500 text-brand-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<tab.icon size={18} />
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="space-y-6">
{!canAcceptPayments ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={24} />
<div>
<h3 className="font-semibold text-yellow-800">Payment Setup Required</h3>
<p className="text-yellow-700 mt-1">
Complete your payment setup in the Settings tab to start accepting payments and see analytics.
</p>
<button
onClick={() => setActiveTab('settings')}
className="mt-3 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200"
>
Go to Settings
</button>
</div>
</div>
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Revenue */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<div className="p-2 bg-green-100 rounded-lg">
<DollarSign className="text-green-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.net_revenue_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
{summary?.total_transactions || 0} transactions
</p>
</>
)}
</div>
{/* Available Balance */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Available Balance</p>
<div className="p-2 bg-blue-100 rounded-lg">
<Wallet className="text-blue-600" size={20} />
</div>
</div>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
<p className="text-sm text-gray-500 mt-1">
${((balance?.pending_total || 0) / 100).toFixed(2)} pending
</p>
</>
)}
</div>
{/* Success Rate */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Success Rate</p>
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="text-purple-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.total_transactions
? ((summary.successful_transactions / summary.total_transactions) * 100).toFixed(1)
: '0'}%
</p>
<p className="text-sm text-green-600 mt-1 flex items-center gap-1">
<ArrowUpRight size={14} />
{summary?.successful_transactions || 0} successful
</p>
</>
)}
</div>
{/* Average Transaction */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Transaction</p>
<div className="p-2 bg-orange-100 rounded-lg">
<BarChart3 className="text-orange-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.average_transaction_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
Platform fees: {summary?.total_fees_display || '$0.00'}
</p>
</>
)}
</div>
</div>
{/* Recent Transactions */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Recent Transactions</h3>
<button
onClick={() => setActiveTab('transactions')}
className="text-sm text-brand-600 hover:text-brand-700 font-medium"
>
View All
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactionsLoading ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : transactions?.results?.length ? (
transactions.results.slice(0, 5).map((txn) => (
<tr
key={txn.id}
onClick={() => setSelectedTransactionId(txn.id)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateTime(txn.created_at)}
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
<p className="text-xs text-gray-500">Fee: {txn.fee_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No transactions yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)}
{activeTab === 'transactions' && (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Calendar size={16} className="text-gray-400" />
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Start date"
/>
<span className="text-gray-400">to</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="End date"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Statuses</option>
<option value="succeeded">Succeeded</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
<select
value={filters.transaction_type}
onChange={(e) => setFilters({ ...filters, transaction_type: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Types</option>
<option value="payment">Payment</option>
<option value="refund">Refund</option>
</select>
<button
onClick={() => refetchTransactions()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
<RefreshCcw size={14} />
Refresh
</button>
</div>
</div>
{/* Transactions Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transaction</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactionsLoading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : transactions?.results?.length ? (
transactions.results.map((txn) => (
<tr
key={txn.id}
onClick={() => setSelectedTransactionId(txn.id)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
>
<td className="px-6 py-4">
<p className="text-sm font-mono text-gray-600">{txn.stripe_payment_intent_id.slice(0, 18)}...</p>
<p className="text-xs text-gray-400 capitalize">{txn.transaction_type}</p>
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateTime(txn.created_at)}
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
</td>
<td className="px-6 py-4">
<p className={`font-medium ${txn.transaction_type === 'refund' ? 'text-red-600' : 'text-green-600'}`}>
{txn.transaction_type === 'refund' ? '-' : ''}${(txn.net_amount / 100).toFixed(2)}
</p>
{txn.application_fee_amount > 0 && (
<p className="text-xs text-gray-400">-{txn.fee_display} fee</p>
)}
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedTransactionId(txn.id);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-brand-600 hover:text-brand-700 hover:bg-brand-50 rounded-lg transition-colors"
>
<Eye size={14} />
View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
No transactions found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{transactions && transactions.total_pages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {(filters.page! - 1) * filters.page_size! + 1} to{' '}
{Math.min(filters.page! * filters.page_size!, transactions.count)} of {transactions.count}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setFilters({ ...filters, page: filters.page! - 1 })}
disabled={filters.page === 1}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={20} />
</button>
<span className="text-sm text-gray-600">
Page {filters.page} of {transactions.total_pages}
</span>
<button
onClick={() => setFilters({ ...filters, page: filters.page! + 1 })}
disabled={filters.page === transactions.total_pages}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={20} />
</button>
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'payouts' && (
<div className="space-y-6">
{/* Balance Summary */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-green-100 rounded-lg">
<Wallet className="text-green-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Available for Payout</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
<p className="text-2xl font-bold text-gray-900">
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
)}
</div>
</div>
{balance?.available?.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-t border-gray-100">
<span className="text-sm text-gray-500">{item.currency.toUpperCase()}</span>
<span className="font-medium">{item.amount_display}</span>
</div>
))}
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-yellow-100 rounded-lg">
<Clock className="text-yellow-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Pending</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
<p className="text-2xl font-bold text-gray-900">
${((balance?.pending_total || 0) / 100).toFixed(2)}
</p>
)}
</div>
</div>
{balance?.pending?.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-t border-gray-100">
<span className="text-sm text-gray-500">{item.currency.toUpperCase()}</span>
<span className="font-medium">{item.amount_display}</span>
</div>
))}
</div>
</div>
{/* Payouts List */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payout History</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Payout ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Arrival Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{payoutsLoading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : payoutsData?.payouts?.length ? (
payoutsData.payouts.map((payout) => (
<tr key={payout.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4">
<p className="text-sm font-mono text-gray-600">{payout.id}</p>
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{payout.amount_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(payout.status)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{payout.arrival_date ? formatDate(payout.arrival_date) : '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 capitalize">
{payout.method}
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
No payouts yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab === 'settings' && (
<PaymentSettingsSection business={business} />
)}
{/* Transaction Detail Modal */}
{selectedTransactionId && (
<TransactionDetailModal
transactionId={selectedTransactionId}
onClose={() => setSelectedTransactionId(null)}
/>
)}
{/* Export Modal */}
{showExportModal && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Export Transactions</h3>
<button onClick={() => setShowExportModal(false)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Export Format</label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'csv', label: 'CSV', icon: FileText },
{ id: 'xlsx', label: 'Excel', icon: FileSpreadsheet },
{ id: 'pdf', label: 'PDF', icon: FileText },
{ id: 'quickbooks', label: 'QuickBooks', icon: FileSpreadsheet },
].map((format) => (
<button
key={format.id}
onClick={() => setExportFormat(format.id as any)}
className={`flex items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
exportFormat === format.id
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<format.icon size={18} />
{format.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date Range (Optional)</label>
<div className="flex items-center gap-2">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
<span className="text-gray-400">to</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
</div>
<button
onClick={handleExport}
disabled={exportMutation.isPending}
className="w-full flex items-center justify-center gap-2 py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{exportMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={18} />
Exporting...
</>
) : (
<>
<Download size={18} />
Export
</>
)}
</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
}
// Customer View
if (isCustomer && customerProfile) {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Billing</h2>
<p className="text-gray-500 dark:text-gray-400">Manage your payment methods and view invoice history.</p>
</div>
{/* Payment Methods */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payment Methods</h3>
<button onClick={() => setIsAddCardModalOpen(true)} className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
<Plus size={16} /> Add Card
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{customerProfile.paymentMethods.length > 0 ? customerProfile.paymentMethods.map((pm) => (
<div key={pm.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div className="flex items-center gap-4">
<CreditCard className="text-gray-400" size={24} />
<div>
<p className="font-medium text-gray-900 dark:text-white">{pm.brand} ending in {pm.last4}</p>
{pm.isDefault && <span className="text-xs font-medium text-green-600 dark:text-green-400">Default</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!pm.isDefault && (
<button onClick={() => handleSetDefault(pm.id)} className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 font-medium">
<Star size={14} /> Set as Default
</button>
)}
<button onClick={() => handleDeleteMethod(pm.id)} className="p-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400">
<Trash2 size={16} />
</button>
</div>
</div>
)) : <div className="p-8 text-center text-gray-500 dark:text-gray-400">No payment methods on file.</div>}
</div>
</div>
{/* Invoice History */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Invoice History</h3>
</div>
<div className="p-8 text-center text-gray-500">
No invoices yet.
</div>
</div>
{/* Add Card Modal */}
{isAddCardModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsAddCardModalOpen(false)}>
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Add New Card</h3>
<button onClick={() => setIsAddCardModalOpen(false)}><X size={20} /></button>
</div>
<form onSubmit={handleAddCard} className="p-6 space-y-4">
<div><label className="text-sm font-medium">Card Number</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"> 4242</div></div>
<div><label className="text-sm font-medium">Cardholder Name</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">{effectiveUser.name}</div></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="text-sm font-medium">Expiry</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">12 / 2028</div></div>
<div><label className="text-sm font-medium">CVV</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"></div></div>
</div>
<p className="text-xs text-gray-400 text-center">This is a simulated form. No real card data is required.</p>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">Add Card</button>
</form>
</div>
</div>
</Portal>
)}
</div>
);
}
return <div>Access Denied or User not found.</div>;
};
export default Payments;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Link } from 'react-router-dom';
// FIX: PageComponent will be imported from types after the type definition is added.
import { Business, PageComponent } from '../types';
import { SERVICES } from '../mockData';
const RenderComponent: React.FC<{ component: PageComponent }> = ({ component }) => {
switch (component.type) {
case 'HEADING': {
// FIX: Replaced dynamic JSX tag with React.createElement to fix parsing errors.
const tag = `h${component.content?.level || 1}`;
const className = `font-bold text-gray-900 dark:text-white my-4 ${
component.content?.level === 1 ? 'text-4xl' :
component.content?.level === 2 ? 'text-2xl' : 'text-xl'
}`;
return React.createElement(tag, { className }, component.content?.text);
}
case 'TEXT':
return <p className="text-gray-600 dark:text-gray-300 my-4 leading-relaxed">{component.content?.text}</p>;
case 'IMAGE':
return <img src={component.content?.src} alt={component.content?.alt} className="rounded-lg my-4 max-w-full h-auto shadow-md" />;
case 'BUTTON':
return <a href={component.content?.href} className="inline-block px-6 py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 my-4 shadow-sm transition-colors">{component.content?.buttonText}</a>;
case 'SERVICE':
const service = SERVICES.find(s => s.id === component.content?.serviceId);
if (!service) return <div className="text-red-500">Service not found</div>;
return (
<div className="p-6 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 my-4 shadow-sm">
<h4 className="text-xl font-bold">{service.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{service.description}</p>
<div className="flex items-center justify-between mt-4">
<span className="text-lg font-bold text-gray-900 dark:text-white">${service.price.toFixed(2)}</span>
<Link to="/portal/book" className="text-sm font-medium text-brand-600 hover:underline">Book Now &rarr;</Link>
</div>
</div>
);
case 'COLUMNS':
return (
<div className="flex flex-col md:flex-row gap-8 my-4">
{component.children?.map((col, colIndex) => (
<div key={colIndex} className="flex-1 space-y-4">
{col.map(child => <RenderComponent key={child.id} component={child} />)}
</div>
))}
</div>
);
default:
return null;
}
};
interface PublicSitePageProps {
business: Business;
path: string;
}
const PublicSitePage: React.FC<PublicSitePageProps> = ({ business, path }) => {
// FIX: Property 'websitePages' is optional. Added optional chaining.
const page = business.websitePages?.[path] || business.websitePages?.['/'];
if (!page) {
return <div>Page not found</div>;
}
return (
<div>
{page.content.map(component => (
<RenderComponent key={component.id} component={component} />
))}
</div>
);
};
export default PublicSitePage;

View File

@@ -0,0 +1,336 @@
/**
* Resource Scheduler - Vertical agenda view for resource users
*/
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Appointment, User, Business, Blocker } from '../types';
import { Clock, CheckCircle2, Lock, Plus, X, ChevronLeft, ChevronRight, Ban } from 'lucide-react';
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import Portal from '../components/Portal';
// Time settings
const START_HOUR = 8;
const END_HOUR = 18;
const PIXELS_PER_MINUTE_VERTICAL = 2;
interface ResourceSchedulerProps {
user: User;
business: Business;
}
const ResourceScheduler: React.FC<ResourceSchedulerProps> = ({ user, business }) => {
const { data: appointments = [] } = useAppointments();
const { data: resources = [] } = useResources();
const { data: services = [] } = useServices();
const updateMutation = useUpdateAppointment();
const [blockers, setBlockers] = useState<Blocker[]>([]);
const [viewDate, setViewDate] = useState(new Date());
const [isBlockTimeModalOpen, setIsBlockTimeModalOpen] = useState(false);
const [newBlocker, setNewBlocker] = useState({ title: 'Break', startTime: '12:00', durationMinutes: 60 });
const agendaContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Scroll to current time on mount (centered in view)
useEffect(() => {
if (!scrollContainerRef.current) return;
const now = new Date();
const today = new Date();
today.setHours(0, 0, 0, 0);
const viewDay = new Date(viewDate);
viewDay.setHours(0, 0, 0, 0);
// Only scroll if viewing today
if (viewDay.getTime() !== today.getTime()) return;
const container = scrollContainerRef.current;
const containerHeight = container.clientHeight;
// Calculate current time offset in pixels (vertical)
const startOfDay = new Date(now);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE_VERTICAL;
// Scroll so current time is centered
const scrollPosition = currentTimeOffset - (containerHeight / 2);
container.scrollTop = Math.max(0, scrollPosition);
}, []);
const isSameDay = (d1: Date, d2: Date) =>
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
const myResource = useMemo(() => resources.find(r => r.userId === user.id), [user.id, resources]);
const myAppointments = useMemo(
() => appointments
.filter(a => a.resourceId === myResource?.id && isSameDay(new Date(a.startTime), viewDate))
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()),
[appointments, myResource, viewDate]
);
const myBlockers = useMemo(
() => blockers
.filter(b => b.resourceId === myResource?.id && isSameDay(new Date(b.startTime), viewDate))
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()),
[blockers, myResource, viewDate]
);
const timeMarkersVertical = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i)
.flatMap(h => [`${h}:00`, `${h}:30`]);
const handleVerticalDragStart = (e: React.DragEvent, appointment: Appointment) => {
if (!business.resourcesCanReschedule || appointment.status === 'COMPLETED') {
return e.preventDefault();
}
e.dataTransfer.setData('appointmentId', appointment.id);
};
const handleVerticalDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!business.resourcesCanReschedule || !agendaContainerRef.current) return;
const appointmentId = e.dataTransfer.getData('appointmentId');
const appointment = myAppointments.find(a => a.id === appointmentId);
if (!appointment || appointment.status === 'COMPLETED') return;
const rect = agendaContainerRef.current.getBoundingClientRect();
const dropY = e.clientY - rect.top;
const minutesFromStart = dropY / PIXELS_PER_MINUTE_VERTICAL;
const snappedMinutes = Math.round(minutesFromStart / 15) * 15;
const newStartTime = new Date(viewDate);
newStartTime.setHours(START_HOUR, snappedMinutes, 0, 0);
updateMutation.mutate({
id: appointmentId,
updates: { startTime: newStartTime }
});
};
const handleAddBlocker = () => {
const [hours, minutes] = newBlocker.startTime.split(':').map(Number);
const startTime = new Date(viewDate);
startTime.setHours(hours, minutes, 0, 0);
const newBlock: Blocker = {
id: `block_${Date.now()}`,
resourceId: myResource!.id,
title: newBlocker.title,
startTime,
durationMinutes: newBlocker.durationMinutes
};
setBlockers(prev => [...prev, newBlock]);
setIsBlockTimeModalOpen(false);
};
const getVerticalOffset = (date: Date) => {
const startOfDay = new Date(date);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60);
return diffMinutes * PIXELS_PER_MINUTE_VERTICAL;
};
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
if (status === 'COMPLETED' || status === 'NO_SHOW')
return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
if (status === 'CANCELLED')
return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
const now = new Date();
if (now > endTime)
return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
if (now >= startTime && now <= endTime)
return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200';
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
};
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Schedule: {myResource?.name}</h2>
<p className="text-gray-500 dark:text-gray-400">
Viewing appointments for {viewDate.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
})}.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsBlockTimeModalOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm"
>
<Plus size={16} /> Block Time
</button>
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<button
onClick={() => setViewDate(d => new Date(d.setDate(d.getDate() - 1)))}
className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-l-md"
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => setViewDate(new Date())}
className="px-3 py-1.5 text-sm font-semibold text-gray-700 dark:text-gray-200 border-x border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Today
</button>
<button
onClick={() => setViewDate(d => new Date(d.setDate(d.getDate() + 1)))}
className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-r-md"
>
<ChevronRight size={18} />
</button>
</div>
</div>
</div>
<div ref={scrollContainerRef} className="h-[70vh] overflow-y-auto timeline-scroll bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm flex">
{/* Time Gutter */}
<div className="w-20 text-right pr-4 border-r border-gray-100 dark:border-gray-700 shrink-0">
{timeMarkersVertical.map((time, i) => (
<div key={i} className="text-xs text-gray-400 relative" style={{ height: 30 * PIXELS_PER_MINUTE_VERTICAL }}>
{time.endsWith(':00') && <span className="absolute -top-1.5">{time}</span>}
</div>
))}
</div>
{/* Agenda */}
<div
ref={agendaContainerRef}
className="flex-1 relative"
onDragOver={(e) => { if (business.resourcesCanReschedule) e.preventDefault(); }}
onDrop={handleVerticalDrop}
>
<div style={{ height: (END_HOUR - START_HOUR) * 60 * PIXELS_PER_MINUTE_VERTICAL }} className="relative">
{timeMarkersVertical.map((_, i) => (
<div
key={i}
className={`absolute w-full ${i % 2 === 0 ? 'border-t border-gray-100 dark:border-gray-700' : 'border-t border-dashed border-gray-100 dark:border-gray-800'}`}
style={{ top: i * 30 * PIXELS_PER_MINUTE_VERTICAL }}
/>
))}
{[...myAppointments, ...myBlockers].map(item => {
const isAppointment = 'customerName' in item;
const startTime = new Date(item.startTime);
const endTime = new Date(startTime.getTime() + item.durationMinutes * 60000);
const isCompleted = isAppointment && item.status === 'COMPLETED';
const canDrag = business.resourcesCanReschedule && !isCompleted && isAppointment;
const colorClass = isAppointment
? getStatusColor(item.status, startTime, endTime)
: 'bg-gray-100 border-gray-300 text-gray-500 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-400';
const cursorClass = canDrag ? 'cursor-grab active:cursor-grabbing' : 'cursor-default';
const service = isAppointment ? services.find(s => s.id === (item as Appointment).serviceId) : null;
return (
<div
key={item.id}
draggable={canDrag}
onDragStart={(e) => isAppointment && handleVerticalDragStart(e, item as Appointment)}
className={`absolute left-2 right-2 rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${cursorClass}`}
style={{
top: getVerticalOffset(startTime),
height: item.durationMinutes * PIXELS_PER_MINUTE_VERTICAL,
zIndex: 10,
backgroundImage: !isAppointment ? `linear-gradient(45deg, rgba(0,0,0,0.05) 25%, transparent 25%, transparent 50%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.05) 75%, transparent 75%, transparent)` : undefined,
backgroundSize: !isAppointment ? '20px 20px' : undefined
}}
>
<div className="font-semibold text-sm truncate flex items-center justify-between">
<span>{isAppointment ? (item as Appointment).customerName : item.title}</span>
{isCompleted && <Lock size={12} className="text-gray-400 shrink-0" />}
</div>
{isAppointment && <div className="text-xs truncate opacity-80">{service?.name}</div>}
<div className="mt-2 flex items-center gap-1 text-xs opacity-75">
{isAppointment && (item as Appointment).status === 'COMPLETED' ? (
<CheckCircle2 size={12} />
) : isAppointment ? (
<Clock size={12} />
) : (
<Ban size={12} />
)}
<span>
{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
{endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{isBlockTimeModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsBlockTimeModalOpen(false)}>
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Add Time Off</h3>
<button onClick={() => setIsBlockTimeModalOpen(false)} className="p-1 text-gray-400 hover:bg-gray-100 rounded-full">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input
type="text"
value={newBlocker.title}
onChange={e => setNewBlocker(s => ({ ...s, title: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
<input
type="time"
value={newBlocker.startTime}
onChange={e => setNewBlocker(s => ({ ...s, startTime: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duration (min)</label>
<input
type="number"
step="15"
min="15"
value={newBlocker.durationMinutes}
onChange={e => setNewBlocker(s => ({ ...s, durationMinutes: parseInt(e.target.value, 10) }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
</div>
<div className="pt-2 flex justify-end gap-3">
<button
onClick={() => setIsBlockTimeModalOpen(false)}
className="px-4 py-2 text-sm font-medium rounded-lg"
>
Cancel
</button>
<button
onClick={handleAddBlocker}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
>
Add Block
</button>
</div>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default ResourceScheduler;

View File

@@ -0,0 +1,266 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ResourceType, User } from '../types';
import { useResources, useCreateResource } from '../hooks/useBusiness';
import { useAppointments } from '../hooks/useAppointments';
import ResourceCalendar from '../components/ResourceCalendar';
import Portal from '../components/Portal';
import {
Plus,
MoreHorizontal,
User as UserIcon,
Home,
Wrench,
Eye,
Calendar
} from 'lucide-react';
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
switch (type) {
case 'STAFF':
return <UserIcon size={16} className="text-blue-500" />;
case 'ROOM':
return <Home size={16} className="text-green-500" />;
case 'EQUIPMENT':
return <Wrench size={16} className="text-purple-500" />;
default:
return null;
}
};
interface ResourcesProps {
onMasquerade: (user: User) => void;
effectiveUser: User;
}
const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) => {
const { t } = useTranslation();
// All hooks must be called at the top, before any conditional returns
const { data: resources = [], isLoading, error } = useResources();
const [isAddModalOpen, setIsAddModalOpen] = React.useState(false);
const [newResourceType, setNewResourceType] = React.useState<ResourceType>('STAFF');
const [newResourceName, setNewResourceName] = React.useState('');
const [selectedResource, setSelectedResource] = React.useState<{ id: string; name: string } | null>(null);
const createResourceMutation = useCreateResource();
// Fetch ALL appointments (not filtered by date) to count per resource
// We filter client-side for future appointments
const { data: allAppointments = [] } = useAppointments();
// Count future appointments per resource
const appointmentCountByResource = useMemo(() => {
const counts: Record<string, number> = {};
const now = new Date();
allAppointments.forEach(apt => {
// Only count future appointments that have a resource assigned
if (apt.resourceId && new Date(apt.startTime) >= now) {
counts[apt.resourceId] = (counts[apt.resourceId] || 0) + 1;
}
});
return counts;
}, [allAppointments]);
const handleAddResource = (e: React.FormEvent) => {
e.preventDefault();
createResourceMutation.mutate({
name: newResourceName,
type: newResourceType
}, {
onSuccess: () => {
setIsAddModalOpen(false);
setNewResourceName('');
setNewResourceType('STAFF');
}
});
};
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">{t('resources.errorLoading')}: {(error as Error).message}</p>
</div>
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('resources.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('resources.description')}</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('resources.addResource')}
</button>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium">{t('resources.resourceName')}</th>
<th className="px-6 py-4 font-medium">{t('resources.type')}</th>
<th className="px-6 py-4 font-medium">{t('resources.upcoming')}</th>
<th className="px-6 py-4 font-medium">{t('scheduler.status')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{resources.map((resource: any) => {
return (
<tr key={resource.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
<ResourceIcon type={resource.type} />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{resource.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${resource.type === 'STAFF' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
resource.type === 'ROOM' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
}`}>
{resource.type.toLowerCase()}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<Calendar size={14} className="text-brand-500" />
<span className="font-medium">{appointmentCountByResource[String(resource.id)] || 0}</span>
<span className="text-xs text-gray-400">{t('resources.appointments')}</span>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{t('resources.active')}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setSelectedResource({ id: resource.id, name: resource.name })}
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
title={`${t('resources.viewCalendar')} - ${resource.name}`}
>
<Eye size={14} /> {t('resources.viewCalendar')}
</button>
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{resources.length === 0 && (
<div className="p-12 text-center">
<p className="text-gray-500 dark:text-gray-400">{t('resources.noResourcesFound')}</p>
</div>
)}
</div>
</div>
{/* Add Resource Modal */}
{isAddModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('resources.addNewResource')}</h3>
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<span className="sr-only">{t('common.close')}</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleAddResource} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceType')}</label>
<select
value={newResourceType}
onChange={(e) => setNewResourceType(e.target.value as ResourceType)}
className="w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-brand-500 focus:border-brand-500"
>
<option value="STAFF">{t('resources.staffMember')}</option>
<option value="ROOM">{t('resources.room')}</option>
<option value="EQUIPMENT">{t('resources.equipment')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceName')}</label>
<input
type="text"
value={newResourceName}
onChange={(e) => setNewResourceName(e.target.value)}
className="w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-brand-500 focus:border-brand-500"
placeholder={newResourceType === 'STAFF' ? 'e.g. Sarah (Stylist)' : newResourceType === 'ROOM' ? 'e.g. Massage Room 1' : 'e.g. Laser Machine'}
required
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('resources.resourceNote')}
</p>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
>
{t('resources.createResource')}
</button>
</div>
</form>
</div>
</div>
</Portal>
)}
{/* Resource Calendar Modal */}
{selectedResource && (
<ResourceCalendar
resourceId={selectedResource.id}
resourceName={selectedResource.name}
onClose={() => setSelectedResource(null)}
/>
)}
</div>
);
};
export default Resources;

View File

@@ -0,0 +1,41 @@
/**
* Scheduler - Main wrapper component that renders appropriate scheduler view
* Refactored to avoid conditional hooks issue
*/
import React from 'react';
import { useOutletContext } from 'react-router-dom';
import { User, Business } from '../types';
import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import ResourceScheduler from './ResourceScheduler';
import OwnerScheduler from './OwnerScheduler';
const Scheduler: React.FC = () => {
const { user, business } = useOutletContext<{ user: User, business: Business }>();
// Fetch data from API (shared across both views)
const { data: appointments = [], isLoading: appointmentsLoading } = useAppointments();
const { data: resources = [], isLoading: resourcesLoading } = useResources();
const { data: services = [], isLoading: servicesLoading } = useServices();
// Show loading state
if (appointmentsLoading || resourcesLoading || servicesLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-gray-500 dark:text-gray-400">Loading scheduler...</div>
</div>
);
}
// Render appropriate scheduler based on user role
if (user.role === 'resource') {
return <ResourceScheduler user={user} business={business} />;
}
// Owner, manager, staff get horizontal timeline view
return <OwnerScheduler user={user} business={business} />;
};
export default Scheduler;

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2 } from 'lucide-react';
import { useServices, useCreateService, useUpdateService, useDeleteService } from '../hooks/useServices';
import { Service } from '../types';
interface ServiceFormData {
name: string;
durationMinutes: number;
price: number;
description: string;
}
const Services: React.FC = () => {
const { t } = useTranslation();
const { data: services, isLoading, error } = useServices();
const createService = useCreateService();
const updateService = useUpdateService();
const deleteService = useDeleteService();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
const [formData, setFormData] = useState<ServiceFormData>({
name: '',
durationMinutes: 60,
price: 0,
description: '',
});
const openCreateModal = () => {
setEditingService(null);
setFormData({
name: '',
durationMinutes: 60,
price: 0,
description: '',
});
setIsModalOpen(true);
};
const openEditModal = (service: Service) => {
setEditingService(service);
setFormData({
name: service.name,
durationMinutes: service.durationMinutes,
price: service.price,
description: service.description || '',
});
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingService(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingService) {
await updateService.mutateAsync({
id: editingService.id,
updates: formData,
});
} else {
await createService.mutateAsync(formData);
}
closeModal();
} catch (error) {
console.error('Failed to save service:', error);
}
};
const handleDelete = async (id: string) => {
if (window.confirm(t('services.confirmDelete', 'Are you sure you want to delete this service?'))) {
try {
await deleteService.mutateAsync(id);
} catch (error) {
console.error('Failed to delete service:', error);
}
}
};
if (isLoading) {
return (
<div className="p-8">
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
</div>
);
}
if (error) {
return (
<div className="p-8">
<div className="text-center text-red-600 dark:text-red-400">
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
</div>
</div>
);
}
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('services.title', 'Services')}
</h2>
<p className="text-gray-500 dark:text-gray-400">
{t('services.description', 'Manage the services your business offers')}
</p>
</div>
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
<Plus className="h-4 w-4" />
{t('services.addService', 'Add Service')}
</button>
</div>
{services && services.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700">
<div className="text-gray-500 dark:text-gray-400 mb-4">
{t('services.noServices', 'No services yet. Add your first service to get started.')}
</div>
<button
onClick={openCreateModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
<Plus className="h-4 w-4" />
{t('services.addService', 'Add Service')}
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{services?.map((service) => (
<div
key={service.id}
className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm"
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
<div className="flex items-center gap-2">
<button
onClick={() => openEditModal(service)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{service.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{service.description}
</p>
)}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<Clock className="h-4 w-4" />
<span>{service.durationMinutes} {t('common.minutes', 'min')}</span>
</div>
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<DollarSign className="h-4 w-4" />
<span>${service.price.toFixed(2)}</span>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingService
? t('services.editService', 'Edit Service')
: t('services.addService', 'Add Service')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.name', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.duration', 'Duration (min)')} *
</label>
<input
type="number"
value={formData.durationMinutes}
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })}
required
min={5}
step={5}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} *
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
required
min={0}
step={0.01}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.descriptionLabel', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createService.isPending || updateService.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{(createService.isPending || updateService.isPending) && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{editingService ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Services;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '../types';
import { useBusinessUsers, useCreateResource, useResources } from '../hooks/useBusiness';
import {
Plus,
MoreHorizontal,
User as UserIcon,
Shield,
Briefcase,
Calendar
} from 'lucide-react';
import Portal from '../components/Portal';
interface StaffProps {
onMasquerade: (user: User) => void;
effectiveUser: User;
}
const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const { t } = useTranslation();
const { data: users = [], isLoading, error } = useBusinessUsers();
const { data: resources = [] } = useResources();
const createResourceMutation = useCreateResource();
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
// Helper to check if a user is already linked to a resource
const getLinkedResource = (userId: string) => {
return resources.find((r: any) => r.user_id === parseInt(userId));
};
const handleMakeBookable = (user: any) => {
if (confirm(`Create a bookable resource for ${user.name || user.username}?`)) {
createResourceMutation.mutate({
name: user.name || user.username,
type: 'STAFF',
user_id: user.id
});
}
};
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">{t('staff.errorLoading')}: {(error as Error).message}</p>
</div>
</div>
);
}
// Filter for staff/management roles
const staffUsers = users.filter((u: any) => ['owner', 'manager', 'staff'].includes(u.role));
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('staff.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('staff.description')}</p>
</div>
<button
onClick={() => setIsInviteModalOpen(true)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('staff.inviteStaff')}
</button>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{staffUsers.map((user: any) => {
const linkedResource = getLinkedResource(user.id);
// Owners/Managers can log in as anyone.
const canMasquerade = ['owner', 'manager'].includes(effectiveUser.role) && user.id !== effectiveUser.id;
return (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400 font-medium">
{user.name ? user.name.charAt(0).toUpperCase() : user.username.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{user.name || user.username}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${user.role === 'owner' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
user.role === 'manager' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}>
{user.role === 'owner' && <Shield size={12} />}
{user.role === 'manager' && <Briefcase size={12} />}
{user.role}
</span>
</td>
<td className="px-6 py-4">
{linkedResource ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
<Calendar size={12} />
{t('staff.yes')} ({linkedResource.name})
</span>
) : (
<button
onClick={() => handleMakeBookable(user)}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 hover:underline"
>
{t('staff.makeBookable')}
</button>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{canMasquerade && (
<button
onClick={() => onMasquerade(user)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
>
{t('common.masquerade')}
</button>
)}
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Invite Modal Placeholder */}
{isInviteModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold mb-4 dark:text-white">{t('staff.inviteModalTitle')}</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">{t('staff.inviteModalDescription')}</p>
<div className="flex justify-end">
<button onClick={() => setIsInviteModalOpen(false)} className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-700 dark:text-gray-300">{t('common.close')}</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default Staff;

View File

@@ -0,0 +1,222 @@
import React from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { Clock, ArrowRight, Check, X, CreditCard, TrendingDown, AlertTriangle } from 'lucide-react';
import { User, Business } from '../types';
/**
* TrialExpired Page
* Shown when a business trial has expired and they need to either upgrade or downgrade to free tier
*/
const TrialExpired: React.FC = () => {
const navigate = useNavigate();
const { user, business } = useOutletContext<{ user: User; business: Business }>();
const isOwner = user.role === 'owner';
// Feature comparison based on tier
const getTierFeatures = (tier: string | undefined) => {
switch (tier) {
case 'Professional':
return [
{ name: 'Unlimited appointments', included: true },
{ name: 'Online booking portal', included: true },
{ name: 'Email notifications', included: true },
{ name: 'SMS reminders', included: true },
{ name: 'Custom branding', included: true },
{ name: 'Advanced analytics', included: true },
{ name: 'Payment processing', included: true },
{ name: 'Priority support', included: true },
];
case 'Business':
return [
{ name: 'Everything in Professional', included: true },
{ name: 'Multiple locations', included: true },
{ name: 'Team management', included: true },
{ name: 'API access', included: true },
{ name: 'Custom domain', included: true },
{ name: 'White-label options', included: true },
{ name: 'Dedicated account manager', included: true },
];
case 'Enterprise':
return [
{ name: 'Everything in Business', included: true },
{ name: 'Unlimited users', included: true },
{ name: 'Custom integrations', included: true },
{ name: 'SLA guarantee', included: true },
{ name: 'Custom contract terms', included: true },
{ name: '24/7 phone support', included: true },
{ name: 'On-premise deployment option', included: true },
];
default:
return [];
}
};
const freeTierFeatures = [
{ name: 'Up to 50 appointments/month', included: true },
{ name: 'Basic online booking', included: true },
{ name: 'Email notifications', included: true },
{ name: 'SMS reminders', included: false },
{ name: 'Custom branding', included: false },
{ name: 'Advanced analytics', included: false },
{ name: 'Payment processing', included: false },
{ name: 'Priority support', included: false },
];
const paidTierFeatures = getTierFeatures(business.plan);
const handleUpgrade = () => {
navigate('/payments');
};
const handleDowngrade = () => {
if (window.confirm('Are you sure you want to downgrade to the Free plan? You will lose access to premium features immediately.')) {
// TODO: Implement downgrade to free tier API call
console.log('Downgrading to free tier...');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<div className="max-w-4xl w-full">
{/* Main Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-red-500 to-orange-500 text-white p-8 text-center">
<div className="flex justify-center mb-4">
<div className="bg-white/20 backdrop-blur-sm rounded-full p-4">
<Clock size={48} />
</div>
</div>
<h1 className="text-3xl font-bold mb-2">Your 14-Day Trial Has Expired</h1>
<p className="text-white/90 text-lg">
Your trial of the {business.plan} plan ended on{' '}
{business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : 'N/A'}
</p>
</div>
{/* Content */}
<div className="p-8">
<div className="mb-8">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
What happens now?
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4">
You have two options to continue using SmoothSchedule:
</p>
</div>
{/* Feature Comparison */}
<div className="grid md:grid-cols-2 gap-6 mb-8">
{/* Free Tier Card */}
<div className="border-2 border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Free Plan</h3>
<span className="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full text-sm font-medium">
$0/month
</span>
</div>
<ul className="space-y-3 mb-6">
{freeTierFeatures.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
{feature.included ? (
<Check size={20} className="text-green-500 flex-shrink-0 mt-0.5" />
) : (
<X size={20} className="text-gray-400 flex-shrink-0 mt-0.5" />
)}
<span className={feature.included ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500 line-through'}>
{feature.name}
</span>
</li>
))}
</ul>
{isOwner && (
<button
onClick={handleDowngrade}
className="w-full px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium flex items-center justify-center gap-2"
>
<TrendingDown size={20} />
Downgrade to Free
</button>
)}
</div>
{/* Paid Tier Card */}
<div className="border-2 border-blue-500 dark:border-blue-400 rounded-xl p-6 bg-blue-50/50 dark:bg-blue-900/20 relative">
<div className="absolute top-4 right-4">
<span className="px-3 py-1 bg-blue-500 text-white rounded-full text-xs font-bold uppercase tracking-wide">
Recommended
</span>
</div>
<div className="mb-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-1">
{business.plan} Plan
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Continue where you left off</p>
</div>
<ul className="space-y-3 mb-6">
{paidTierFeatures.slice(0, 8).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check size={20} className="text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-gray-700 dark:text-gray-300">{feature.name}</span>
</li>
))}
{paidTierFeatures.length > 8 && (
<li className="text-sm text-gray-500 dark:text-gray-400 italic">
+ {paidTierFeatures.length - 8} more features
</li>
)}
</ul>
{isOwner && (
<button
onClick={handleUpgrade}
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg hover:from-blue-700 hover:to-blue-600 transition-all font-medium flex items-center justify-center gap-2 shadow-lg shadow-blue-500/30"
>
<CreditCard size={20} />
Upgrade Now
<ArrowRight size={20} />
</button>
)}
</div>
</div>
{/* Important Notice */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500" />
</div>
<div className="flex-1">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium">
{isOwner
? 'Your account has limited functionality until you choose an option.'
: 'Please contact your business owner to upgrade or downgrade the account.'}
</p>
</div>
</div>
</div>
{!isOwner && (
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400">
Business Owner: <span className="font-semibold text-gray-900 dark:text-white">{business.name}</span>
</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="text-center mt-6">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Questions? Contact our support team at{' '}
<a href="mailto:support@smoothschedule.com" className="text-blue-600 dark:text-blue-400 hover:underline">
support@smoothschedule.com
</a>
</p>
</div>
</div>
</div>
);
};
export default TrialExpired;

View File

@@ -0,0 +1,389 @@
import React, { useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, Loader2, CreditCard, Shield, Zap, Users, ArrowLeft } from 'lucide-react';
import { User, Business } from '../types';
type BillingPeriod = 'monthly' | 'annual';
type PlanTier = 'Professional' | 'Business' | 'Enterprise';
interface PlanFeature {
text: string;
included: boolean;
}
interface PlanDetails {
name: string;
description: string;
monthlyPrice: number;
annualPrice: number;
features: PlanFeature[];
popular?: boolean;
}
/**
* Upgrade Page
* Allows businesses to upgrade from trial/free to a paid plan
*/
const Upgrade: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user, business } = useOutletContext<{ user: User; business: Business }>();
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('monthly');
const [selectedPlan, setSelectedPlan] = useState<PlanTier>('Professional');
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Plan configurations
const plans: Record<PlanTier, PlanDetails> = {
Professional: {
name: t('marketing.pricing.tiers.professional.name'),
description: t('marketing.pricing.tiers.professional.description'),
monthlyPrice: 29,
annualPrice: 290,
features: [
{ text: t('upgrade.features.resources', { count: 10 }), included: true },
{ text: t('upgrade.features.customDomain'), included: true },
{ text: t('upgrade.features.stripeConnect'), included: true },
{ text: t('upgrade.features.whitelabel'), included: true },
{ text: t('upgrade.features.emailReminders'), included: true },
{ text: t('upgrade.features.prioritySupport'), included: true },
{ text: t('upgrade.features.apiAccess'), included: false },
],
},
Business: {
name: t('marketing.pricing.tiers.business.name'),
description: t('marketing.pricing.tiers.business.description'),
monthlyPrice: 79,
annualPrice: 790,
popular: true,
features: [
{ text: t('upgrade.features.unlimitedResources'), included: true },
{ text: t('upgrade.features.customDomain'), included: true },
{ text: t('upgrade.features.stripeConnect'), included: true },
{ text: t('upgrade.features.whitelabel'), included: true },
{ text: t('upgrade.features.teamManagement'), included: true },
{ text: t('upgrade.features.advancedAnalytics'), included: true },
{ text: t('upgrade.features.apiAccess'), included: true },
{ text: t('upgrade.features.phoneSupport'), included: true },
],
},
Enterprise: {
name: t('marketing.pricing.tiers.enterprise.name'),
description: t('marketing.pricing.tiers.enterprise.description'),
monthlyPrice: 0,
annualPrice: 0,
features: [
{ text: t('upgrade.features.everything'), included: true },
{ text: t('upgrade.features.customIntegrations'), included: true },
{ text: t('upgrade.features.dedicatedManager'), included: true },
{ text: t('upgrade.features.sla'), included: true },
{ text: t('upgrade.features.customContracts'), included: true },
{ text: t('upgrade.features.onPremise'), included: true },
],
},
};
const currentPlan = plans[selectedPlan];
const price = billingPeriod === 'monthly' ? currentPlan.monthlyPrice : currentPlan.annualPrice;
const isEnterprise = selectedPlan === 'Enterprise';
const handleUpgrade = async () => {
if (isEnterprise) {
// For Enterprise, redirect to contact page
window.location.href = 'mailto:sales@smoothschedule.com?subject=Enterprise Plan Inquiry';
return;
}
setIsProcessing(true);
setError(null);
try {
// TODO: Integrate with Stripe Checkout or dj-stripe subscription creation
// This is a placeholder for the actual Stripe integration
// Example flow:
// 1. Call backend API to create Stripe Checkout session
// 2. Redirect to Stripe Checkout
// 3. Handle success/cancel callbacks
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
// For now, just show a message
alert(`Upgrading to ${selectedPlan} (${billingPeriod})\nThis would redirect to Stripe Checkout.`);
// After successful payment, redirect to dashboard
navigate('/');
} catch (err) {
setError(t('upgrade.errors.processingFailed'));
console.error('Upgrade error:', err);
} finally {
setIsProcessing(false);
}
};
const calculateSavings = () => {
if (billingPeriod === 'annual') {
const monthlyCost = currentPlan.monthlyPrice * 12;
const annualCost = currentPlan.annualPrice;
return monthlyCost - annualCost;
}
return 0;
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12 px-4">
<div className="max-w-6xl mx-auto">
{/* Back Button */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-8"
>
<ArrowLeft size={20} />
{t('common.back')}
</button>
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
{t('upgrade.title')}
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{t('upgrade.subtitle', { businessName: business.name })}
</p>
</div>
{/* Billing Period Toggle */}
<div className="flex justify-center mb-12">
<div className="inline-flex items-center gap-4 bg-white dark:bg-gray-800 rounded-full p-1.5 shadow-sm border border-gray-200 dark:border-gray-700">
<button
onClick={() => setBillingPeriod('monthly')}
className={`px-6 py-2.5 rounded-full font-medium transition-all ${
billingPeriod === 'monthly'
? 'bg-brand-600 text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('upgrade.billing.monthly')}
</button>
<button
onClick={() => setBillingPeriod('annual')}
className={`px-6 py-2.5 rounded-full font-medium transition-all relative ${
billingPeriod === 'annual'
? 'bg-brand-600 text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('upgrade.billing.annual')}
<span className="absolute -top-2 -right-2 px-2 py-0.5 bg-green-500 text-white text-xs font-bold rounded-full">
{t('upgrade.billing.save20')}
</span>
</button>
</div>
</div>
{/* Plan Cards */}
<div className="grid md:grid-cols-3 gap-8 mb-12">
{(Object.entries(plans) as [PlanTier, PlanDetails][]).map(([tier, plan]) => {
const isSelected = selectedPlan === tier;
const tierPrice = billingPeriod === 'monthly' ? plan.monthlyPrice : plan.annualPrice;
const isEnterprisePlan = tier === 'Enterprise';
return (
<div
key={tier}
onClick={() => setSelectedPlan(tier)}
className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border-2 transition-all cursor-pointer ${
isSelected
? 'border-brand-600 shadow-brand-500/20 scale-105'
: 'border-gray-200 dark:border-gray-700 hover:border-brand-300'
} ${plan.popular ? 'ring-2 ring-brand-500' : ''}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<span className="px-4 py-1.5 bg-brand-600 text-white text-sm font-bold rounded-full shadow-lg">
{t('upgrade.mostPopular')}
</span>
</div>
)}
<div className="p-8">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{plan.name}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{plan.description}
</p>
<div className="mb-6">
{isEnterprisePlan ? (
<div className="text-4xl font-bold text-gray-900 dark:text-white">
{t('upgrade.custom')}
</div>
) : (
<>
<div className="text-4xl font-bold text-gray-900 dark:text-white">
${tierPrice}
<span className="text-lg font-normal text-gray-500">
/{billingPeriod === 'monthly' ? t('upgrade.month') : t('upgrade.year')}
</span>
</div>
{billingPeriod === 'annual' && (
<div className="text-sm text-green-600 dark:text-green-400 mt-1">
{t('upgrade.billing.saveAmount', { amount: (plan.monthlyPrice * 12 - plan.annualPrice) })}
</div>
)}
</>
)}
</div>
<ul className="space-y-3 mb-8">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-3">
<div
className={`p-1 rounded-full mt-0.5 ${
feature.included
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-gray-100 dark:bg-gray-700'
}`}
>
<Check
className={
feature.included
? 'text-green-600 dark:text-green-400'
: 'text-gray-400'
}
size={16}
/>
</div>
<span
className={`text-sm ${
feature.included
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{feature.text}
</span>
</li>
))}
</ul>
<div
className={`w-full py-3 rounded-xl font-semibold text-center transition-all ${
isSelected
? 'bg-brand-600 text-white shadow-lg'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{isSelected ? t('upgrade.selected') : t('upgrade.selectPlan')}
</div>
</div>
</div>
);
})}
</div>
{/* Summary & Checkout */}
<div className="max-w-2xl mx-auto bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
{t('upgrade.orderSummary')}
</h2>
<div className="space-y-4 mb-8">
<div className="flex justify-between items-center pb-4 border-b border-gray-200 dark:border-gray-700">
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{currentPlan.name} {t('upgrade.plan')}
</div>
<div className="text-sm text-gray-500">
{billingPeriod === 'monthly' ? t('upgrade.billedMonthly') : t('upgrade.billedAnnually')}
</div>
</div>
{!isEnterprise && (
<div className="text-xl font-bold text-gray-900 dark:text-white">
${price}
</div>
)}
</div>
{billingPeriod === 'annual' && !isEnterprise && calculateSavings() > 0 && (
<div className="flex justify-between items-center text-green-600 dark:text-green-400">
<div className="font-medium">{t('upgrade.annualSavings')}</div>
<div className="font-bold">-${calculateSavings()}</div>
</div>
)}
</div>
{/* Trust Indicators */}
<div className="grid grid-cols-3 gap-4 mb-8 p-6 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="text-center">
<Shield className="mx-auto mb-2 text-brand-600" size={24} />
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('upgrade.trust.secure')}
</div>
</div>
<div className="text-center">
<Zap className="mx-auto mb-2 text-brand-600" size={24} />
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('upgrade.trust.instant')}
</div>
</div>
<div className="text-center">
<Users className="mx-auto mb-2 text-brand-600" size={24} />
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('upgrade.trust.support')}
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
</div>
)}
{/* Upgrade Button */}
<button
onClick={handleUpgrade}
disabled={isProcessing}
className="w-full flex items-center justify-center gap-3 py-4 bg-gradient-to-r from-brand-600 to-brand-700 text-white font-bold rounded-xl hover:from-brand-700 hover:to-brand-800 transition-all shadow-lg shadow-brand-500/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<>
<Loader2 className="animate-spin" size={20} />
{t('upgrade.processing')}
</>
) : (
<>
<CreditCard size={20} />
{isEnterprise ? t('upgrade.contactSales') : t('upgrade.continueToPayment')}
</>
)}
</button>
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-4">
{t('upgrade.secureCheckout')}
</p>
</div>
{/* FAQ or Additional Info */}
<div className="max-w-2xl mx-auto mt-12 text-center">
<p className="text-gray-600 dark:text-gray-400">
{t('upgrade.questions')}{' '}
<a
href="mailto:support@smoothschedule.com"
className="text-brand-600 hover:text-brand-700 font-medium"
>
{t('upgrade.contactUs')}
</a>
</p>
</div>
</div>
</div>
);
};
export default Upgrade;

View File

@@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { CheckCircle, XCircle, Loader2, Mail, ShieldCheck } from 'lucide-react';
import apiClient from '../api/client';
import { deleteCookie } from '../utils/cookies';
type VerificationStatus = 'pending' | 'loading' | 'success' | 'error' | 'already_verified';
const VerifyEmail: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<VerificationStatus>('pending');
const [errorMessage, setErrorMessage] = useState('');
const token = searchParams.get('token');
const handleVerify = async () => {
if (!token) {
setStatus('error');
setErrorMessage('No verification token provided');
return;
}
setStatus('loading');
try {
const response = await apiClient.get(`/api/auth/emails/verify/${token}/`);
// Immediately clear auth cookies to log out
deleteCookie('access_token');
deleteCookie('refresh_token');
if (response.data.message === 'Email is already verified') {
setStatus('already_verified');
} else {
setStatus('success');
}
} catch (err: any) {
setStatus('error');
setErrorMessage(err.response?.data?.detail || 'Failed to verify email');
}
};
// If no token, show error immediately
if (!token) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
<XCircle size={32} className="text-red-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Invalid Link
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-6">
No verification token was provided. Please check your email for the correct link.
</p>
<button
onClick={() => navigate('/login')}
className="w-full px-4 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Go to Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
{status === 'pending' && (
<>
<div className="mx-auto w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mb-6">
<ShieldCheck size={32} className="text-brand-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Verify Your Email
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-6">
Click the button below to confirm your email address.
</p>
<button
onClick={handleVerify}
className="w-full px-4 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Confirm Verification
</button>
</>
)}
{status === 'loading' && (
<>
<div className="mx-auto w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mb-6">
<Loader2 size={32} className="text-brand-500 animate-spin" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Verifying Your Email
</h1>
<p className="text-gray-500 dark:text-gray-400">
Please wait while we verify your email address...
</p>
</>
)}
{status === 'success' && (
<>
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-6">
<CheckCircle size={32} className="text-green-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Email Verified!
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-6">
Your email address has been successfully verified. You can now sign in to your account.
</p>
<button
onClick={() => window.location.href = '/login'}
className="w-full px-4 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Go to Login
</button>
</>
)}
{status === 'already_verified' && (
<>
<div className="mx-auto w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
<Mail size={32} className="text-blue-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Already Verified
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-6">
This email address has already been verified. You can use it to sign in to your account.
</p>
<button
onClick={() => navigate('/login')}
className="w-full px-4 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Go to Login
</button>
</>
)}
{status === 'error' && (
<>
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
<XCircle size={32} className="text-red-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Verification Failed
</h1>
<p className="text-gray-500 dark:text-gray-400 mb-6">
{errorMessage || 'We were unable to verify your email address. The link may have expired or is invalid.'}
</p>
<div className="space-y-3">
<button
onClick={() => navigate('/login')}
className="w-full px-4 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Go to Login
</button>
<p className="text-sm text-gray-500 dark:text-gray-400">
If you need a new verification link, please sign in and request one from your profile settings.
</p>
</div>
</>
)}
</div>
</div>
);
};
export default VerifyEmail;

View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { useOutletContext, Link } from 'react-router-dom';
import { User, Business, Service, Customer } from '../../types';
import { SERVICES, CUSTOMERS } from '../../mockData';
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard } from 'lucide-react';
const BookingPage: React.FC = () => {
const { user, business } = useOutletContext<{ user: User, business: Business }>();
const customer = CUSTOMERS.find(c => c.userId === user.id);
const [step, setStep] = useState(1);
const [selectedService, setSelectedService] = useState<Service | null>(null);
const [selectedTime, setSelectedTime] = useState<Date | null>(null);
const [bookingConfirmed, setBookingConfirmed] = useState(false);
// Mock available times
const availableTimes: Date[] = [
new Date(new Date().setHours(9, 0, 0, 0)),
new Date(new Date().setHours(10, 30, 0, 0)),
new Date(new Date().setHours(14, 0, 0, 0)),
new Date(new Date().setHours(16, 15, 0, 0)),
];
const handleSelectService = (service: Service) => {
if (business.requirePaymentMethodToBook && (!customer || customer.paymentMethods.length === 0)) {
// Handled by the conditional rendering below, but could also be an alert.
return;
}
setSelectedService(service);
setStep(2);
};
const handleSelectTime = (time: Date) => {
setSelectedTime(time);
setStep(3);
};
const handleConfirmBooking = () => {
// In a real app, this would send a request to the backend.
setBookingConfirmed(true);
setStep(4);
};
const resetFlow = () => {
setStep(1);
setSelectedService(null);
setSelectedTime(null);
setBookingConfirmed(false);
}
const renderStepContent = () => {
if (business.requirePaymentMethodToBook && (!customer || customer.paymentMethods.length === 0)) {
return (
<div className="text-center bg-yellow-50 dark:bg-yellow-900/30 p-8 rounded-lg border border-yellow-200 dark:border-yellow-700">
<AlertTriangle className="mx-auto text-yellow-500" size={40} />
<h3 className="mt-4 text-lg font-bold text-yellow-800 dark:text-yellow-200">Payment Method Required</h3>
<p className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
This business requires a payment method on file to book an appointment. Please add a card to your account before proceeding.
</p>
<Link to="/payments" className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 font-medium shadow-sm transition-colors">
<CreditCard size={16} /> Go to Billing
</Link>
</div>
)
}
switch (step) {
case 1: // Select Service
return (
<div className="space-y-4">
{SERVICES.map(service => (
<button
key={service.id}
onClick={() => handleSelectService(service)}
className="w-full text-left p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:border-brand-500 hover:ring-1 hover:ring-brand-500 transition-all flex justify-between items-center"
>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">{service.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">{service.durationMinutes} min {service.description}</p>
</div>
<span className="font-bold text-lg text-gray-900 dark:text-white">${service.price.toFixed(2)}</span>
</button>
))}
</div>
);
case 2: // Select Time
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{availableTimes.map(time => (
<button
key={time.toISOString()}
onClick={() => handleSelectTime(time)}
className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm text-center hover:bg-brand-50 dark:hover:bg-brand-900/50 hover:border-brand-500 transition-colors"
>
<p className="text-lg font-bold text-gray-900 dark:text-white">{time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>
</button>
))}
</div>
);
case 3: // Confirmation
return (
<div className="p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm text-center space-y-4">
<Calendar className="mx-auto text-brand-500" size={40}/>
<h3 className="text-xl font-bold">Confirm Your Booking</h3>
<p className="text-gray-500 dark:text-gray-400">You are booking <strong className="text-gray-900 dark:text-white">{selectedService?.name}</strong> for <strong className="text-gray-900 dark:text-white">{selectedTime?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</strong>.</p>
<div className="pt-4">
<button onClick={handleConfirmBooking} className="w-full max-w-xs py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">
Confirm Appointment
</button>
</div>
</div>
);
case 4: // Success
return (
<div className="p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm text-center space-y-4">
<Check className="mx-auto text-green-500 bg-green-100 dark:bg-green-900/50 rounded-full p-2" size={48}/>
<h3 className="text-xl font-bold">Appointment Booked!</h3>
<p className="text-gray-500 dark:text-gray-400">Your appointment for <strong className="text-gray-900 dark:text-white">{selectedService?.name}</strong> at <strong className="text-gray-900 dark:text-white">{selectedTime?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</strong> is confirmed.</p>
<div className="pt-4 flex justify-center gap-4">
<Link to="/" className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">Go to Dashboard</Link>
<button onClick={resetFlow} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700">Book Another</button>
</div>
</div>
)
default:
return null;
}
};
return (
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-4 mb-6">
{step > 1 && step < 4 && (
<button onClick={() => setStep(s => s - 1)} className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700">
<ChevronLeft size={20} />
</button>
)}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{step === 1 && "Step 1: Select a Service"}
{step === 2 && "Step 2: Choose a Time"}
{step === 3 && "Step 3: Confirm Details"}
{step === 4 && "Booking Confirmed"}
</h2>
<p className="text-gray-500 dark:text-gray-400">
{step === 1 && "Pick from our list of available services."}
{step === 2 && `Available times for ${new Date().toLocaleDateString()}`}
{step === 3 && "Please review your appointment details below."}
{step === 4 && "We've sent a confirmation to your email."}
</p>
</div>
</div>
{renderStepContent()}
</div>
);
};
export default BookingPage;

View File

@@ -0,0 +1,73 @@
import React, { useState, useMemo } from 'react';
import { useOutletContext, Link } from 'react-router-dom';
import { User, Business, Appointment } from '../../types';
import { APPOINTMENTS, SERVICES } from '../../mockData';
import { Calendar, Clock, MapPin, AlertTriangle } from 'lucide-react';
const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, business }) => {
const [appointments, setAppointments] = useState(APPOINTMENTS);
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
const myAppointments = useMemo(() => appointments.filter(apt => apt.customerName.includes(user.name.split(' ')[0])).sort((a, b) => b.startTime.getTime() - a.startTime.getTime()), [user.name, appointments]);
const upcomingAppointments = myAppointments.filter(apt => new Date(apt.startTime) >= new Date() && apt.status !== 'CANCELLED');
const pastAppointments = myAppointments.filter(apt => new Date(apt.startTime) < new Date() || apt.status === 'CANCELLED');
const handleCancel = (appointment: Appointment) => {
const hoursBefore = (new Date(appointment.startTime).getTime() - new Date().getTime()) / 3600000;
if (hoursBefore < business.cancellationWindowHours) {
const service = SERVICES.find(s => s.id === appointment.serviceId);
const fee = service ? (service.price * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
if (!window.confirm(`Cancelling within the ${business.cancellationWindowHours}-hour window may incur a fee of $${fee}. Are you sure?`)) return;
} else {
if (!window.confirm("Are you sure you want to cancel this appointment?")) return;
}
setAppointments(prev => prev.map(apt => apt.id === appointment.id ? {...apt, status: 'CANCELLED'} : apt));
};
return (
<div className="mt-8">
<h2 className="text-xl font-bold mb-4">Your Appointments</h2>
<div className="flex items-center gap-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 self-start">
<button onClick={() => setActiveTab('upcoming')} className={`px-4 py-2 text-sm font-medium rounded-md ${activeTab === 'upcoming' ? 'bg-brand-500 text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}>Upcoming</button>
<button onClick={() => setActiveTab('past')} className={`px-4 py-2 text-sm font-medium rounded-md ${activeTab === 'past' ? 'bg-brand-500 text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}>Past</button>
</div>
<div className="space-y-4">
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
const service = SERVICES.find(s => s.id === apt.serviceId);
return (
<div key={apt.id} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 className="font-semibold">{service?.name}</h3>
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
</div>
{activeTab === 'upcoming' && <button onClick={() => handleCancel(apt)} className="text-sm font-medium text-red-600 hover:underline">Cancel</button>}
</div>
);
})}
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).length === 0 && (
<div className="text-center py-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p className="text-gray-500 dark:text-gray-400">No {activeTab} appointments found.</p>
</div>
)}
</div>
</div>
);
};
const CustomerDashboard: React.FC = () => {
const { user, business } = useOutletContext<{ user: User, business: Business }>();
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Welcome, {user.name.split(' ')[0]}!</h1>
<p className="text-gray-500 dark:text-gray-400">View your upcoming appointments and manage your account.</p>
</div>
<AppointmentList user={user} business={business} />
</div>
);
};
export default CustomerDashboard;

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Lightbulb, Shield, Eye, HeartHandshake } from 'lucide-react';
import CTASection from '../../components/marketing/CTASection';
const AboutPage: React.FC = () => {
const { t } = useTranslation();
const values = [
{
icon: Lightbulb,
titleKey: 'marketing.about.values.simplicity.title',
descriptionKey: 'marketing.about.values.simplicity.description',
color: 'brand',
},
{
icon: Shield,
titleKey: 'marketing.about.values.reliability.title',
descriptionKey: 'marketing.about.values.reliability.description',
color: 'green',
},
{
icon: Eye,
titleKey: 'marketing.about.values.transparency.title',
descriptionKey: 'marketing.about.values.transparency.description',
color: 'purple',
},
{
icon: HeartHandshake,
titleKey: 'marketing.about.values.support.title',
descriptionKey: 'marketing.about.values.support.description',
color: 'orange',
},
];
const colorClasses: Record<string, string> = {
brand: 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400',
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
};
return (
<div>
{/* Header Section */}
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-6">
{t('marketing.about.title')}
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400">
{t('marketing.about.subtitle')}
</p>
</div>
</section>
{/* Story Section */}
<section className="py-20 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">
{t('marketing.about.story.title')}
</h2>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-6">
{t('marketing.about.story.content')}
</p>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{t('marketing.about.story.content2')}
</p>
</div>
<div className="bg-gradient-to-br from-brand-500 to-brand-600 rounded-2xl p-8 text-white">
<div className="text-6xl font-bold mb-2">2017</div>
<div className="text-brand-100">{t('marketing.about.story.founded')}</div>
<div className="mt-8 space-y-4">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-brand-200 rounded-full" />
<span className="text-brand-100">8+ years building scheduling solutions</span>
</div>
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-brand-200 rounded-full" />
<span className="text-brand-100">Battle-tested with real businesses</span>
</div>
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-brand-200 rounded-full" />
<span className="text-brand-100">Features born from customer feedback</span>
</div>
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-brand-200 rounded-full" />
<span className="text-brand-100">Now available to everyone</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Mission Section */}
<section className="py-20 bg-gray-50 dark:bg-gray-800/50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">
{t('marketing.about.mission.title')}
</h2>
<p className="text-xl text-gray-600 dark:text-gray-400 leading-relaxed max-w-3xl mx-auto">
{t('marketing.about.mission.content')}
</p>
</div>
</section>
{/* Values Section */}
<section className="py-20 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-12 text-center">
{t('marketing.about.values.title')}
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{values.map((value) => (
<div key={value.titleKey} className="text-center">
<div className={`inline-flex p-4 rounded-2xl ${colorClasses[value.color]} mb-4`}>
<value.icon className="h-8 w-8" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t(value.titleKey)}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{t(value.descriptionKey)}
</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<CTASection variant="minimal" />
</div>
);
};
export default AboutPage;

View File

@@ -0,0 +1,261 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Mail, Phone, MapPin, Send, MessageSquare } from 'lucide-react';
const ContactPage: React.FC = () => {
const { t } = useTranslation();
const [formState, setFormState] = useState({
name: '',
email: '',
subject: '',
message: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
// Simulate form submission
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsSubmitting(false);
setSubmitted(true);
setFormState({ name: '', email: '', subject: '', message: '' });
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormState((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const contactInfo = [
{
icon: Mail,
label: 'Email',
value: t('marketing.contact.info.email'),
href: `mailto:${t('marketing.contact.info.email')}`,
},
{
icon: Phone,
label: 'Phone',
value: t('marketing.contact.info.phone'),
href: `tel:${t('marketing.contact.info.phone').replace(/[^0-9+]/g, '')}`,
},
{
icon: MapPin,
label: 'Address',
value: t('marketing.contact.info.address'),
href: null,
},
];
return (
<div>
{/* Header Section */}
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-6">
{t('marketing.contact.title')}
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400">
{t('marketing.contact.subtitle')}
</p>
</div>
</section>
{/* Contact Content */}
<section className="py-20 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16">
{/* Contact Form */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-8">
Send us a message
</h2>
{submitted ? (
<div className="p-8 bg-green-50 dark:bg-green-900/20 rounded-2xl border border-green-200 dark:border-green-800 text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Send className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
Message Sent!
</h3>
<p className="text-green-600 dark:text-green-400">
{t('marketing.contact.form.success')}
</p>
<button
onClick={() => setSubmitted(false)}
className="mt-4 text-sm text-green-700 dark:text-green-300 underline"
>
Send another message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.contact.form.name')}
</label>
<input
type="text"
id="name"
name="name"
value={formState.name}
onChange={handleChange}
required
placeholder={t('marketing.contact.form.namePlaceholder')}
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.contact.form.email')}
</label>
<input
type="email"
id="email"
name="email"
value={formState.email}
onChange={handleChange}
required
placeholder={t('marketing.contact.form.emailPlaceholder')}
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label
htmlFor="subject"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.contact.form.subject')}
</label>
<input
type="text"
id="subject"
name="subject"
value={formState.subject}
onChange={handleChange}
required
placeholder={t('marketing.contact.form.subjectPlaceholder')}
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.contact.form.message')}
</label>
<textarea
id="message"
name="message"
value={formState.message}
onChange={handleChange}
required
rows={5}
placeholder={t('marketing.contact.form.messagePlaceholder')}
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors resize-none"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3.5 px-6 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('marketing.contact.form.sending')}
</>
) : (
<>
<Send className="h-5 w-5" />
{t('marketing.contact.form.submit')}
</>
)}
</button>
</form>
)}
</div>
{/* Contact Info */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-8">
Get in touch
</h2>
<div className="space-y-6 mb-12">
{contactInfo.map((item) => (
<div key={item.label} className="flex items-start gap-4">
<div className="p-3 bg-brand-50 dark:bg-brand-900/30 rounded-xl">
<item.icon className="h-6 w-6 text-brand-600 dark:text-brand-400" />
</div>
<div>
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">
{item.label}
</div>
{item.href ? (
<a
href={item.href}
className="text-gray-900 dark:text-white hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{item.value}
</a>
) : (
<span className="text-gray-900 dark:text-white">{item.value}</span>
)}
</div>
</div>
))}
</div>
{/* Sales CTA */}
<div className="p-6 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-xl">
<MessageSquare className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('marketing.contact.sales.title')}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t('marketing.contact.sales.description')}
</p>
<a
href="mailto:sales@smoothschedule.com"
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
>
Schedule a call
<span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
);
};
export default ContactPage;

View File

@@ -0,0 +1,173 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Calendar,
Users,
CreditCard,
Building2,
Palette,
BarChart3,
Plug,
UserCircle,
Bell,
Shield,
Smartphone,
Clock,
} from 'lucide-react';
import FeatureCard from '../../components/marketing/FeatureCard';
import CTASection from '../../components/marketing/CTASection';
const FeaturesPage: React.FC = () => {
const { t } = useTranslation();
const featureCategories = [
{
title: 'Scheduling & Calendar',
features: [
{
icon: Calendar,
titleKey: 'marketing.features.scheduling.title',
descriptionKey: 'marketing.features.scheduling.description',
color: 'brand',
},
{
icon: Clock,
title: 'Real-Time Availability',
description: 'Customers see only available time slots. No double bookings, ever.',
color: 'green',
},
{
icon: Bell,
title: 'Automated Reminders',
description: 'Reduce no-shows with email and SMS reminders sent automatically.',
color: 'purple',
},
],
},
{
title: 'Resource Management',
features: [
{
icon: Users,
titleKey: 'marketing.features.resources.title',
descriptionKey: 'marketing.features.resources.description',
color: 'orange',
},
{
icon: Smartphone,
title: 'Staff Mobile App',
description: 'Your team can view schedules and manage appointments on the go.',
color: 'pink',
},
{
icon: Shield,
title: 'Role-Based Access',
description: 'Control what each team member can see and do with granular permissions.',
color: 'cyan',
},
],
},
{
title: 'Customer Experience',
features: [
{
icon: UserCircle,
titleKey: 'marketing.features.customers.title',
descriptionKey: 'marketing.features.customers.description',
color: 'brand',
},
{
icon: CreditCard,
titleKey: 'marketing.features.payments.title',
descriptionKey: 'marketing.features.payments.description',
color: 'green',
},
{
icon: Palette,
titleKey: 'marketing.features.whiteLabel.title',
descriptionKey: 'marketing.features.whiteLabel.description',
color: 'purple',
},
],
},
{
title: 'Business Growth',
features: [
{
icon: Building2,
titleKey: 'marketing.features.multiTenant.title',
descriptionKey: 'marketing.features.multiTenant.description',
color: 'orange',
},
{
icon: BarChart3,
titleKey: 'marketing.features.analytics.title',
descriptionKey: 'marketing.features.analytics.description',
color: 'pink',
},
{
icon: Plug,
titleKey: 'marketing.features.integrations.title',
descriptionKey: 'marketing.features.integrations.description',
color: 'cyan',
},
],
},
];
return (
<div>
{/* Header Section */}
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.features.title')}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{t('marketing.features.subtitle')}
</p>
</div>
</div>
</section>
{/* Feature Categories */}
{featureCategories.map((category, categoryIndex) => (
<section
key={categoryIndex}
className={`py-20 ${
categoryIndex % 2 === 0
? 'bg-white dark:bg-gray-900'
: 'bg-gray-50 dark:bg-gray-800/50'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mb-10 text-center">
{category.title}
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{category.features.map((feature, featureIndex) => (
<FeatureCard
key={featureIndex}
icon={feature.icon}
title={feature.titleKey ? t(feature.titleKey) : feature.title || ''}
description={
feature.descriptionKey
? t(feature.descriptionKey)
: feature.description || ''
}
iconColor={feature.color}
/>
))}
</div>
</div>
</section>
))}
{/* CTA Section */}
<CTASection />
</div>
);
};
export default FeaturesPage;

View File

@@ -0,0 +1,258 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Calendar,
Users,
CreditCard,
Building2,
Palette,
BarChart3,
Plug,
UserCircle,
ArrowRight,
} from 'lucide-react';
import Hero from '../../components/marketing/Hero';
import FeatureCard from '../../components/marketing/FeatureCard';
import HowItWorks from '../../components/marketing/HowItWorks';
import StatsSection from '../../components/marketing/StatsSection';
import TestimonialCard from '../../components/marketing/TestimonialCard';
import CTASection from '../../components/marketing/CTASection';
const HomePage: React.FC = () => {
const { t } = useTranslation();
const features = [
{
icon: Calendar,
titleKey: 'marketing.features.scheduling.title',
descriptionKey: 'marketing.features.scheduling.description',
color: 'brand',
},
{
icon: Users,
titleKey: 'marketing.features.resources.title',
descriptionKey: 'marketing.features.resources.description',
color: 'green',
},
{
icon: UserCircle,
titleKey: 'marketing.features.customers.title',
descriptionKey: 'marketing.features.customers.description',
color: 'purple',
},
{
icon: CreditCard,
titleKey: 'marketing.features.payments.title',
descriptionKey: 'marketing.features.payments.description',
color: 'orange',
},
{
icon: Building2,
titleKey: 'marketing.features.multiTenant.title',
descriptionKey: 'marketing.features.multiTenant.description',
color: 'pink',
},
{
icon: Palette,
titleKey: 'marketing.features.whiteLabel.title',
descriptionKey: 'marketing.features.whiteLabel.description',
color: 'cyan',
},
];
const testimonials = [
{
quote: "SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.",
author: "Sarah Johnson",
role: "Owner",
company: "Luxe Salon",
rating: 5,
},
{
quote: "The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.",
author: "Michael Chen",
role: "CEO",
company: "FitLife Studios",
rating: 5,
},
{
quote: "Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.",
author: "Emily Rodriguez",
role: "Manager",
company: "Peak Performance Therapy",
rating: 5,
},
];
return (
<div>
{/* Hero Section */}
<Hero />
{/* Features Section */}
<section className="py-20 lg:py-28 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.features.title')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{t('marketing.features.subtitle')}
</p>
</div>
{/* Feature Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{features.map((feature) => (
<FeatureCard
key={feature.titleKey}
icon={feature.icon}
title={t(feature.titleKey)}
description={t(feature.descriptionKey)}
iconColor={feature.color}
/>
))}
</div>
{/* View All Features Link */}
<div className="text-center mt-12">
<Link
to="/features"
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
>
{t('common.viewAll')} {t('marketing.nav.features').toLowerCase()}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</section>
{/* How It Works Section */}
<HowItWorks />
{/* Stats Section */}
<StatsSection />
{/* Testimonials Section */}
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.testimonials.title')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{t('marketing.testimonials.subtitle')}
</p>
</div>
{/* Testimonials Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{testimonials.map((testimonial, index) => (
<TestimonialCard key={index} {...testimonial} />
))}
</div>
</div>
</section>
{/* Pricing Preview Section */}
<section className="py-20 lg:py-28 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.pricing.title')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{t('marketing.pricing.subtitle')}
</p>
</div>
{/* Pricing Cards Preview */}
<div className="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto">
{/* Free */}
<div className="p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('marketing.pricing.tiers.free.name')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('marketing.pricing.tiers.free.description')}
</p>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900 dark:text-white">$0</span>
<span className="text-gray-500 dark:text-gray-400">{t('marketing.pricing.perMonth')}</span>
</div>
<Link
to="/signup"
className="block w-full py-3 px-4 text-center text-sm font-medium text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
</div>
{/* Professional - Highlighted */}
<div className="relative p-6 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20">
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-brand-500 text-white text-xs font-semibold rounded-full">
{t('marketing.pricing.mostPopular')}
</div>
<h3 className="text-lg font-semibold text-white mb-2">
{t('marketing.pricing.tiers.professional.name')}
</h3>
<p className="text-sm text-brand-100 mb-4">
{t('marketing.pricing.tiers.professional.description')}
</p>
<div className="mb-6">
<span className="text-4xl font-bold text-white">$29</span>
<span className="text-brand-200">{t('marketing.pricing.perMonth')}</span>
</div>
<Link
to="/signup"
className="block w-full py-3 px-4 text-center text-sm font-medium text-brand-600 bg-white rounded-lg hover:bg-brand-50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
</div>
{/* Business */}
<div className="p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('marketing.pricing.tiers.business.name')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('marketing.pricing.tiers.business.description')}
</p>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900 dark:text-white">$79</span>
<span className="text-gray-500 dark:text-gray-400">{t('marketing.pricing.perMonth')}</span>
</div>
<Link
to="/signup"
className="block w-full py-3 px-4 text-center text-sm font-medium text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
</div>
</div>
{/* View Full Pricing Link */}
<div className="text-center mt-10">
<Link
to="/pricing"
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
>
View full pricing details
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</section>
{/* Final CTA */}
<CTASection />
</div>
);
};
export default HomePage;

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import PricingCard from '../../components/marketing/PricingCard';
import FAQAccordion from '../../components/marketing/FAQAccordion';
import CTASection from '../../components/marketing/CTASection';
const PricingPage: React.FC = () => {
const { t } = useTranslation();
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
const faqItems = [
{
question: t('marketing.faq.questions.freePlan.question'),
answer: t('marketing.faq.questions.freePlan.answer'),
},
{
question: t('marketing.faq.questions.cancel.question'),
answer: t('marketing.faq.questions.cancel.answer'),
},
{
question: t('marketing.faq.questions.payment.question'),
answer: t('marketing.faq.questions.payment.answer'),
},
{
question: t('marketing.faq.questions.migrate.question'),
answer: t('marketing.faq.questions.migrate.answer'),
},
{
question: t('marketing.faq.questions.support.question'),
answer: t('marketing.faq.questions.support.answer'),
},
{
question: t('marketing.faq.questions.customDomain.question'),
answer: t('marketing.faq.questions.customDomain.answer'),
},
];
return (
<div>
{/* Header Section */}
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.pricing.title')}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{t('marketing.pricing.subtitle')}
</p>
</div>
{/* Billing Toggle */}
<div className="flex flex-wrap items-center justify-center gap-3 mb-12">
<div className="flex items-center gap-3">
<span
className={`text-sm font-medium whitespace-nowrap transition-colors ${
billingPeriod === 'monthly'
? 'text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{t('marketing.pricing.monthly')}
</span>
<button
onClick={() => setBillingPeriod(billingPeriod === 'monthly' ? 'annual' : 'monthly')}
className="relative flex-shrink-0 w-12 h-6 bg-brand-600 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Toggle billing period"
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${
billingPeriod === 'annual' ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
<span
className={`text-sm font-medium whitespace-nowrap transition-colors ${
billingPeriod === 'annual'
? 'text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{t('marketing.pricing.annual')}
</span>
</div>
{billingPeriod === 'annual' && (
<span className="px-2 py-1 text-xs font-semibold text-brand-700 bg-brand-100 dark:bg-brand-900/30 dark:text-brand-300 rounded-full whitespace-nowrap">
{t('marketing.pricing.annualSave')}
</span>
)}
</div>
{/* Pricing Cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
<PricingCard tier="free" billingPeriod={billingPeriod} />
<PricingCard tier="professional" billingPeriod={billingPeriod} highlighted />
<PricingCard tier="business" billingPeriod={billingPeriod} />
<PricingCard tier="enterprise" billingPeriod={billingPeriod} />
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.faq.title')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{t('marketing.faq.subtitle')}
</p>
</div>
<FAQAccordion items={faqItems} />
</div>
</section>
{/* CTA Section */}
<CTASection variant="minimal" />
</div>
);
};
export default PricingPage;

View File

@@ -0,0 +1,970 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Building2,
User,
CreditCard,
CheckCircle,
ArrowRight,
ArrowLeft,
Check,
AlertCircle,
Loader2,
} from 'lucide-react';
import apiClient from '../../api/client';
interface SignupFormData {
// Step 1: Business info
businessName: string;
subdomain: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
phone: string;
// Step 2: User info
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
// Step 3: Plan selection
plan: 'free' | 'professional' | 'business' | 'enterprise';
}
const SignupPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [currentStep, setCurrentStep] = useState(1);
const firstNameRef = useRef<HTMLInputElement>(null);
// Focus on firstName when entering step 2
useEffect(() => {
if (currentStep === 2 && firstNameRef.current) {
firstNameRef.current.focus();
}
}, [currentStep]);
const [formData, setFormData] = useState<SignupFormData>({
businessName: '',
subdomain: '',
addressLine1: '',
addressLine2: '',
city: '',
state: '',
postalCode: '',
country: 'US',
phone: '',
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
plan: (searchParams.get('plan') as SignupFormData['plan']) || 'professional',
});
// Total steps: Business Info, User Info, Plan Selection, Confirmation
const totalSteps = 4;
const [errors, setErrors] = useState<Partial<Record<keyof SignupFormData, string>>>({});
const [subdomainAvailable, setSubdomainAvailable] = useState<boolean | null>(null);
const [checkingSubdomain, setCheckingSubdomain] = useState(false);
const [subdomainManuallyEdited, setSubdomainManuallyEdited] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [signupComplete, setSignupComplete] = useState(false);
// Signup steps
const steps = [
{ number: 1, title: t('marketing.signup.steps.business'), icon: Building2 },
{ number: 2, title: t('marketing.signup.steps.account'), icon: User },
{ number: 3, title: t('marketing.signup.steps.plan'), icon: CreditCard },
{ number: 4, title: t('marketing.signup.steps.confirm'), icon: CheckCircle },
];
const plans = [
{
id: 'free' as const,
name: t('marketing.pricing.tiers.free.name'),
price: '$0',
period: t('marketing.pricing.period'),
features: [
t('marketing.pricing.tiers.free.features.0'),
t('marketing.pricing.tiers.free.features.1'),
t('marketing.pricing.tiers.free.features.2'),
],
},
{
id: 'professional' as const,
name: t('marketing.pricing.tiers.professional.name'),
price: '$29',
period: t('marketing.pricing.period'),
popular: true,
features: [
t('marketing.pricing.tiers.professional.features.0'),
t('marketing.pricing.tiers.professional.features.1'),
t('marketing.pricing.tiers.professional.features.2'),
t('marketing.pricing.tiers.professional.features.3'),
],
},
{
id: 'business' as const,
name: t('marketing.pricing.tiers.business.name'),
price: '$79',
period: t('marketing.pricing.period'),
features: [
t('marketing.pricing.tiers.business.features.0'),
t('marketing.pricing.tiers.business.features.1'),
t('marketing.pricing.tiers.business.features.2'),
t('marketing.pricing.tiers.business.features.3'),
],
},
{
id: 'enterprise' as const,
name: t('marketing.pricing.tiers.enterprise.name'),
price: t('marketing.pricing.tiers.enterprise.price'),
period: '',
features: [
t('marketing.pricing.tiers.enterprise.features.0'),
t('marketing.pricing.tiers.enterprise.features.1'),
t('marketing.pricing.tiers.enterprise.features.2'),
t('marketing.pricing.tiers.enterprise.features.3'),
],
},
];
// Generate subdomain from business name (only if user hasn't manually edited it)
useEffect(() => {
if (formData.businessName && !subdomainManuallyEdited) {
const generated = formData.businessName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 30);
setFormData((prev) => ({ ...prev, subdomain: generated }));
}
}, [formData.businessName, subdomainManuallyEdited]);
// Check subdomain availability with debounce
useEffect(() => {
if (!formData.subdomain || formData.subdomain.length < 3) {
setSubdomainAvailable(null);
return;
}
const timer = setTimeout(async () => {
setCheckingSubdomain(true);
try {
const response = await apiClient.post('/api/auth/signup/check-subdomain/', {
subdomain: formData.subdomain,
});
setSubdomainAvailable(response.data.available);
} catch {
setSubdomainAvailable(null);
} finally {
setCheckingSubdomain(false);
}
}, 500);
return () => clearTimeout(timer);
}, [formData.subdomain]);
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => ({ ...prev, [name]: undefined }));
};
const handleSubdomainChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
setFormData((prev) => ({ ...prev, subdomain: value }));
setErrors((prev) => ({ ...prev, subdomain: undefined }));
setSubdomainAvailable(null);
setSubdomainManuallyEdited(true);
};
const validateStep = (step: number): boolean => {
const newErrors: Partial<Record<keyof SignupFormData, string>> = {};
if (step === 1) {
if (!formData.businessName.trim()) {
newErrors.businessName = t('marketing.signup.errors.businessNameRequired');
}
if (!formData.subdomain.trim()) {
newErrors.subdomain = t('marketing.signup.errors.subdomainRequired');
} else if (formData.subdomain.length < 3) {
newErrors.subdomain = t('marketing.signup.errors.subdomainTooShort');
} else if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(formData.subdomain) && formData.subdomain.length > 2) {
newErrors.subdomain = t('marketing.signup.errors.subdomainInvalid');
} else if (subdomainAvailable === false) {
newErrors.subdomain = t('marketing.signup.errors.subdomainTaken');
}
// Address validation
if (!formData.addressLine1.trim()) {
newErrors.addressLine1 = t('marketing.signup.errors.addressRequired');
}
if (!formData.city.trim()) {
newErrors.city = t('marketing.signup.errors.cityRequired');
}
if (!formData.state.trim()) {
newErrors.state = t('marketing.signup.errors.stateRequired');
}
if (!formData.postalCode.trim()) {
newErrors.postalCode = t('marketing.signup.errors.postalCodeRequired');
}
}
if (step === 2) {
if (!formData.firstName.trim()) {
newErrors.firstName = t('marketing.signup.errors.firstNameRequired');
}
if (!formData.lastName.trim()) {
newErrors.lastName = t('marketing.signup.errors.lastNameRequired');
}
if (!formData.email.trim()) {
newErrors.email = t('marketing.signup.errors.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('marketing.signup.errors.emailInvalid');
}
if (!formData.password) {
newErrors.password = t('marketing.signup.errors.passwordRequired');
} else if (formData.password.length < 8) {
newErrors.password = t('marketing.signup.errors.passwordTooShort');
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = t('marketing.signup.errors.passwordMismatch');
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep((prev) => Math.min(prev + 1, totalSteps));
}
};
const handleBack = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// Determine if current step is the confirmation step (last step)
const isConfirmationStep = currentStep === totalSteps;
const handleSubmit = async () => {
if (!validateStep(currentStep)) return;
setIsSubmitting(true);
setSubmitError(null);
try {
await apiClient.post('/api/auth/signup/', {
business_name: formData.businessName,
subdomain: formData.subdomain,
address_line1: formData.addressLine1,
address_line2: formData.addressLine2,
city: formData.city,
state: formData.state,
postal_code: formData.postalCode,
country: formData.country,
phone: formData.phone,
first_name: formData.firstName,
last_name: formData.lastName,
email: formData.email,
password: formData.password,
tier: formData.plan.toUpperCase(),
payments_enabled: false,
});
setSignupComplete(true);
} catch (error: any) {
const errorMessage =
error.response?.data?.detail ||
error.response?.data?.message ||
t('marketing.signup.errors.generic');
setSubmitError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
if (signupComplete) {
return (
<div className="min-h-screen bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-20">
<div className="max-w-lg mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-10 h-10 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.signup.success.title')}
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{t('marketing.signup.success.message')}
</p>
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-6">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
{t('marketing.signup.success.yourUrl')}
</p>
<p className="text-lg font-semibold text-brand-600 dark:text-brand-400">
{formData.subdomain}.smoothschedule.com
</p>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{t('marketing.signup.success.checkEmail')}
</p>
<button
onClick={() => {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${formData.subdomain}.lvh.me${port}/login`;
}}
className="w-full py-3 px-6 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-colors"
>
{t('marketing.signup.success.goToLogin')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 lg:py-20">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-10">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-3">
{t('marketing.signup.title')}
</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('marketing.signup.subtitle')}
</p>
</div>
{/* Progress Steps */}
<div className="mb-10">
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<React.Fragment key={step.number}>
<div className="flex flex-col items-center">
<div
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
currentStep > step.number
? 'bg-green-500 text-white'
: currentStep === step.number
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-400'
}`}
>
{currentStep > step.number ? (
<Check className="w-6 h-6" />
) : (
<step.icon className="w-6 h-6" />
)}
</div>
<span
className={`mt-2 text-xs font-medium hidden sm:block ${
currentStep >= step.number
? 'text-gray-900 dark:text-white'
: 'text-gray-400'
}`}
>
{step.title}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`flex-1 h-1 mx-2 rounded ${
currentStep > step.number
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Form Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 sm:p-8">
{/* Step 1: Business Info */}
{currentStep === 1 && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('marketing.signup.businessInfo.title')}
</h2>
<div>
<label
htmlFor="businessName"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.name')}
</label>
<input
type="text"
id="businessName"
name="businessName"
value={formData.businessName}
onChange={handleInputChange}
autoComplete="organization"
placeholder={t('marketing.signup.businessInfo.namePlaceholder')}
className={`w-full px-4 py-3 rounded-xl border ${
errors.businessName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.businessName && (
<p className="mt-1 text-sm text-red-500">{errors.businessName}</p>
)}
</div>
<div>
<label
htmlFor="subdomain"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.subdomain')}
</label>
<div className="flex items-center">
<input
type="text"
id="subdomain"
name="subdomain"
value={formData.subdomain}
onChange={handleSubdomainChange}
autoComplete="off"
placeholder="your-business"
className={`flex-1 px-4 py-3 rounded-l-xl border-y border-l ${
errors.subdomain
? 'border-red-500 focus:ring-red-500'
: subdomainAvailable === true
? 'border-green-500 focus:ring-green-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
<span className="px-4 py-3 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-r-xl text-gray-500 dark:text-gray-400 text-sm">
.smoothschedule.com
</span>
</div>
<div className="mt-1 flex items-center gap-2">
{checkingSubdomain && (
<span className="text-sm text-gray-500 flex items-center gap-1">
<Loader2 className="w-4 h-4 animate-spin" />
{t('marketing.signup.businessInfo.checking')}
</span>
)}
{!checkingSubdomain && subdomainAvailable === true && (
<span className="text-sm text-green-500 flex items-center gap-1">
<Check className="w-4 h-4" />
{t('marketing.signup.businessInfo.available')}
</span>
)}
{!checkingSubdomain && subdomainAvailable === false && (
<span className="text-sm text-red-500 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{t('marketing.signup.businessInfo.taken')}
</span>
)}
{errors.subdomain && !subdomainAvailable && (
<span className="text-sm text-red-500">{errors.subdomain}</span>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t('marketing.signup.businessInfo.subdomainNote')}
</p>
</div>
{/* Business Address */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t('marketing.signup.businessInfo.address')}
</h3>
<div className="space-y-4">
<div>
<label
htmlFor="addressLine1"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.addressLine1')}
</label>
<input
type="text"
id="addressLine1"
name="addressLine1"
value={formData.addressLine1}
onChange={handleInputChange}
autoComplete="address-line1"
placeholder={t('marketing.signup.businessInfo.addressLine1Placeholder')}
className={`w-full px-4 py-3 rounded-xl border ${
errors.addressLine1
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.addressLine1 && (
<p className="mt-1 text-sm text-red-500">{errors.addressLine1}</p>
)}
</div>
<div>
<label
htmlFor="addressLine2"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.addressLine2')}
</label>
<input
type="text"
id="addressLine2"
name="addressLine2"
value={formData.addressLine2}
onChange={handleInputChange}
autoComplete="address-line2"
placeholder={t('marketing.signup.businessInfo.addressLine2Placeholder')}
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors"
/>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="city"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.city')}
</label>
<input
type="text"
id="city"
name="city"
value={formData.city}
onChange={handleInputChange}
autoComplete="address-level2"
className={`w-full px-4 py-3 rounded-xl border ${
errors.city
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.city && (
<p className="mt-1 text-sm text-red-500">{errors.city}</p>
)}
</div>
<div>
<label
htmlFor="state"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.state')}
</label>
<input
type="text"
id="state"
name="state"
value={formData.state}
onChange={handleInputChange}
autoComplete="address-level1"
className={`w-full px-4 py-3 rounded-xl border ${
errors.state
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.state && (
<p className="mt-1 text-sm text-red-500">{errors.state}</p>
)}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="postalCode"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.postalCode')}
</label>
<input
type="text"
id="postalCode"
name="postalCode"
value={formData.postalCode}
onChange={handleInputChange}
autoComplete="postal-code"
className={`w-full px-4 py-3 rounded-xl border ${
errors.postalCode
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.postalCode && (
<p className="mt-1 text-sm text-red-500">{errors.postalCode}</p>
)}
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.businessInfo.phone')}
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
autoComplete="tel"
placeholder={t('marketing.signup.businessInfo.phonePlaceholder')}
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors"
/>
</div>
</div>
</div>
</div>
</div>
)}
{/* Step 2: User Info */}
{currentStep === 2 && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('marketing.signup.accountInfo.title')}
</h2>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.accountInfo.firstName')}
</label>
<input
ref={firstNameRef}
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
autoComplete="given-name"
className={`w-full px-4 py-3 rounded-xl border ${
errors.firstName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.firstName && (
<p className="mt-1 text-sm text-red-500">{errors.firstName}</p>
)}
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.accountInfo.lastName')}
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
autoComplete="family-name"
className={`w-full px-4 py-3 rounded-xl border ${
errors.lastName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.lastName && (
<p className="mt-1 text-sm text-red-500">{errors.lastName}</p>
)}
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.accountInfo.email')}
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
autoComplete="email"
placeholder="you@example.com"
className={`w-full px-4 py-3 rounded-xl border ${
errors.email
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.accountInfo.password')}
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
autoComplete="new-password"
className={`w-full px-4 py-3 rounded-xl border ${
errors.password
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-500">{errors.password}</p>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('marketing.signup.accountInfo.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
autoComplete="new-password"
className={`w-full px-4 py-3 rounded-xl border ${
errors.confirmPassword
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
)}
</div>
</div>
)}
{/* Step 3: Plan Selection */}
{currentStep === 3 && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('marketing.signup.planSelection.title')}
</h2>
<div className="grid sm:grid-cols-2 gap-4">
{plans.map((plan) => (
<button
key={plan.id}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, plan: plan.id }))}
className={`relative text-left p-4 rounded-xl border-2 transition-all ${
formData.plan === plan.id
? 'border-brand-600 bg-brand-50 dark:bg-gray-800 dark:border-brand-500'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{plan.popular && (
<span className="absolute -top-3 left-4 px-2 py-0.5 text-xs font-semibold text-white bg-brand-600 rounded-full">
{t('marketing.pricing.popular')}
</span>
)}
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{plan.name}
</h3>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{plan.price}
{plan.period && (
<span className="text-sm font-normal text-gray-500">
/{plan.period}
</span>
)}
</p>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
formData.plan === plan.id
? 'border-brand-600 bg-brand-600'
: 'border-gray-300 dark:border-gray-600'
}`}
>
{formData.plan === plan.id && (
<Check className="w-3 h-3 text-white" />
)}
</div>
</div>
<ul className="space-y-1">
{plan.features.slice(0, 3).map((feature, index) => (
<li
key={index}
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
>
<Check className="w-3 h-3 text-green-500 flex-shrink-0" />
{feature}
</li>
))}
</ul>
</button>
))}
</div>
</div>
)}
{/* Confirmation Step (last step) */}
{isConfirmationStep && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('marketing.signup.confirm.title')}
</h2>
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
{t('marketing.signup.confirm.business')}
</h3>
<p className="text-gray-900 dark:text-white font-medium">
{formData.businessName}
</p>
<p className="text-sm text-brand-600 dark:text-brand-400">
{formData.subdomain}.smoothschedule.com
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
{t('marketing.signup.confirm.account')}
</h3>
<p className="text-gray-900 dark:text-white font-medium">
{formData.firstName} {formData.lastName}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{formData.email}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
{t('marketing.signup.confirm.plan')}
</h3>
<p className="text-gray-900 dark:text-white font-medium">
{plans.find((p) => p.id === formData.plan)?.name}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{plans.find((p) => p.id === formData.plan)?.price}
{plans.find((p) => p.id === formData.plan)?.period &&
`/${plans.find((p) => p.id === formData.plan)?.period}`}
</p>
</div>
</div>
{submitError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
{t('marketing.signup.confirm.terms')}
</p>
</div>
)}
{/* Navigation Buttons */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
{currentStep > 1 ? (
<button
type="button"
onClick={handleBack}
className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('marketing.signup.back')}
</button>
) : (
<div />
)}
{!isConfirmationStep ? (
<button
type="button"
onClick={handleNext}
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 transition-colors font-medium"
>
{t('marketing.signup.next')}
<ArrowRight className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('marketing.signup.creating')}
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
{t('marketing.signup.createAccount')}
</>
)}
</button>
)}
</div>
</div>
{/* Login Link */}
<p className="text-center mt-6 text-gray-600 dark:text-gray-400">
{t('marketing.signup.haveAccount')}{' '}
<button
onClick={() => navigate('/login')}
className="text-brand-600 dark:text-brand-400 hover:underline font-medium"
>
{t('marketing.signup.signIn')}
</button>
</p>
</div>
</div>
);
};
export default SignupPage;

View File

@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react';
import { User } from '../../types';
import { useBusinesses } from '../../hooks/usePlatform';
interface PlatformBusinessesProps {
onMasquerade: (targetUser: User) => void;
}
const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade }) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const { data: businesses, isLoading, error } = useBusinesses();
const filteredBusinesses = (businesses || []).filter(b =>
b.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
b.subdomain.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleLoginAs = (business: any) => {
// Use the owner data from the API response
if (business.owner) {
const targetOwner: User = {
id: business.owner.id.toString(),
username: business.owner.username,
name: business.owner.name,
email: business.owner.email,
role: business.owner.role,
business_id: business.id.toString(),
business_subdomain: business.subdomain,
is_active: true,
is_staff: false,
is_superuser: false,
};
onMasquerade(targetOwner);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{t('errors.generic')}</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.businesses')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('platform.businessesDescription')}</p>
</div>
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium shadow-sm">
{t('platform.addNewTenant')}
</button>
</div>
<div className="flex items-center gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder={t('platform.searchBusinesses')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600">
<Filter size={16} /> {t('common.filter')}
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium">{t('platform.businessName')}</th>
<th className="px-6 py-4 font-medium">{t('platform.subdomain')}</th>
<th className="px-6 py-4 font-medium">{t('platform.plan')}</th>
<th className="px-6 py-4 font-medium">{t('platform.status')}</th>
<th className="px-6 py-4 font-medium">{t('platform.joined')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{filteredBusinesses.map((biz) => {
const tierDisplay = biz.tier.charAt(0).toUpperCase() + biz.tier.slice(1).toLowerCase();
const statusDisplay = biz.is_active ? 'Active' : 'Inactive';
return (
<tr key={biz.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 flex items-center justify-center font-bold text-xs text-indigo-600">
{biz.name.substring(0, 2).toUpperCase()}
</div>
{biz.name}
</div>
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-xs">
{biz.subdomain}.smoothschedule.com
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${biz.tier === 'ENTERPRISE' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
biz.tier === 'BUSINESS' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
biz.tier === 'PROFESSIONAL' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}
`}>
{tierDisplay}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{biz.is_active && <ShieldCheck size={16} className="text-green-500" />}
{!biz.is_active && <Ban size={16} className="text-red-500" />}
<span className="text-gray-700 dark:text-gray-300">{statusDisplay}</span>
</div>
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{new Date(biz.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleLoginAs(biz)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors mr-2"
disabled={!biz.owner}
title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.name}`}
>
<Eye size={14} /> {t('platform.masquerade')}
</button>
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<MoreHorizontal size={18} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
export default PlatformBusinesses;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { PLATFORM_METRICS } from '../../mockData';
import { TrendingUp, TrendingDown, Minus, Users, DollarSign, Activity, AlertCircle } from 'lucide-react';
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts';
const data = [
{ name: 'Jan', mrr: 340000 },
{ name: 'Feb', mrr: 355000 },
{ name: 'Mar', mrr: 370000 },
{ name: 'Apr', mrr: 365000 },
{ name: 'May', mrr: 390000 },
{ name: 'Jun', mrr: 410000 },
{ name: 'Jul', mrr: 425900 },
];
const PlatformDashboard: React.FC = () => {
const { t } = useTranslation();
const getColorClass = (color: string) => {
switch(color) {
case 'blue': return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400';
case 'green': return 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400';
case 'purple': return 'text-purple-600 bg-purple-50 dark:bg-purple-900/20 dark:text-purple-400';
case 'orange': return 'text-orange-600 bg-orange-50 dark:bg-orange-900/20 dark:text-orange-400';
default: return 'text-gray-600 bg-gray-50';
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.overview')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('platform.overviewDescription')}</p>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{PLATFORM_METRICS.map((metric, idx) => (
<div key={idx} className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{metric.label}</p>
<span className={`p-2 rounded-lg ${getColorClass(metric.color)}`}>
{metric.label.includes('Revenue') ? <DollarSign size={16} /> :
metric.label.includes('Active') ? <Users size={16} /> :
metric.label.includes('Churn') ? <AlertCircle size={16} /> : <Activity size={16} />}
</span>
</div>
<div className="flex items-baseline gap-3">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">{metric.value}</h3>
<span className={`flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${
metric.trend === 'up' ? 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400' :
'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400'
}`}>
{metric.trend === 'up' ? <TrendingUp size={12} className="mr-1"/> : <TrendingDown size={12} className="mr-1"/>}
{metric.change}
</span>
</div>
</div>
))}
</div>
{/* MRR Chart */}
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">{t('platform.mrrGrowth')}</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id="colorMrr" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#6366f1" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.1} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<YAxis axisLine={false} tickLine={false} tickFormatter={(val) => `$${val/1000}k`} tick={{ fill: '#9CA3AF' }} />
<Tooltip
contentStyle={{
backgroundColor: '#1F2937',
border: 'none',
borderRadius: '8px',
color: '#fff'
}}
formatter={(val: number) => [`$${val.toLocaleString()}`, 'MRR']}
/>
<Area type="monotone" dataKey="mrr" stroke="#6366f1" fillOpacity={1} fill="url(#colorMrr)" strokeWidth={3} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
export default PlatformDashboard;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SUPPORT_TICKETS } from '../../mockData';
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
const PlatformSupport: React.FC = () => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.supportTickets')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('platform.supportDescription')}</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{SUPPORT_TICKETS.map((ticket) => (
<div key={ticket.id} className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-lg shrink-0 ${
ticket.priority === 'High' || ticket.priority === 'Critical' ? 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400' :
ticket.priority === 'Medium' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400' :
'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
}`}>
<TicketIcon size={20} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{ticket.subject}</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
{ticket.id}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.businessName}</span></p>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Clock size={12} /> {ticket.createdAt.toLocaleDateString()}
</span>
<span className={`flex items-center gap-1 font-medium ${
ticket.status === 'Open' ? 'text-green-600' :
ticket.status === 'In Progress' ? 'text-blue-600' : 'text-gray-500'
}`}>
{ticket.status === 'Open' && <AlertCircle size={12} />}
{ticket.status === 'Resolved' && <CheckCircle2 size={12} />}
{ticket.status}
</span>
</div>
</div>
</div>
<div className="text-right">
<span className={`inline-block px-2 py-1 rounded text-xs font-medium ${
ticket.priority === 'High' ? 'bg-red-50 text-red-700 dark:bg-red-900/30' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}`}>
{ticket.priority} {t('platform.priority')}
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default PlatformSupport;

View File

@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
import { User } from '../../types';
import { usePlatformUsers } from '../../hooks/usePlatform';
interface PlatformUsersProps {
onMasquerade: (targetUser: User) => void;
}
const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [roleFilter, setRoleFilter] = useState<string>('all');
const { data: users, isLoading, error } = usePlatformUsers();
const filteredUsers = (users || []).filter(u => {
const matchesSearch = (u.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.username.toLowerCase().includes(searchTerm.toLowerCase());
const matchesRole = roleFilter === 'all' || u.role === roleFilter;
return matchesSearch && matchesRole;
});
const getRoleBadgeColor = (role: string) => {
switch(role) {
case 'superuser': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'platform_manager':
case 'platform_support': return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300';
case 'owner': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'staff': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'customer': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
default: return 'bg-gray-100 text-gray-800';
}
};
const handleMasquerade = (platformUser: any) => {
// Convert platform user to User type for masquerade
const targetUser: User = {
id: platformUser.id.toString(),
username: platformUser.username,
name: platformUser.name || platformUser.username,
email: platformUser.email,
role: platformUser.role || 'customer',
business_id: platformUser.business?.toString() || null,
business_subdomain: platformUser.business_subdomain || null,
is_active: platformUser.is_active,
is_staff: platformUser.is_staff,
is_superuser: platformUser.is_superuser,
};
onMasquerade(targetUser);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{t('errors.generic')}</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.userDirectory')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('platform.userDirectoryDescription')}</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder={t('platform.searchUsers')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:text-white"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-gray-700 dark:text-gray-200"
>
<option value="all">{t('platform.allRoles')}</option>
<option value="superuser">{t('platform.roles.superuser')}</option>
<option value="platform_manager">{t('platform.roles.platformManager')}</option>
<option value="owner">{t('platform.roles.businessOwner')}</option>
<option value="staff">{t('platform.roles.staff')}</option>
<option value="customer">{t('platform.roles.customer')}</option>
</select>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium">{t('platform.user')}</th>
<th className="px-6 py-4 font-medium">{t('platform.role')}</th>
<th className="px-6 py-4 font-medium">{t('platform.email')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{filteredUsers.map((u) => (
<tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center text-indigo-600 dark:text-indigo-300 font-semibold text-sm">
{(u.name || u.username).charAt(0).toUpperCase()}
</div>
<div>
<div>{u.name || u.username}</div>
{u.business_name && (
<div className="text-xs text-gray-500 dark:text-gray-400">{u.business_name}</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${getRoleBadgeColor(u.role || 'customer')}`}>
{(u.role || 'customer').replace(/_/g, ' ')}
</span>
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-xs">
{u.email}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleMasquerade(u)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
disabled={u.is_superuser}
title={u.is_superuser ? 'Cannot masquerade as superuser' : `Masquerade as ${u.name || u.username}`}
>
<Eye size={14} /> {t('platform.masquerade')}
</button>
</td>
</tr>
))}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
{t('platform.noUsersFound')}
</div>
)}
</div>
</div>
);
};
export default PlatformUsers;

View File

@@ -0,0 +1,122 @@
import React, { useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { User } from '../../types';
import { APPOINTMENTS, RESOURCES, SERVICES } from '../../mockData';
import { CheckCircle, XCircle, Clock, Calendar, Briefcase } from 'lucide-react';
const StatCard: React.FC<{ icon: React.ElementType; label: string; value: string | number; color: string }> = ({ icon: Icon, label, value, color }) => (
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm transition-colors duration-200">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg bg-opacity-10 ${color}`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</p>
<span className="text-2xl font-bold text-gray-900 dark:text-white">{value}</span>
</div>
</div>
</div>
);
const ResourceDashboard: React.FC = () => {
const { user } = useOutletContext<{ user: User }>();
const myResource = useMemo(() => RESOURCES.find(r => r.userId === user.id), [user.id]);
const myAppointments = useMemo(() => {
if (!myResource) return [];
return APPOINTMENTS.filter(a => a.resourceId === myResource.id);
}, [myResource]);
const stats = useMemo(() => {
const today = new Date();
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - today.getDay());
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
const thisWeekAppointments = myAppointments.filter(a => {
const aptDate = new Date(a.startTime);
return aptDate >= startOfWeek && aptDate <= endOfWeek;
});
const completedThisWeek = thisWeekAppointments.filter(a => a.status === 'COMPLETED').length;
const totalPastThisWeek = thisWeekAppointments.filter(a => new Date(a.startTime) < today && (a.status === 'COMPLETED' || a.status === 'NO_SHOW')).length;
const noShowThisWeek = thisWeekAppointments.filter(a => a.status === 'NO_SHOW').length;
const noShowRate = totalPastThisWeek > 0 ? ((noShowThisWeek / totalPastThisWeek) * 100).toFixed(0) + '%' : '0%';
const hoursBookedThisWeek = thisWeekAppointments.reduce((total, apt) => total + apt.durationMinutes, 0) / 60;
return {
completed: completedThisWeek,
noShowRate,
hoursBooked: hoursBookedThisWeek.toFixed(1)
};
}, [myAppointments]);
const todaysAppointments = useMemo(() => {
const today = new Date();
return myAppointments
.filter(a => new Date(a.startTime).toDateString() === today.toDateString())
.sort((a,b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}, [myAppointments]);
if (!myResource) {
return <div className="p-8">Error: Resource not found for this user.</div>;
}
return (
<div className="p-8 space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h2>
<p className="text-gray-500 dark:text-gray-400">Welcome, {myResource.name}. Here's your performance summary.</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<StatCard icon={CheckCircle} label="Completed This Week" value={stats.completed} color="text-green-500 bg-green-100" />
<StatCard icon={XCircle} label="No-Show Rate (Weekly)" value={stats.noShowRate} color="text-red-500 bg-red-100" />
<StatCard icon={Briefcase} label="Hours Booked This Week" value={stats.hoursBooked} color="text-blue-500 bg-blue-100" />
</div>
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2"><Calendar size={20} className="text-brand-500" /> Today's Agenda</h3>
<div className="space-y-3">
{todaysAppointments.length > 0 ? todaysAppointments.map(apt => {
const startTime = new Date(apt.startTime);
const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000);
// FIX: Look up service by serviceId to get the service name.
const service = SERVICES.find(s => s.id === apt.serviceId);
return (
<div key={apt.id} className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<p className="font-semibold text-gray-900 dark:text-white">{apt.customerName}</p>
{/* FIX: Property 'serviceName' does not exist on type 'Appointment'. Use looked-up service name. */}
<p className="text-sm text-gray-500 dark:text-gray-400">{service?.name}</p>
</div>
<div className="text-right">
<div className="flex items-center gap-2 font-medium text-sm text-gray-700 dark:text-gray-300">
<Clock size={14} />
<span>{startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - {endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
<p className="text-xs text-gray-400">{apt.durationMinutes} minutes</p>
</div>
</div>
)
}) : (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">No appointments scheduled for today.</p>
</div>
)}
</div>
</div>
</div>
);
};
export default ResourceDashboard;