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