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:
Michael
2026-03-02 21:35:32 +01:00
parent 32411cb27f
commit 39eac91568
22 changed files with 2772 additions and 94 deletions

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}