Add booking flow, business hours, and dark mode support
Features: - Complete multi-step booking flow with service selection, date/time picker, auth (login/signup with email verification), payment, and confirmation - Business hours settings page for defining when business is open - TimeBlock purpose field (BUSINESS_HOURS, CLOSURE, UNAVAILABLE) - Service resource assignment with prep/takedown time buffers - Availability checking respects business hours and service buffers - Customer registration via email verification code UI/UX: - Full dark mode support for all booking components - Separate first/last name fields in signup form - Back buttons on each wizard step - Removed auto-redirect from confirmation page API: - Public endpoints for services, availability, business hours - Customer verification and registration endpoints - Tenant lookup from X-Business-Subdomain header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
Plug,
|
||||
FileSignature,
|
||||
CalendarOff,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
@@ -119,6 +120,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{!isStaff && (
|
||||
@@ -152,6 +154,13 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{/* Manage Section - Staff+ */}
|
||||
{canViewManagementPages && (
|
||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/site-editor"
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/customers"
|
||||
icon={Users}
|
||||
|
||||
361
frontend/src/components/booking/AuthSection.tsx
Normal file
361
frontend/src/components/booking/AuthSection.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, Lock, User as UserIcon, ArrowRight, Shield } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../../api/client';
|
||||
|
||||
export interface User {
|
||||
id: string | number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AuthSectionProps {
|
||||
onLogin: (user: User) => void;
|
||||
}
|
||||
|
||||
export const AuthSection: React.FC<AuthSectionProps> = ({ onLogin }) => {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Email verification states
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [verifyingCode, setVerifyingCode] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/login/', {
|
||||
username: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
const user: User = {
|
||||
id: response.data.user.id,
|
||||
email: response.data.user.email,
|
||||
name: response.data.user.full_name || response.data.user.email,
|
||||
};
|
||||
|
||||
toast.success('Welcome back!');
|
||||
onLogin(user);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Send verification email
|
||||
await api.post('/auth/send-verification/', {
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
});
|
||||
|
||||
toast.success('Verification code sent to your email!');
|
||||
setNeedsVerification(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Failed to send verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setVerifyingCode(true);
|
||||
|
||||
try {
|
||||
// Verify code and create account
|
||||
const response = await api.post('/auth/verify-and-register/', {
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
password: password,
|
||||
verification_code: verificationCode
|
||||
});
|
||||
|
||||
const user: User = {
|
||||
id: response.data.user.id,
|
||||
email: response.data.user.email,
|
||||
name: response.data.user.full_name || response.data.user.name,
|
||||
};
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
onLogin(user);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Verification failed');
|
||||
} finally {
|
||||
setVerifyingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/auth/send-verification/', {
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
});
|
||||
toast.success('New code sent!');
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to resend code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
if (isLogin) {
|
||||
handleLogin(e);
|
||||
} else {
|
||||
handleSignup(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Show verification step for new customers
|
||||
if (needsVerification && !isLogin) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
We've sent a 6-digit code to <span className="font-medium text-gray-900 dark:text-white">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<form onSubmit={handleVerifyCode} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="block w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifyingCode || verificationCode.length !== 6}
|
||||
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{verifyingCode ? (
|
||||
<span className="animate-pulse">Verifying...</span>
|
||||
) : (
|
||||
<>
|
||||
Verify & Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={loading}
|
||||
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 disabled:opacity-50"
|
||||
>
|
||||
Resend Code
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNeedsVerification(false);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
Change email address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{isLogin ? 'Welcome Back' : 'Create Account'}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
{isLogin
|
||||
? 'Sign in to access your bookings and history.'
|
||||
: 'Join us to book your first premium service.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{!isLogin && (
|
||||
<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">First Name</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required={!isLogin}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required={!isLogin}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="block w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<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 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={isLogin ? undefined : 8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{!isLogin && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
|
||||
<div className="relative">
|
||||
<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 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={`block w-full pl-10 pr-3 py-2.5 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors ${
|
||||
confirmPassword && password !== confirmPassword
|
||||
? 'border-red-300 dark:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">Processing...</span>
|
||||
) : (
|
||||
<>
|
||||
{isLogin ? 'Sign In' : 'Create Account'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLogin(!isLogin);
|
||||
setConfirmPassword('');
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
}}
|
||||
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -33,29 +33,32 @@ export const BookingWidget: React.FC<BookingWidgetProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="booking-widget p-6 bg-white rounded-lg shadow-md max-w-md mx-auto text-left">
|
||||
<h2 className="text-2xl font-bold mb-2" style={{ color: accentColor }}>{headline}</h2>
|
||||
<p className="text-gray-600 mb-6">{subheading}</p>
|
||||
<div className="booking-widget p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-900/30 max-w-md mx-auto text-left border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-2xl font-bold mb-2 text-indigo-600 dark:text-indigo-400">{headline}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">{subheading}</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{services?.length === 0 && <p>No services available.</p>}
|
||||
{services?.length === 0 && <p className="text-gray-600 dark:text-gray-400">No services available.</p>}
|
||||
{services?.map((service: any) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className={`p-4 border rounded cursor-pointer transition-colors ${selectedService?.id === service.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-300'}`}
|
||||
<div
|
||||
key={service.id}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedService?.id === service.id
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 dark:border-indigo-400'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 bg-white dark:bg-gray-700/50'
|
||||
}`}
|
||||
onClick={() => setSelectedService(service)}
|
||||
>
|
||||
<h3 className="font-semibold">{service.name}</h3>
|
||||
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{service.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={!selectedService}
|
||||
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
className="w-full py-3 px-4 rounded-lg bg-indigo-600 dark:bg-indigo-500 text-white font-semibold disabled:opacity-50 hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
|
||||
113
frontend/src/components/booking/Confirmation.tsx
Normal file
113
frontend/src/components/booking/Confirmation.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle, Calendar, MapPin, ArrowRight } from 'lucide-react';
|
||||
import { PublicService } from '../../hooks/useBooking';
|
||||
import { User } from './AuthSection';
|
||||
|
||||
interface BookingState {
|
||||
step: number;
|
||||
service: PublicService | null;
|
||||
date: Date | null;
|
||||
timeSlot: string | null;
|
||||
user: User | null;
|
||||
paymentMethod: string | null;
|
||||
}
|
||||
|
||||
interface ConfirmationProps {
|
||||
booking: BookingState;
|
||||
}
|
||||
|
||||
export const Confirmation: React.FC<ConfirmationProps> = ({ booking }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!booking.service || !booking.date || !booking.timeSlot) return null;
|
||||
|
||||
// Generate a pseudo-random booking reference based on timestamp
|
||||
const bookingRef = `BK-${Date.now().toString().slice(-6)}`;
|
||||
|
||||
return (
|
||||
<div className="text-center max-w-2xl mx-auto py-10">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="h-24 w-24 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">Booking Confirmed!</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
|
||||
Thank you, {booking.user?.name}. Your appointment has been successfully scheduled.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden text-left">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Booking Details</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Ref: #{bookingRef}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start">
|
||||
<div className="h-12 w-12 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center flex-shrink-0 mr-4">
|
||||
{booking.service.photos && booking.service.photos.length > 0 ? (
|
||||
<img src={booking.service.photos[0]} className="h-12 w-12 rounded-lg object-cover" alt="" />
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-lg bg-indigo-200 dark:bg-indigo-800" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{booking.service.name}</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{booking.service.duration} minutes</p>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<p className="font-medium text-gray-900 dark:text-white">${(booking.service.price_cents / 100).toFixed(2)}</p>
|
||||
{booking.service.deposit_amount_cents && booking.service.deposit_amount_cents > 0 && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Deposit Paid</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 pt-4 flex flex-col sm:flex-row sm:justify-between gap-4">
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<Calendar className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Date & Time</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{booking.date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} at {booking.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<MapPin className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Location</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">See confirmation email</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
A confirmation email has been sent to {booking.user?.email}.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex justify-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors shadow-lg"
|
||||
>
|
||||
Done
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Clear booking state and start fresh
|
||||
sessionStorage.removeItem('booking_state');
|
||||
navigate('/book');
|
||||
}}
|
||||
className="px-6 py-3 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Book Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2, XCircle } from 'lucide-react';
|
||||
import { usePublicAvailability, usePublicBusinessHours } from '../../hooks/useBooking';
|
||||
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../utils/dateUtils';
|
||||
|
||||
interface DateTimeSelectionProps {
|
||||
serviceId?: number;
|
||||
selectedDate: Date | null;
|
||||
selectedTimeSlot: string | null;
|
||||
onDateChange: (date: Date) => void;
|
||||
onTimeChange: (time: string) => void;
|
||||
}
|
||||
|
||||
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
|
||||
serviceId,
|
||||
selectedDate,
|
||||
selectedTimeSlot,
|
||||
onDateChange,
|
||||
onTimeChange
|
||||
}) => {
|
||||
const today = new Date();
|
||||
const [currentMonth, setCurrentMonth] = React.useState(today.getMonth());
|
||||
const [currentYear, setCurrentYear] = React.useState(today.getFullYear());
|
||||
|
||||
// Calculate date range for business hours query (current month view)
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const start = new Date(currentYear, currentMonth, 1);
|
||||
const end = new Date(currentYear, currentMonth + 1, 0);
|
||||
return {
|
||||
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
|
||||
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
|
||||
};
|
||||
}, [currentMonth, currentYear]);
|
||||
|
||||
// Fetch business hours for the month
|
||||
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
|
||||
|
||||
// Create a map of dates to their open status
|
||||
const openDaysMap = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
if (businessHours?.dates) {
|
||||
businessHours.dates.forEach(day => {
|
||||
map.set(day.date, day.is_open);
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [businessHours]);
|
||||
|
||||
// Format selected date for API query (YYYY-MM-DD)
|
||||
const dateString = selectedDate
|
||||
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
|
||||
: undefined;
|
||||
|
||||
// Fetch availability when both serviceId and date are set
|
||||
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
|
||||
|
||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentMonth(11);
|
||||
setCurrentYear(currentYear - 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentMonth(0);
|
||||
setCurrentYear(currentYear + 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
return selectedDate?.getDate() === day &&
|
||||
selectedDate?.getMonth() === currentMonth &&
|
||||
selectedDate?.getFullYear() === currentYear;
|
||||
};
|
||||
|
||||
const isPast = (day: number) => {
|
||||
const d = new Date(currentYear, currentMonth, day);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return d < now;
|
||||
};
|
||||
|
||||
const isClosed = (day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
// If we have business hours data, use it. Otherwise default to open (except past dates)
|
||||
if (openDaysMap.size > 0) {
|
||||
return openDaysMap.get(dateStr) === false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
return isPast(day) || isClosed(day);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Calendar Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
|
||||
Select Date
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="font-medium text-gray-900 dark:text-white w-32 text-center">
|
||||
{monthName} {currentYear}
|
||||
</span>
|
||||
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2 mb-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
|
||||
</div>
|
||||
|
||||
{businessHoursLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const past = isPast(day);
|
||||
const closed = isClosed(day);
|
||||
const disabled = isDisabled(day);
|
||||
const selected = isSelected(day);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentYear, currentMonth, day);
|
||||
onDateChange(newDate);
|
||||
}}
|
||||
className={`
|
||||
h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium transition-all relative
|
||||
${selected
|
||||
? 'bg-indigo-600 dark:bg-indigo-500 text-white shadow-md'
|
||||
: closed
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: past
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400'
|
||||
}
|
||||
`}
|
||||
title={closed ? 'Business closed' : past ? 'Past date' : undefined}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-100 dark:bg-gray-700"></div>
|
||||
<span>Closed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-indigo-600 dark:bg-indigo-500"></div>
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slots Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Available Time Slots</h3>
|
||||
{!selectedDate ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
Please select a date first
|
||||
</div>
|
||||
) : availabilityLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-red-500 dark:text-red-400">
|
||||
<XCircle className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium">Failed to load availability</p>
|
||||
<p className="text-sm mt-1 text-gray-500 dark:text-gray-400">
|
||||
{error instanceof Error ? error.message : 'Please try again'}
|
||||
</p>
|
||||
</div>
|
||||
) : availability?.is_open === false ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<XCircle className="w-12 h-12 mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p className="font-medium">Business Closed</p>
|
||||
<p className="text-sm mt-1">Please select another date</p>
|
||||
</div>
|
||||
) : availability?.slots && availability.slots.length > 0 ? (
|
||||
<>
|
||||
{(() => {
|
||||
// Determine which timezone to display based on business settings
|
||||
const displayTimezone = availability.timezone_display_mode === 'viewer'
|
||||
? getUserTimezone()
|
||||
: availability.business_timezone || getUserTimezone();
|
||||
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
{availability.business_hours && (
|
||||
<>Business hours: {availability.business_hours.start} - {availability.business_hours.end} • </>
|
||||
)}
|
||||
Times shown in {tzAbbrev}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availability.slots.map((slot) => {
|
||||
// Format time in the appropriate timezone
|
||||
const displayTime = formatTimeForDisplay(
|
||||
slot.time,
|
||||
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slot.time}
|
||||
disabled={!slot.available}
|
||||
onClick={() => onTimeChange(displayTime)}
|
||||
className={`
|
||||
py-3 px-4 rounded-lg text-sm font-medium border transition-all duration-200
|
||||
${!slot.available
|
||||
? 'bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-600 cursor-not-allowed'
|
||||
: selectedTimeSlot === displayTime
|
||||
? 'bg-indigo-600 dark:bg-indigo-500 text-white border-indigo-600 dark:border-indigo-500 shadow-sm'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{displayTime}
|
||||
{!slot.available && <span className="block text-[10px] font-normal">Booked</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : !serviceId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
Please select a service first
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
No available time slots for this date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
134
frontend/src/components/booking/GeminiChat.tsx
Normal file
134
frontend/src/components/booking/GeminiChat.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageCircle, X, Send, Sparkles } from 'lucide-react';
|
||||
import { BookingState, ChatMessage } from './types';
|
||||
// TODO: Implement Gemini service
|
||||
const sendMessageToGemini = async (message: string, bookingState: BookingState): Promise<string> => {
|
||||
// Mock implementation - replace with actual Gemini API call
|
||||
return "I'm here to help you book your appointment. Please use the booking form above.";
|
||||
};
|
||||
|
||||
interface GeminiChatProps {
|
||||
currentBookingState: BookingState;
|
||||
}
|
||||
|
||||
export const GeminiChat: React.FC<GeminiChatProps> = ({ currentBookingState }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'model', text: 'Hi! I can help you choose a service or answer questions about booking.' }
|
||||
]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', text: inputText };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const responseText = await sendMessageToGemini(inputText, messages, currentBookingState);
|
||||
setMessages(prev => [...prev, { role: 'model', text: responseText }]);
|
||||
} catch (error) {
|
||||
setMessages(prev => [...prev, { role: 'model', text: "Sorry, I'm having trouble connecting." }]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||
{/* Chat Window */}
|
||||
{isOpen && (
|
||||
<div className="bg-white w-80 sm:w-96 h-[500px] rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden mb-4 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
||||
<div className="bg-indigo-600 p-4 flex justify-between items-center text-white">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="font-semibold">Lumina Assistant</span>
|
||||
</div>
|
||||
<button onClick={() => setIsOpen(false)} className="hover:bg-indigo-500 rounded-full p-1 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 scrollbar-hide">
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
max-w-[80%] px-4 py-2 rounded-2xl text-sm
|
||||
${msg.role === 'user'
|
||||
? 'bg-indigo-600 text-white rounded-br-none'
|
||||
: 'bg-white text-gray-800 border border-gray-200 shadow-sm rounded-bl-none'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white px-4 py-2 rounded-2xl rounded-bl-none border border-gray-200 shadow-sm">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white border-t border-gray-100">
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Ask about services..."
|
||||
className="flex-1 px-4 py-2 rounded-full border border-gray-300 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !inputText.trim()}
|
||||
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`
|
||||
p-4 rounded-full shadow-xl transition-all duration-300 flex items-center justify-center
|
||||
${isOpen ? 'bg-gray-800 rotate-90 scale-0' : 'bg-indigo-600 hover:bg-indigo-700 scale-100'}
|
||||
`}
|
||||
style={{display: isOpen ? 'none' : 'flex'}}
|
||||
>
|
||||
<MessageCircle className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
159
frontend/src/components/booking/PaymentSection.tsx
Normal file
159
frontend/src/components/booking/PaymentSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PublicService } from '../../hooks/useBooking';
|
||||
import { CreditCard, ShieldCheck, Lock } from 'lucide-react';
|
||||
|
||||
interface PaymentSectionProps {
|
||||
service: PublicService;
|
||||
onPaymentComplete: () => void;
|
||||
}
|
||||
|
||||
export const PaymentSection: React.FC<PaymentSectionProps> = ({ service, onPaymentComplete }) => {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [cardNumber, setCardNumber] = useState('');
|
||||
const [expiry, setExpiry] = useState('');
|
||||
const [cvc, setCvc] = useState('');
|
||||
|
||||
// Convert cents to dollars
|
||||
const price = service.price_cents / 100;
|
||||
const deposit = (service.deposit_amount_cents || 0) / 100;
|
||||
|
||||
// Auto-format card number
|
||||
const handleCardInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let val = e.target.value.replace(/\D/g, '');
|
||||
val = val.substring(0, 16);
|
||||
val = val.replace(/(\d{4})/g, '$1 ').trim();
|
||||
setCardNumber(val);
|
||||
};
|
||||
|
||||
const handlePayment = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProcessing(true);
|
||||
|
||||
// Simulate Stripe Payment Intent & Processing
|
||||
setTimeout(() => {
|
||||
setProcessing(false);
|
||||
onPaymentComplete();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Payment Details Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div 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-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<CreditCard className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
|
||||
Card Details
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
{/* Mock Card Icons */}
|
||||
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="payment-form" onSubmit={handlePayment} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Number</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={cardNumber}
|
||||
onChange={handleCardInput}
|
||||
placeholder="0000 0000 0000 0000"
|
||||
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expiry Date</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
placeholder="MM / YY"
|
||||
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CVC</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={cvc}
|
||||
onChange={(e) => setCvc(e.target.value)}
|
||||
placeholder="123"
|
||||
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
||||
/>
|
||||
<Lock className="w-4 h-4 text-gray-400 dark:text-gray-500 absolute right-3 top-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg">
|
||||
<ShieldCheck className="w-5 h-5 text-indigo-600 dark:text-indigo-400 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<p className="text-sm text-indigo-800 dark:text-indigo-200">
|
||||
Your payment is secure. We use Stripe to process your payment. {deposit > 0 ? <>A deposit of <strong>${deposit.toFixed(2)}</strong> will be charged now.</> : <>Full payment will be collected at your appointment.</>}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Column */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 sticky top-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Payment Summary</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Service Total</span>
|
||||
<span>${price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Tax (Estimated)</span>
|
||||
<span>$0.00</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 my-2 pt-2"></div>
|
||||
<div className="flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white">
|
||||
<span>Total</span>
|
||||
<span>${price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deposit > 0 ? (
|
||||
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Due Now (Deposit)</span>
|
||||
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${deposit.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Due at appointment</span>
|
||||
<span>${(price - deposit).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Due at appointment</span>
|
||||
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
form="payment-form"
|
||||
disabled={processing}
|
||||
className="w-full mt-6 py-3 px-4 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-semibold shadow-md hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-75 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{processing ? 'Processing...' : deposit > 0 ? `Pay $${deposit.toFixed(2)} Deposit` : 'Confirm Booking'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
frontend/src/components/booking/ServiceSelection.tsx
Normal file
114
frontend/src/components/booking/ServiceSelection.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Clock, DollarSign, Loader2 } from 'lucide-react';
|
||||
import { usePublicServices, usePublicBusinessInfo, PublicService } from '../../hooks/useBooking';
|
||||
|
||||
interface ServiceSelectionProps {
|
||||
selectedService: PublicService | null;
|
||||
onSelect: (service: PublicService) => void;
|
||||
}
|
||||
|
||||
export const ServiceSelection: React.FC<ServiceSelectionProps> = ({ selectedService, onSelect }) => {
|
||||
const { data: services, isLoading: servicesLoading } = usePublicServices();
|
||||
const { data: businessInfo, isLoading: businessLoading } = usePublicBusinessInfo();
|
||||
|
||||
const isLoading = servicesLoading || businessLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const heading = businessInfo?.service_selection_heading || 'Choose your experience';
|
||||
const subheading = businessInfo?.service_selection_subheading || 'Select a service to begin your booking.';
|
||||
|
||||
// Get first photo as image, or use a placeholder
|
||||
const getServiceImage = (service: PublicService): string | null => {
|
||||
if (service.photos && service.photos.length > 0) {
|
||||
return service.photos[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Format price from cents to dollars
|
||||
const formatPrice = (cents: number): string => {
|
||||
return (cents / 100).toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{heading}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">{subheading}</p>
|
||||
</div>
|
||||
|
||||
{(!services || services.length === 0) && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No services available at this time.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{services?.map((service) => {
|
||||
const image = getServiceImage(service);
|
||||
const hasImage = !!image;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
onClick={() => onSelect(service)}
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border-2 transition-all duration-200 cursor-pointer group
|
||||
${selectedService?.id === service.id
|
||||
? 'border-indigo-600 dark:border-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/20 ring-2 ring-indigo-600 dark:ring-indigo-400 ring-offset-2 dark:ring-offset-gray-900'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg bg-white dark:bg-gray-800'}
|
||||
`}
|
||||
>
|
||||
<div className="flex h-full min-h-[140px]">
|
||||
{hasImage && (
|
||||
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={service.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{service.name}
|
||||
</h3>
|
||||
{service.description && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4 mr-1.5" />
|
||||
{service.duration} mins
|
||||
</div>
|
||||
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{formatPrice(service.price_cents)}
|
||||
</div>
|
||||
</div>
|
||||
{service.deposit_amount_cents && service.deposit_amount_cents > 0 && (
|
||||
<div className="mt-2 text-xs text-indigo-600 dark:text-indigo-400 font-medium">
|
||||
Deposit required: ${formatPrice(service.deposit_amount_cents)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
frontend/src/components/booking/Steps.tsx
Normal file
61
frontend/src/components/booking/Steps.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface StepsProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ id: 1, name: 'Service' },
|
||||
{ id: 2, name: 'Date & Time' },
|
||||
{ id: 3, name: 'Account' },
|
||||
{ id: 4, name: 'Payment' },
|
||||
{ id: 5, name: 'Done' },
|
||||
];
|
||||
|
||||
export const Steps: React.FC<StepsProps> = ({ currentStep }) => {
|
||||
return (
|
||||
<nav aria-label="Progress">
|
||||
<ol role="list" className="flex items-center">
|
||||
{steps.map((step, stepIdx) => (
|
||||
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pr-8 sm:pr-20' : ''} relative`}>
|
||||
{step.id < currentStep ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-indigo-600 dark:bg-indigo-500" />
|
||||
</div>
|
||||
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
|
||||
<Check className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
<span className="sr-only">{step.name}</span>
|
||||
</a>
|
||||
</>
|
||||
) : step.id === currentStep ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800" aria-current="step">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-indigo-600 dark:bg-indigo-400" aria-hidden="true" />
|
||||
<span className="sr-only">{step.name}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<a href="#" className="group relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-gray-600" aria-hidden="true" />
|
||||
<span className="sr-only">{step.name}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2 w-max text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{step.name}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
61
frontend/src/components/booking/constants.ts
Normal file
61
frontend/src/components/booking/constants.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Service, TimeSlot } from './types';
|
||||
|
||||
// Mock services for booking flow
|
||||
// TODO: In production, these should be fetched from the API
|
||||
export const SERVICES: Service[] = [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'Rejuvenating Facial',
|
||||
description: 'A 60-minute deep cleansing and hydrating facial treatment.',
|
||||
durationMin: 60,
|
||||
price: 120,
|
||||
deposit: 30,
|
||||
category: 'Skincare',
|
||||
image: 'https://picsum.photos/400/300?random=1'
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
name: 'Deep Tissue Massage',
|
||||
description: 'Therapeutic massage focusing on realigning deeper layers of muscles.',
|
||||
durationMin: 90,
|
||||
price: 150,
|
||||
deposit: 50,
|
||||
category: 'Massage',
|
||||
image: 'https://picsum.photos/400/300?random=2'
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
name: 'Executive Haircut',
|
||||
description: 'Precision haircut with wash, style, and hot towel finish.',
|
||||
durationMin: 45,
|
||||
price: 65,
|
||||
deposit: 15,
|
||||
category: 'Hair',
|
||||
image: 'https://picsum.photos/400/300?random=3'
|
||||
},
|
||||
{
|
||||
id: 's4',
|
||||
name: 'Full Body Scrub',
|
||||
description: 'Exfoliating treatment to remove dead skin cells and improve circulation.',
|
||||
durationMin: 60,
|
||||
price: 110,
|
||||
deposit: 25,
|
||||
category: 'Body',
|
||||
image: 'https://picsum.photos/400/300?random=4'
|
||||
}
|
||||
];
|
||||
|
||||
// Mock time slots
|
||||
// TODO: In production, these should be fetched from the availability API
|
||||
export const TIME_SLOTS: TimeSlot[] = [
|
||||
{ id: 't1', time: '09:00 AM', available: true },
|
||||
{ id: 't2', time: '10:00 AM', available: true },
|
||||
{ id: 't3', time: '11:00 AM', available: false },
|
||||
{ id: 't4', time: '01:00 PM', available: true },
|
||||
{ id: 't5', time: '02:00 PM', available: true },
|
||||
{ id: 't6', time: '03:00 PM', available: true },
|
||||
{ id: 't7', time: '04:00 PM', available: false },
|
||||
{ id: 't8', time: '05:00 PM', available: true },
|
||||
];
|
||||
|
||||
export const APP_NAME = "SmoothSchedule";
|
||||
36
frontend/src/components/booking/types.ts
Normal file
36
frontend/src/components/booking/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
durationMin: number;
|
||||
price: number;
|
||||
deposit: number;
|
||||
image: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string;
|
||||
time: string; // "09:00 AM"
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface BookingState {
|
||||
step: number;
|
||||
service: Service | null;
|
||||
date: Date | null;
|
||||
timeSlot: string | null;
|
||||
user: User | null;
|
||||
paymentMethod: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Clock,
|
||||
MapPin,
|
||||
User,
|
||||
Calendar,
|
||||
import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Image as ImageIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Service, Business } from '../../types';
|
||||
import Card from '../ui/Card';
|
||||
import Badge from '../ui/Badge';
|
||||
|
||||
interface CustomerPreviewProps {
|
||||
@@ -33,23 +31,22 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
|
||||
name: previewData?.name || service?.name || 'New Service',
|
||||
description: previewData?.description || service?.description || 'Service description will appear here...',
|
||||
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
|
||||
photos: previewData?.photos ?? service?.photos ?? [],
|
||||
};
|
||||
|
||||
// Get the first photo for the cover image
|
||||
const coverPhoto = data.photos && data.photos.length > 0 ? data.photos[0] : null;
|
||||
|
||||
const formatPrice = (price: number | string) => {
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(numPrice);
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -59,82 +56,86 @@ export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
|
||||
<Badge variant="info" size="sm">Live Preview</Badge>
|
||||
</div>
|
||||
|
||||
{/* Booking Page Card Simulation */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden transform transition-all hover:scale-[1.02]">
|
||||
{/* Cover Image Placeholder */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center relative"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-400, ${business.secondaryColor}))`,
|
||||
opacity: 0.9
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-white/20 font-bold text-4xl select-none">
|
||||
{data.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white leading-tight mb-1">
|
||||
{data.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Clock size={14} />
|
||||
<span>{formatDuration(data.durationMinutes)}</span>
|
||||
<span>•</span>
|
||||
<span>{data.category?.name || 'General'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-brand-600 dark:text-brand-400">
|
||||
{data.variable_pricing ? (
|
||||
'Variable'
|
||||
) : (
|
||||
formatPrice(data.price)
|
||||
)}
|
||||
</div>
|
||||
{data.deposit_amount && data.deposit_amount > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatPrice(data.deposit_amount)} deposit
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-6">
|
||||
{data.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="p-1.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 size={14} />
|
||||
</div>
|
||||
<span>Online booking available</span>
|
||||
</div>
|
||||
|
||||
{(data.resource_ids?.length || 0) > 0 && !data.all_resources && (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="p-1.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<span>Specific staff only</span>
|
||||
{/* Lumina-style Horizontal Card */}
|
||||
<div className="relative overflow-hidden rounded-xl border-2 border-brand-600 bg-brand-50/50 dark:bg-brand-900/20 ring-2 ring-brand-600 ring-offset-2 dark:ring-offset-gray-900 transition-all duration-200">
|
||||
<div className="flex h-full min-h-[180px]">
|
||||
{/* Image Section - 1/3 width */}
|
||||
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
|
||||
{coverPhoto ? (
|
||||
<img
|
||||
src={coverPhoto}
|
||||
alt={data.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor || '#2563eb'}), var(--color-brand-400, ${business.secondaryColor || '#0ea5e9'}))`
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="w-12 h-12 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button className="w-full py-2.5 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-xl transition-colors shadow-sm shadow-brand-200 dark:shadow-none">
|
||||
Book Now
|
||||
</button>
|
||||
{/* Content Section - 2/3 width */}
|
||||
<div className="w-2/3 p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
{/* Category Badge */}
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/50 px-2.5 py-0.5 text-xs font-medium text-brand-800 dark:text-brand-300">
|
||||
{data.category?.name || 'General'}
|
||||
</span>
|
||||
{data.variable_pricing && (
|
||||
<span className="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:text-purple-300">
|
||||
Variable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{data.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom Info */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<Clock className="w-4 h-4 mr-1.5" />
|
||||
{data.durationMinutes} mins
|
||||
</div>
|
||||
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
|
||||
{data.variable_pricing ? (
|
||||
<span className="text-purple-600 dark:text-purple-400">Price varies</span>
|
||||
) : (
|
||||
<>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{data.price}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deposit Info */}
|
||||
{((data.deposit_amount && data.deposit_amount > 0) || (data.variable_pricing && data.deposit_amount)) && (
|
||||
<div className="mt-2 text-xs text-brand-600 dark:text-brand-400 font-medium">
|
||||
Deposit required: {formatPrice(data.deposit_amount || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
|
||||
<AlertCircle size={20} className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { BlockedDate, BlockType } from '../../types';
|
||||
import { BlockedDate, BlockType, BlockPurpose } from '../../types';
|
||||
|
||||
interface TimeBlockCalendarOverlayProps {
|
||||
blockedDates: BlockedDate[];
|
||||
@@ -126,61 +126,46 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
return overlays;
|
||||
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
|
||||
|
||||
const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
|
||||
const getBlockStyle = (blockType: BlockType, purpose: BlockPurpose, isBusinessLevel: boolean): React.CSSProperties => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'default',
|
||||
zIndex: 5, // Ensure overlays are visible above grid lines
|
||||
};
|
||||
|
||||
// Business-level blocks (including business hours): Simple gray background
|
||||
// No fancy styling - just indicates "not available for booking"
|
||||
if (isBusinessLevel) {
|
||||
// Business blocks: Red (hard) / Amber (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(239, 68, 68, 0.3),
|
||||
rgba(239, 68, 68, 0.3) 5px,
|
||||
rgba(239, 68, 68, 0.5) 5px,
|
||||
rgba(239, 68, 68, 0.5) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(251, 191, 36, 0.2)',
|
||||
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(107, 114, 128, 0.25)', // Gray-500 at 25% opacity (more visible)
|
||||
};
|
||||
}
|
||||
|
||||
// Resource-level blocks: Purple (hard) / Cyan (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(147, 51, 234, 0.25),
|
||||
rgba(147, 51, 234, 0.25) 5px,
|
||||
rgba(147, 51, 234, 0.4) 5px,
|
||||
rgba(147, 51, 234, 0.4) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
};
|
||||
} else {
|
||||
// Resource blocks: Purple (hard) / Cyan (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(147, 51, 234, 0.25),
|
||||
rgba(147, 51, 234, 0.25) 5px,
|
||||
rgba(147, 51, 234, 0.4) 5px,
|
||||
rgba(147, 51, 234, 0.4) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(6, 182, 212, 0.15)',
|
||||
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(6, 182, 212, 0.15)',
|
||||
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,7 +193,7 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
<>
|
||||
{blockOverlays.map((overlay, index) => {
|
||||
const isBusinessLevel = overlay.block.resource_id === null;
|
||||
const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
|
||||
const style = getBlockStyle(overlay.block.block_type, overlay.block.purpose, isBusinessLevel);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -224,14 +209,12 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
||||
>
|
||||
{/* Block level indicator */}
|
||||
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
|
||||
isBusinessLevel
|
||||
? 'bg-red-600'
|
||||
: 'bg-purple-600'
|
||||
}`}>
|
||||
{isBusinessLevel ? 'B' : 'R'}
|
||||
</div>
|
||||
{/* Only show badge for resource-level blocks */}
|
||||
{!isBusinessLevel && (
|
||||
<div className="absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide bg-purple-600">
|
||||
R
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface CurrencyInputProps {
|
||||
value: number; // Value in cents (integer)
|
||||
@@ -12,15 +12,15 @@ interface CurrencyInputProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* ATM-style currency input where digits are entered as cents.
|
||||
* As more digits are entered, they shift from cents to dollars.
|
||||
* Only accepts integer values (digits 0-9).
|
||||
* Currency input where digits represent cents.
|
||||
* Only accepts integer input (0-9), no decimal points.
|
||||
* Allows normal text selection and editing.
|
||||
*
|
||||
* Example: typing "1234" displays "$12.34"
|
||||
* - Type "1" → $0.01
|
||||
* - Type "2" → $0.12
|
||||
* - Type "3" → $1.23
|
||||
* - Type "4" → $12.34
|
||||
* Examples:
|
||||
* - Type "5" → $0.05
|
||||
* - Type "50" → $0.50
|
||||
* - Type "500" → $5.00
|
||||
* - Type "1234" → $12.34
|
||||
*/
|
||||
const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||
value,
|
||||
@@ -33,128 +33,110 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||
max,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Ensure value is always an integer
|
||||
const safeValue = Math.floor(Math.abs(value)) || 0;
|
||||
const [displayValue, setDisplayValue] = useState('');
|
||||
|
||||
// Format cents as dollars string (e.g., 1234 → "$12.34")
|
||||
const formatCentsAsDollars = (cents: number): string => {
|
||||
if (cents === 0 && !isFocused) return '';
|
||||
if (cents === 0) return '';
|
||||
const dollars = cents / 100;
|
||||
return `$${dollars.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
|
||||
|
||||
// Process a new digit being added
|
||||
const addDigit = (digit: number) => {
|
||||
let newValue = safeValue * 10 + digit;
|
||||
|
||||
// Enforce max if specified
|
||||
if (max !== undefined && newValue > max) {
|
||||
newValue = max;
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
// Extract just the digits from a string
|
||||
const extractDigits = (str: string): string => {
|
||||
return str.replace(/\D/g, '');
|
||||
};
|
||||
|
||||
// Remove the last digit
|
||||
const removeDigit = () => {
|
||||
const newValue = Math.floor(safeValue / 10);
|
||||
onChange(newValue);
|
||||
// Sync display value when external value changes
|
||||
useEffect(() => {
|
||||
setDisplayValue(formatCentsAsDollars(value));
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target.value;
|
||||
|
||||
// Extract only digits
|
||||
const digits = extractDigits(input);
|
||||
|
||||
// Convert to cents (the digits ARE the cents value)
|
||||
let cents = digits ? parseInt(digits, 10) : 0;
|
||||
|
||||
// Enforce max if specified
|
||||
if (max !== undefined && cents > max) {
|
||||
cents = max;
|
||||
}
|
||||
|
||||
onChange(cents);
|
||||
|
||||
// Update display immediately with formatted value
|
||||
setDisplayValue(formatCentsAsDollars(cents));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Allow navigation keys without preventing default
|
||||
if (
|
||||
e.key === 'Tab' ||
|
||||
e.key === 'Escape' ||
|
||||
e.key === 'Enter' ||
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'Home' ||
|
||||
e.key === 'End'
|
||||
) {
|
||||
return;
|
||||
// Allow: navigation, selection, delete, backspace, tab, escape, enter
|
||||
const allowedKeys = [
|
||||
'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
|
||||
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
|
||||
'Home', 'End'
|
||||
];
|
||||
|
||||
if (allowedKeys.includes(e.key)) {
|
||||
return; // Let these through
|
||||
}
|
||||
|
||||
// Handle backspace/delete
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
removeDigit();
|
||||
// Allow Ctrl/Cmd + A, C, V, X (select all, copy, paste, cut)
|
||||
if ((e.ctrlKey || e.metaKey) && ['a', 'c', 'v', 'x'].includes(e.key.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow digits 0-9
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
if (!/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
addDigit(parseInt(e.key, 10));
|
||||
return;
|
||||
}
|
||||
|
||||
// Block everything else
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Catch input from mobile keyboards, IME, voice input, etc.
|
||||
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const inputEvent = e.nativeEvent as InputEvent;
|
||||
const data = inputEvent.data;
|
||||
|
||||
// Always prevent default - we handle all input ourselves
|
||||
e.preventDefault();
|
||||
|
||||
if (!data) return;
|
||||
|
||||
// Extract only digits from the input
|
||||
const digits = data.replace(/\D/g, '');
|
||||
|
||||
// Add each digit one at a time
|
||||
for (const char of digits) {
|
||||
addDigit(parseInt(char, 10));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
// Select all text for easy replacement
|
||||
setTimeout(() => {
|
||||
e.target.select();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
// Extract digits and reparse to enforce constraints
|
||||
const digits = extractDigits(displayValue);
|
||||
let cents = digits ? parseInt(digits, 10) : 0;
|
||||
|
||||
// Enforce min on blur if specified
|
||||
if (min !== undefined && safeValue < min && safeValue > 0) {
|
||||
onChange(min);
|
||||
if (min !== undefined && cents < min && cents > 0) {
|
||||
cents = min;
|
||||
onChange(cents);
|
||||
}
|
||||
|
||||
// Enforce max on blur if specified
|
||||
if (max !== undefined && cents > max) {
|
||||
cents = max;
|
||||
onChange(cents);
|
||||
}
|
||||
|
||||
// Reformat display
|
||||
setDisplayValue(formatCentsAsDollars(cents));
|
||||
};
|
||||
|
||||
// Handle paste - extract digits only
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
const digits = pastedText.replace(/\D/g, '');
|
||||
const digits = extractDigits(pastedText);
|
||||
|
||||
if (digits) {
|
||||
let newValue = parseInt(digits, 10);
|
||||
if (max !== undefined && newValue > max) {
|
||||
newValue = max;
|
||||
}
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
let cents = parseInt(digits, 10);
|
||||
|
||||
// Handle drop - extract digits only
|
||||
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const droppedText = e.dataTransfer.getData('text');
|
||||
const digits = droppedText.replace(/\D/g, '');
|
||||
|
||||
if (digits) {
|
||||
let newValue = parseInt(digits, 10);
|
||||
if (max !== undefined && newValue > max) {
|
||||
newValue = max;
|
||||
if (max !== undefined && cents > max) {
|
||||
cents = max;
|
||||
}
|
||||
onChange(newValue);
|
||||
|
||||
onChange(cents);
|
||||
setDisplayValue(formatCentsAsDollars(cents));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,15 +145,12 @@ const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
|
||||
310
frontend/src/components/ui/lumina.tsx
Normal file
310
frontend/src/components/ui/lumina.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Lumina Design System - Reusable UI Components
|
||||
* Modern, premium design aesthetic with smooth animations and clean styling
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// Button Components
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: LucideIcon;
|
||||
iconPosition?: 'left' | 'right';
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const LuminaButton: React.FC<LuminaButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
iconPosition = 'right',
|
||||
loading = false,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500 shadow-sm',
|
||||
secondary: 'bg-white text-gray-900 border border-gray-300 hover:bg-gray-50 focus:ring-indigo-500',
|
||||
ghost: 'text-indigo-600 hover:bg-indigo-50 focus:ring-indigo-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm rounded-lg',
|
||||
md: 'px-4 py-2.5 text-sm rounded-lg',
|
||||
lg: 'px-6 py-3 text-base rounded-lg',
|
||||
};
|
||||
|
||||
const disabledClasses = 'disabled:opacity-70 disabled:cursor-not-allowed';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">Processing...</span>
|
||||
) : (
|
||||
<>
|
||||
{Icon && iconPosition === 'left' && <Icon className="w-4 h-4 mr-2" />}
|
||||
{children}
|
||||
{Icon && iconPosition === 'right' && <Icon className="w-4 h-4 ml-2" />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Input Components
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export const LuminaInput: React.FC<LuminaInputProps> = ({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
icon: Icon,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className={`block w-full ${Icon ? 'pl-10' : 'pl-3'} pr-3 py-2.5 border ${
|
||||
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'
|
||||
} rounded-lg transition-colors ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 mt-1">{error}</p>}
|
||||
{hint && !error && <p className="text-sm text-gray-500 mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Card Components
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export const LuminaCard: React.FC<LuminaCardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
hover = false,
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
};
|
||||
|
||||
const hoverClasses = hover ? 'hover:shadow-lg hover:-translate-y-0.5 transition-all' : '';
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-2xl shadow-sm border border-gray-100 ${paddingClasses[padding]} ${hoverClasses} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Badge Components
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaBadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export const LuminaBadge: React.FC<LuminaBadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
}) => {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-amber-100 text-amber-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2.5 py-1',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center font-medium rounded-full ${variantClasses[variant]} ${sizeClasses[size]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Section Container
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaSectionProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LuminaSection: React.FC<LuminaSectionProps> = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<section className={`py-16 px-4 sm:px-6 lg:px-8 ${className}`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{(title || subtitle) && (
|
||||
<div className="text-center mb-12">
|
||||
{title && <h2 className="text-3xl font-bold text-gray-900 mb-3">{title}</h2>}
|
||||
{subtitle && <p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Icon Box Component
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaIconBoxProps {
|
||||
icon: LucideIcon;
|
||||
color?: 'indigo' | 'green' | 'amber' | 'red' | 'blue';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const LuminaIconBox: React.FC<LuminaIconBoxProps> = ({
|
||||
icon: Icon,
|
||||
color = 'indigo',
|
||||
size = 'md',
|
||||
}) => {
|
||||
const colorClasses = {
|
||||
indigo: 'bg-indigo-100 text-indigo-600',
|
||||
green: 'bg-green-100 text-green-600',
|
||||
amber: 'bg-amber-100 text-amber-600',
|
||||
red: 'bg-red-100 text-red-600',
|
||||
blue: 'bg-blue-100 text-blue-600',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16',
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'w-5 h-5',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} ${colorClasses[color]} rounded-xl flex items-center justify-center`}>
|
||||
<Icon className={iconSizeClasses[size]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Feature Card Component
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaFeatureCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const LuminaFeatureCard: React.FC<LuminaFeatureCardProps> = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<LuminaCard
|
||||
hover={!!onClick}
|
||||
className={onClick ? 'cursor-pointer' : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<LuminaIconBox icon={icon} size="lg" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<p className="mt-2 text-gray-600">{description}</p>
|
||||
</div>
|
||||
</LuminaCard>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Loading Spinner
|
||||
// ============================================================================
|
||||
|
||||
interface LuminaSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LuminaSpinner: React.FC<LuminaSpinnerProps> = ({
|
||||
size = 'md',
|
||||
className = '',
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600 ${sizeClasses[size]} ${className}`} />
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user