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>
311 lines
8.5 KiB
TypeScript
311 lines
8.5 KiB
TypeScript
/**
|
|
* 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}`} />
|
|
);
|
|
};
|