GemeindePortal: Full implementation with Apple HIG redesign
- 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>
This commit is contained in:
214
src/components/BookingCalendar.tsx
Normal file
214
src/components/BookingCalendar.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
83
src/components/ConfirmationModal.tsx
Normal file
83
src/components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
details?: { label: string; value: string }[];
|
||||
calendarEvent?: {
|
||||
title: string;
|
||||
date: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function generateCalendarUrl(title: string, dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T08:00:00');
|
||||
const endDate = new Date(dateStr + 'T09:00:00');
|
||||
const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${fmt(date)}/${fmt(endDate)}`;
|
||||
}
|
||||
|
||||
export default function ConfirmationModal({ title, message, onClose, details, calendarEvent, children }: ConfirmationModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-end sm:items-center justify-center z-50 p-0 sm:p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl max-w-md w-full p-6 pb-8 animate-slide-up">
|
||||
{/* Animated Checkmark */}
|
||||
<div className="w-20 h-20 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-5 animate-checkmark-circle">
|
||||
<svg className="w-10 h-10 text-success" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
className="animate-checkmark-draw"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-center mb-1">{title}</h3>
|
||||
<p className="text-text-muted text-center text-sm mb-5">{message}</p>
|
||||
|
||||
{/* Detail Card */}
|
||||
{details && details.length > 0 && (
|
||||
<div className="bg-bg rounded-xl p-4 mb-5 space-y-2">
|
||||
{details.map((d) => (
|
||||
<div key={d.label} className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{d.label}</span>
|
||||
<span className="font-medium">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{/* Calendar Save Button */}
|
||||
{calendarEvent && (
|
||||
<a
|
||||
href={generateCalendarUrl(calendarEvent.title, calendarEvent.date)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 mb-3 border border-border rounded-xl text-sm font-medium text-primary hover:bg-bg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
In Kalender speichern
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full bg-primary text-white py-3.5 rounded-xl font-semibold hover:bg-primary-light transition-colors"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/Footer.tsx
Normal file
18
src/components/Footer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border/50 bg-white">
|
||||
<div className="max-w-2xl mx-auto px-4 py-5 text-center space-y-1">
|
||||
<p className="text-xs text-text-muted">
|
||||
Gemeindeamt Weißkirchen an der Traun
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted/70">
|
||||
<a href="tel:+4372435060" className="hover:text-primary">+43 7243 50600</a>
|
||||
{' | '}
|
||||
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at" className="hover:text-primary">
|
||||
gemeinde@weisskirchen.ooe.gv.at
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
44
src/components/Header.tsx
Normal file
44
src/components/Header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
interface HeaderProps {
|
||||
back?: { href: string; label: string };
|
||||
}
|
||||
|
||||
export default function Header({ back }: HeaderProps) {
|
||||
return (
|
||||
<header className="bg-primary text-white">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3">
|
||||
{back ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={back.href}
|
||||
className="flex items-center gap-1 text-white/80 hover:text-white transition-colors -ml-1 py-1"
|
||||
aria-label={back.label}
|
||||
>
|
||||
<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>
|
||||
<span className="text-sm">{back.label}</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/" className="block">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white/15 backdrop-blur-sm rounded-xl flex items-center justify-center text-lg font-bold shrink-0">
|
||||
W
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold leading-tight truncate">
|
||||
Weißkirchen an der Traun
|
||||
</h1>
|
||||
<p className="text-white/50 text-[11px]">
|
||||
Bürgerportal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
54
src/components/ProgressBar.tsx
Normal file
54
src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
done: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
steps: Step[];
|
||||
}
|
||||
|
||||
export default function ProgressBar({ steps }: ProgressBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 w-full" role="progressbar" aria-label="Fortschritt">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="flex items-center flex-1 last:flex-none">
|
||||
{/* Step indicator */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all ${
|
||||
step.done
|
||||
? 'bg-success text-white'
|
||||
: step.active
|
||||
? 'bg-accent text-white shadow-md shadow-accent/25'
|
||||
: 'bg-border/60 text-text-muted/60'
|
||||
}`}
|
||||
>
|
||||
{step.done ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] mt-1 font-medium ${
|
||||
step.done ? 'text-success' : step.active ? 'text-accent' : 'text-text-muted/50'
|
||||
}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-1.5 rounded-full transition-colors ${
|
||||
step.done ? 'bg-success/40' : 'bg-border/60'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user