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>
231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
/**
|
|
* TimeBlockCalendarOverlay - Renders time block overlays on the scheduler calendar
|
|
*
|
|
* Shows blocked time periods with visual styling:
|
|
* - Hard blocks: Red with diagonal stripes, 50% opacity
|
|
* - Soft blocks: Yellow with dotted border, 30% opacity
|
|
* - Business blocks: Full-width across the lane
|
|
* - Resource blocks: Only on matching resource lane
|
|
*/
|
|
|
|
import React, { useMemo, useState } from 'react';
|
|
import { BlockedDate, BlockType, BlockPurpose } from '../../types';
|
|
|
|
interface TimeBlockCalendarOverlayProps {
|
|
blockedDates: BlockedDate[];
|
|
resourceId: string;
|
|
viewDate: Date;
|
|
zoomLevel: number;
|
|
pixelsPerMinute: number;
|
|
startHour: number;
|
|
dayWidth: number;
|
|
laneHeight: number;
|
|
days: Date[];
|
|
onDayClick?: (day: Date) => void;
|
|
}
|
|
|
|
interface TimeBlockTooltipProps {
|
|
block: BlockedDate;
|
|
position: { x: number; y: number };
|
|
}
|
|
|
|
const TimeBlockTooltip: React.FC<TimeBlockTooltipProps> = ({ block, position }) => {
|
|
return (
|
|
<div
|
|
className="fixed z-[100] px-3 py-2 bg-gray-900 text-white text-sm rounded-lg shadow-lg max-w-xs pointer-events-none"
|
|
style={{
|
|
left: position.x + 10,
|
|
top: position.y - 40,
|
|
}}
|
|
>
|
|
<div className="font-semibold">{block.title}</div>
|
|
<div className="text-xs text-gray-300 mt-1">
|
|
{block.block_type === 'HARD' ? 'Hard Block' : 'Soft Block'}
|
|
{block.all_day ? ' (All Day)' : ` (${block.start_time} - ${block.end_time})`}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
|
blockedDates,
|
|
resourceId,
|
|
viewDate,
|
|
zoomLevel,
|
|
pixelsPerMinute,
|
|
startHour,
|
|
dayWidth,
|
|
laneHeight,
|
|
days,
|
|
onDayClick,
|
|
}) => {
|
|
const [hoveredBlock, setHoveredBlock] = useState<{ block: BlockedDate; position: { x: number; y: number } } | null>(null);
|
|
|
|
// Filter blocks for this resource (includes business-level blocks where resource_id is null)
|
|
const relevantBlocks = useMemo(() => {
|
|
return blockedDates.filter(
|
|
(block) => block.resource_id === null || block.resource_id === resourceId
|
|
);
|
|
}, [blockedDates, resourceId]);
|
|
|
|
// Calculate block positions for each day
|
|
const blockOverlays = useMemo(() => {
|
|
const overlays: Array<{
|
|
block: BlockedDate;
|
|
left: number;
|
|
width: number;
|
|
dayIndex: number;
|
|
}> = [];
|
|
|
|
relevantBlocks.forEach((block) => {
|
|
// Parse date string as local date, not UTC
|
|
// "2025-12-06" should be Dec 6 in local timezone, not UTC
|
|
const [year, month, dayNum] = block.date.split('-').map(Number);
|
|
const blockDate = new Date(year, month - 1, dayNum);
|
|
blockDate.setHours(0, 0, 0, 0);
|
|
|
|
// Find which day this block falls on
|
|
days.forEach((day, dayIndex) => {
|
|
const dayStart = new Date(day);
|
|
dayStart.setHours(0, 0, 0, 0);
|
|
|
|
if (blockDate.getTime() === dayStart.getTime()) {
|
|
let left: number;
|
|
let width: number;
|
|
|
|
if (block.all_day) {
|
|
// Full day block
|
|
left = dayIndex * dayWidth;
|
|
width = dayWidth;
|
|
} else if (block.start_time && block.end_time) {
|
|
// Partial day block
|
|
const [startHours, startMins] = block.start_time.split(':').map(Number);
|
|
const [endHours, endMins] = block.end_time.split(':').map(Number);
|
|
|
|
const startMinutes = (startHours - startHour) * 60 + startMins;
|
|
const endMinutes = (endHours - startHour) * 60 + endMins;
|
|
|
|
left = dayIndex * dayWidth + startMinutes * pixelsPerMinute * zoomLevel;
|
|
width = (endMinutes - startMinutes) * pixelsPerMinute * zoomLevel;
|
|
} else {
|
|
// Default to full day if no times specified
|
|
left = dayIndex * dayWidth;
|
|
width = dayWidth;
|
|
}
|
|
|
|
overlays.push({
|
|
block,
|
|
left,
|
|
width,
|
|
dayIndex,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return overlays;
|
|
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
|
|
|
|
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) {
|
|
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 {
|
|
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)',
|
|
};
|
|
}
|
|
};
|
|
|
|
const handleMouseEnter = (e: React.MouseEvent, block: BlockedDate) => {
|
|
setHoveredBlock({
|
|
block,
|
|
position: { x: e.clientX, y: e.clientY },
|
|
});
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (hoveredBlock) {
|
|
setHoveredBlock({
|
|
...hoveredBlock,
|
|
position: { x: e.clientX, y: e.clientY },
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
setHoveredBlock(null);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{blockOverlays.map((overlay, index) => {
|
|
const isBusinessLevel = overlay.block.resource_id === null;
|
|
const style = getBlockStyle(overlay.block.block_type, overlay.block.purpose, isBusinessLevel);
|
|
|
|
return (
|
|
<div
|
|
key={`${overlay.block.time_block_id}-${overlay.dayIndex}-${index}`}
|
|
style={{
|
|
...style,
|
|
left: overlay.left,
|
|
width: overlay.width,
|
|
cursor: onDayClick ? 'pointer' : 'default',
|
|
}}
|
|
onMouseEnter={(e) => handleMouseEnter(e, overlay.block)}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
|
>
|
|
{/* 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>
|
|
);
|
|
})}
|
|
|
|
{/* Tooltip */}
|
|
{hoveredBlock && (
|
|
<TimeBlockTooltip block={hoveredBlock.block} position={hoveredBlock.position} />
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default TimeBlockCalendarOverlay;
|