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:
282
legacy_reference/frontend/src/pages/Customers.tsx
Normal file
282
legacy_reference/frontend/src/pages/Customers.tsx
Normal 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;
|
||||
209
legacy_reference/frontend/src/pages/Dashboard.tsx
Normal file
209
legacy_reference/frontend/src/pages/Dashboard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
249
legacy_reference/frontend/src/pages/LoginPage.tsx
Normal file
249
legacy_reference/frontend/src/pages/LoginPage.tsx
Normal 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;
|
||||
258
legacy_reference/frontend/src/pages/OAuthCallback.tsx
Normal file
258
legacy_reference/frontend/src/pages/OAuthCallback.tsx
Normal 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;
|
||||
990
legacy_reference/frontend/src/pages/OwnerScheduler.tsx
Normal file
990
legacy_reference/frontend/src/pages/OwnerScheduler.tsx
Normal 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;
|
||||
870
legacy_reference/frontend/src/pages/Payments.tsx
Normal file
870
legacy_reference/frontend/src/pages/Payments.tsx
Normal 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;
|
||||
1139
legacy_reference/frontend/src/pages/ProfileSettings.tsx
Normal file
1139
legacy_reference/frontend/src/pages/ProfileSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
76
legacy_reference/frontend/src/pages/PublicSitePage.tsx
Normal file
76
legacy_reference/frontend/src/pages/PublicSitePage.tsx
Normal 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 →</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;
|
||||
336
legacy_reference/frontend/src/pages/ResourceScheduler.tsx
Normal file
336
legacy_reference/frontend/src/pages/ResourceScheduler.tsx
Normal 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;
|
||||
266
legacy_reference/frontend/src/pages/Resources.tsx
Normal file
266
legacy_reference/frontend/src/pages/Resources.tsx
Normal 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;
|
||||
41
legacy_reference/frontend/src/pages/Scheduler.tsx
Normal file
41
legacy_reference/frontend/src/pages/Scheduler.tsx
Normal 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;
|
||||
292
legacy_reference/frontend/src/pages/Services.tsx
Normal file
292
legacy_reference/frontend/src/pages/Services.tsx
Normal 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;
|
||||
1110
legacy_reference/frontend/src/pages/Settings.tsx
Normal file
1110
legacy_reference/frontend/src/pages/Settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
182
legacy_reference/frontend/src/pages/Staff.tsx
Normal file
182
legacy_reference/frontend/src/pages/Staff.tsx
Normal 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;
|
||||
222
legacy_reference/frontend/src/pages/TrialExpired.tsx
Normal file
222
legacy_reference/frontend/src/pages/TrialExpired.tsx
Normal 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;
|
||||
389
legacy_reference/frontend/src/pages/Upgrade.tsx
Normal file
389
legacy_reference/frontend/src/pages/Upgrade.tsx
Normal 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;
|
||||
175
legacy_reference/frontend/src/pages/VerifyEmail.tsx
Normal file
175
legacy_reference/frontend/src/pages/VerifyEmail.tsx
Normal 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;
|
||||
160
legacy_reference/frontend/src/pages/customer/BookingPage.tsx
Normal file
160
legacy_reference/frontend/src/pages/customer/BookingPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
140
legacy_reference/frontend/src/pages/marketing/AboutPage.tsx
Normal file
140
legacy_reference/frontend/src/pages/marketing/AboutPage.tsx
Normal 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;
|
||||
261
legacy_reference/frontend/src/pages/marketing/ContactPage.tsx
Normal file
261
legacy_reference/frontend/src/pages/marketing/ContactPage.tsx
Normal 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">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactPage;
|
||||
173
legacy_reference/frontend/src/pages/marketing/FeaturesPage.tsx
Normal file
173
legacy_reference/frontend/src/pages/marketing/FeaturesPage.tsx
Normal 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;
|
||||
258
legacy_reference/frontend/src/pages/marketing/HomePage.tsx
Normal file
258
legacy_reference/frontend/src/pages/marketing/HomePage.tsx
Normal 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;
|
||||
124
legacy_reference/frontend/src/pages/marketing/PricingPage.tsx
Normal file
124
legacy_reference/frontend/src/pages/marketing/PricingPage.tsx
Normal 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;
|
||||
970
legacy_reference/frontend/src/pages/marketing/SignupPage.tsx
Normal file
970
legacy_reference/frontend/src/pages/marketing/SignupPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
1298
legacy_reference/frontend/src/pages/platform/PlatformSettings.tsx
Normal file
1298
legacy_reference/frontend/src/pages/platform/PlatformSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
163
legacy_reference/frontend/src/pages/platform/PlatformUsers.tsx
Normal file
163
legacy_reference/frontend/src/pages/platform/PlatformUsers.tsx
Normal 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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user