Files
smoothschedule/frontend/src/components/time-blocks/TimeBlockCalendarOverlay.tsx
poduck 4a66246708 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>
2025-12-11 20:20:18 -05:00

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;