- Landing page with large CTAs and seasonal banner - Multi-step Pool booking wizard with progress bar - Animated confirmation modals with calendar save - Wasserzähler flow with large number input and live consumption - Admin dashboard with today-stats, CSV export, click-to-call - BookingCalendar with skeleton loading and 44px touch targets - Cloudflare Turnstile CAPTCHA on pool form - Supabase auth, RLS, and API routes - Inline form validation, sticky submit buttons - Mobile-first responsive design throughout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
8.0 KiB
TypeScript
215 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { Verfuegbarkeit } from '@/types';
|
|
|
|
interface BookingCalendarProps {
|
|
onDateSelect: (date: string | null) => void;
|
|
selectedDate: string | null;
|
|
}
|
|
|
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
|
const MONTH_NAMES = [
|
|
'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
];
|
|
|
|
function getSaisonRange(year: number) {
|
|
return {
|
|
start: new Date(year, 2, 15),
|
|
end: new Date(year, 5, 30),
|
|
};
|
|
}
|
|
|
|
function formatDate(d: Date): string {
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
export default function BookingCalendar({ onDateSelect, selectedDate }: BookingCalendarProps) {
|
|
const today = new Date();
|
|
const currentYear = today.getFullYear();
|
|
const saison = getSaisonRange(currentYear);
|
|
|
|
const initialMonth = today > saison.start ? today.getMonth() : saison.start.getMonth();
|
|
const [viewMonth, setViewMonth] = useState(initialMonth);
|
|
const [viewYear] = useState(currentYear);
|
|
const [auslastung, setAuslastung] = useState<Record<string, number>>({});
|
|
const [maxPerDay, setMaxPerDay] = useState(5);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const loadVerfuegbarkeit = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/api/pool/verfuegbarkeit?year=${currentYear}`);
|
|
const data = await res.json();
|
|
if (data.verfuegbarkeit) {
|
|
const map: Record<string, number> = {};
|
|
data.verfuegbarkeit.forEach((v: Verfuegbarkeit) => {
|
|
map[v.datum] = v.anzahl_buchungen;
|
|
});
|
|
setAuslastung(map);
|
|
}
|
|
if (data.max_per_day) {
|
|
setMaxPerDay(data.max_per_day);
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [currentYear]);
|
|
|
|
useEffect(() => {
|
|
loadVerfuegbarkeit();
|
|
}, [loadVerfuegbarkeit]);
|
|
|
|
const firstDayOfMonth = new Date(viewYear, viewMonth, 1);
|
|
let startDow = firstDayOfMonth.getDay() - 1;
|
|
if (startDow < 0) startDow = 6;
|
|
|
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
|
|
const days: (Date | null)[] = [];
|
|
for (let i = 0; i < startDow; i++) days.push(null);
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
days.push(new Date(viewYear, viewMonth, d));
|
|
}
|
|
|
|
const canGoBack = viewMonth > saison.start.getMonth();
|
|
const canGoForward = viewMonth < saison.end.getMonth();
|
|
|
|
// Count available days this month
|
|
let availableDays = 0;
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const day = new Date(viewYear, viewMonth, d);
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
tomorrow.setHours(0, 0, 0, 0);
|
|
if (day >= saison.start && day <= saison.end && day >= tomorrow) {
|
|
const dateStr = formatDate(day);
|
|
const count = auslastung[dateStr] || 0;
|
|
if (count < maxPerDay) availableDays++;
|
|
}
|
|
}
|
|
|
|
function getDayStatus(day: Date): 'disabled' | 'full' | 'available' | 'partial' {
|
|
if (day < saison.start || day > saison.end) return 'disabled';
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
tomorrow.setHours(0, 0, 0, 0);
|
|
if (day < tomorrow) return 'disabled';
|
|
|
|
const dateStr = formatDate(day);
|
|
const count = auslastung[dateStr] || 0;
|
|
if (count >= maxPerDay) return 'full';
|
|
if (count >= maxPerDay - 2) return 'partial';
|
|
return 'available';
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-2xl border border-border overflow-hidden">
|
|
{/* Sticky availability header */}
|
|
{!loading && (
|
|
<div className="px-4 py-2.5 bg-bg border-b border-border/50">
|
|
<p className="text-xs text-text-muted text-center">
|
|
<span className="font-semibold text-success">{availableDays}</span> {availableDays === 1 ? 'Tag' : 'Tage'} verfügbar im {MONTH_NAMES[viewMonth]}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4">
|
|
{/* Month navigation */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button
|
|
onClick={() => canGoBack && setViewMonth(viewMonth - 1)}
|
|
disabled={!canGoBack}
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center hover:bg-bg disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
|
|
aria-label="Vorheriger Monat"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<h3 className="font-bold text-primary text-base">
|
|
{MONTH_NAMES[viewMonth]} {viewYear}
|
|
</h3>
|
|
<button
|
|
onClick={() => canGoForward && setViewMonth(viewMonth + 1)}
|
|
disabled={!canGoForward}
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center hover:bg-bg disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
|
|
aria-label="Nächster Monat"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Weekday headers */}
|
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
|
{WEEKDAYS.map((d) => (
|
|
<div key={d} className="text-center text-[11px] font-semibold text-text-muted/60 py-1 uppercase">
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Days grid */}
|
|
{loading ? (
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{Array.from({ length: 35 }).map((_, i) => (
|
|
<div key={i} className="skeleton w-11 h-11 mx-auto" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{days.map((day, i) => {
|
|
if (!day) return <div key={`empty-${i}`} className="w-11 h-11" />;
|
|
|
|
const status = getDayStatus(day);
|
|
const dateStr = formatDate(day);
|
|
const isSelected = selectedDate === dateStr;
|
|
const count = auslastung[dateStr] || 0;
|
|
const freeSlots = maxPerDay - count;
|
|
|
|
return (
|
|
<button
|
|
key={dateStr}
|
|
onClick={() => {
|
|
if (status === 'disabled' || status === 'full') return;
|
|
onDateSelect(isSelected ? null : dateStr);
|
|
}}
|
|
disabled={status === 'disabled' || status === 'full'}
|
|
className={`cal-day relative ${isSelected ? 'cal-selected' : ''} ${status === 'full' ? 'cal-full' : ''} ${status === 'disabled' ? 'cal-disabled' : ''} ${status === 'available' && !isSelected ? 'cal-available' : ''} ${status === 'partial' && !isSelected ? 'cal-partial' : ''}`}
|
|
aria-label={`${day.getDate()}. ${MONTH_NAMES[viewMonth]}, ${status === 'full' ? 'ausgebucht' : freeSlots + ' Plätze frei'}`}
|
|
>
|
|
{day.getDate()}
|
|
{/* Small dot indicator for partial */}
|
|
{status === 'partial' && !isSelected && (
|
|
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-warning" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="flex items-center justify-center gap-5 mt-4 pt-3 border-t border-border/50 text-[11px] text-text-muted">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="w-2.5 h-2.5 rounded-sm bg-success/20 border border-success/30" />
|
|
Frei
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="w-2.5 h-2.5 rounded-sm bg-warning/20 border border-warning/30" />
|
|
Fast voll
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="w-2.5 h-2.5 rounded-sm bg-danger/15 border border-danger/20" />
|
|
Voll
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|