UI/UX, Design und Accessibility Optimierung

- Farbkontrast: text-muted #64748b → #475569 (WCAG 2.2 AA konform)
- Touch Targets: Kalender-Tage 44px → 48px, Checkbox 20px → 24px
- Formulare: py-3 → py-3.5, aria-required, aria-describedby für Fehler
- Fehler: role="alert" + bg-danger/5 Highlight auf allen Fehlermeldungen
- Modal: role="dialog", aria-modal, Focus Trap, Escape-Taste, Safe Area
- ProgressBar: aria-valuenow/min/max, grössere Labels (10px → 12px)
- Kalender: gap-2, bessere ARIA Labels mit Status, grössere Legende
- Skip Navigation: "Zum Hauptinhalt springen" Link in Layout
- prefers-reduced-motion: alle Animationen deaktiviert
- Placeholder-Kontrast verbessert
- Footer: grössere Schrift, Hover-Underline auf Links
- Header: Focus Ring auf Zurück-Button, aria-hidden auf SVGs
- Error-Seiten: Gemeindeamt-Telefonnummer als Kontakt
- Loading States: "Daten werden geladen..." Text
- CAPTCHA: "Sicherheitsüberprüfung" Label
- Wassermenge: "1 m³ = 1.000 Liter" Hilfetext

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael
2026-03-06 20:23:27 +01:00
parent 4993fbd886
commit dfe4462e10
11 changed files with 168 additions and 68 deletions

View File

@@ -12,7 +12,7 @@
--color-bg: #f8fafc; --color-bg: #f8fafc;
--color-bg-card: #ffffff; --color-bg-card: #ffffff;
--color-text: #1e293b; --color-text: #1e293b;
--color-text-muted: #64748b; --color-text-muted: #475569;
--color-border: #e2e8f0; --color-border: #e2e8f0;
} }
@@ -75,9 +75,9 @@ body {
/* ── Calendar day styles ── */ /* ── Calendar day styles ── */
.cal-day { .cal-day {
@apply w-11 h-11 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all; @apply w-12 h-12 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all;
min-width: 44px; min-width: 48px;
min-height: 44px; min-height: 48px;
} }
.cal-day:hover:not(.cal-disabled):not(.cal-full) { .cal-day:hover:not(.cal-disabled):not(.cal-full) {
@apply bg-accent/15 text-accent; @apply bg-accent/15 text-accent;
@@ -115,3 +115,27 @@ input:focus, select:focus, textarea:focus {
z-index: 10; z-index: 10;
padding-bottom: env(safe-area-inset-bottom, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
} }
/* ── Placeholder contrast ── */
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.animate-checkmark-circle,
.animate-checkmark-draw,
.animate-slide-up,
.animate-fade-in {
animation: none !important;
opacity: 1;
}
.skeleton {
animation: none !important;
}
.cal-day:active:not(.cal-disabled):not(.cal-full) {
transform: none !important;
}
}

View File

@@ -14,6 +14,12 @@ export default function RootLayout({
return ( return (
<html lang="de"> <html lang="de">
<body className="antialiased min-h-screen"> <body className="antialiased min-h-screen">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:bg-primary focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-medium"
>
Zum Hauptinhalt springen
</a>
{children} {children}
</body> </body>
</html> </html>

View File

@@ -24,7 +24,7 @@ export default function Home() {
</div> </div>
</div> </div>
<main className="flex-1 max-w-lg mx-auto px-4 w-full -mt-4"> <main id="main-content" className="flex-1 max-w-lg mx-auto px-4 w-full -mt-4">
{/* Seasonal Banner */} {/* Seasonal Banner */}
{isSaison && ( {isSaison && (
<div className="bg-accent/10 border border-accent/20 rounded-2xl px-4 py-3 mb-5 text-center"> <div className="bg-accent/10 border border-accent/20 rounded-2xl px-4 py-3 mb-5 text-center">
@@ -56,7 +56,7 @@ export default function Home() {
Wunschtermin wählen, in 2 Min. erledigt Wunschtermin wählen, in 2 Min. erledigt
</p> </p>
</div> </div>
<svg className="w-5 h-5 text-text-muted/40 group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-text-muted/40 group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
</Link> </Link>
@@ -76,7 +76,7 @@ export default function Home() {
QR-Code scannen, Zählerstand eingeben QR-Code scannen, Zählerstand eingeben
</p> </p>
</div> </div>
<svg className="w-5 h-5 text-text-muted/40 group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-text-muted/40 group-hover:text-accent group-hover:translate-x-0.5 transition-all shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
</Link> </Link>

View File

@@ -192,7 +192,7 @@ export default function PoolBuchungPage() {
/> />
<Header back={{ href: '/', label: 'Zurück' }} /> <Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-5 w-full"> <main id="main-content" className="flex-1 max-w-lg mx-auto px-4 py-5 w-full">
{/* Progress Bar */} {/* Progress Bar */}
<div className="mb-6 px-2"> <div className="mb-6 px-2">
<ProgressBar steps={steps} /> <ProgressBar steps={steps} />
@@ -247,8 +247,8 @@ export default function PoolBuchungPage() {
{/* Wassermenge bei Ortswasserleitung */} {/* Wassermenge bei Ortswasserleitung */}
{wasserquelle === 'ortswasserleitung' && ( {wasserquelle === 'ortswasserleitung' && (
<div className="animate-fade-in"> <div className="animate-fade-in">
<label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-1.5"> <label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-2">
Geschätzte Wassermenge (m³) <span className="text-danger">*</span> Geschätzte Wassermenge (m³) <span className="text-danger" aria-label="erforderlich">*</span>
</label> </label>
<input <input
id="wassermenge_m3" id="wassermenge_m3"
@@ -258,18 +258,21 @@ export default function PoolBuchungPage() {
onChange={(e) => { setWassermenge(e.target.value); setError(''); }} onChange={(e) => { setWassermenge(e.target.value); setError(''); }}
min="5" min="5"
step="0.5" step="0.5"
className="w-full border border-border rounded-xl px-4 py-3 text-base max-w-[200px]" required
aria-required="true"
aria-describedby="wassermenge_help"
className="w-full border border-border rounded-xl px-4 py-3.5 text-base max-w-[200px]"
placeholder="z.B. 25" placeholder="z.B. 25"
/> />
<p className="text-xs text-text-muted mt-1.5"> <p id="wassermenge_help" className="text-xs text-text-muted mt-1.5">
Die Kapazität ist pro Tag begrenzt. Anhand Ihrer Menge wird die Verfügbarkeit berechnet. 1 m³ = 1.000 Liter. Die Kapazität ist pro Tag begrenzt.
</p> </p>
</div> </div>
)} )}
</div> </div>
{error && ( {error && (
<div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm"> <div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm" role="alert" aria-live="polite">
{error} {error}
</div> </div>
)} )}
@@ -329,7 +332,7 @@ export default function PoolBuchungPage() {
)} )}
{error && ( {error && (
<div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm"> <div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm" role="alert" aria-live="polite">
{error} {error}
</div> </div>
)} )}
@@ -398,8 +401,8 @@ export default function PoolBuchungPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Name */} {/* Name */}
<div> <div>
<label htmlFor="name" className="block text-sm font-medium mb-1.5"> <label htmlFor="name" className="block text-sm font-medium mb-2">
Name <span className="text-danger">*</span> Name <span className="text-danger" aria-label="erforderlich">*</span>
</label> </label>
<input <input
id="name" id="name"
@@ -410,15 +413,17 @@ export default function PoolBuchungPage() {
onBlur={handleBlur} onBlur={handleBlur}
autoComplete="name" autoComplete="name"
required required
className={`w-full border rounded-xl px-4 py-3 text-base transition-colors ${fieldErrors.name ? 'border-danger' : 'border-border'}`} aria-required="true"
aria-describedby={fieldErrors.name ? 'name-error' : undefined}
className={`w-full border rounded-xl px-4 py-3.5 text-base transition-colors ${fieldErrors.name ? 'border-danger bg-danger/5' : 'border-border'}`}
placeholder="Max Mustermann" placeholder="Max Mustermann"
/> />
{fieldErrors.name && <p className="text-danger text-xs mt-1">{fieldErrors.name}</p>} {fieldErrors.name && <p id="name-error" className="text-danger text-sm mt-1.5" role="alert">{fieldErrors.name}</p>}
</div> </div>
{/* Adresse */} {/* Adresse */}
<div> <div>
<label htmlFor="strasse" className="block text-sm font-medium mb-1.5"> <label htmlFor="strasse" className="block text-sm font-medium mb-2">
Straße & Hausnummer Straße & Hausnummer
</label> </label>
<input <input
@@ -428,15 +433,15 @@ export default function PoolBuchungPage() {
value={formData.strasse} value={formData.strasse}
onChange={handleChange} onChange={handleChange}
autoComplete="street-address" autoComplete="street-address"
className="w-full border border-border rounded-xl px-4 py-3 text-base" className="w-full border border-border rounded-xl px-4 py-3.5 text-base"
placeholder="Hauptstraße 12" placeholder="Hauptstraße 12"
/> />
</div> </div>
{/* Telefon */} {/* Telefon */}
<div> <div>
<label htmlFor="telefon" className="block text-sm font-medium mb-1.5"> <label htmlFor="telefon" className="block text-sm font-medium mb-2">
Telefon <span className="text-danger">*</span> Telefon <span className="text-danger" aria-label="erforderlich">*</span>
</label> </label>
<input <input
id="telefon" id="telefon"
@@ -448,16 +453,18 @@ export default function PoolBuchungPage() {
onBlur={handleBlur} onBlur={handleBlur}
autoComplete="tel" autoComplete="tel"
required required
className={`w-full border rounded-xl px-4 py-3 text-base transition-colors ${fieldErrors.telefon ? 'border-danger' : 'border-border'}`} aria-required="true"
aria-describedby={fieldErrors.telefon ? 'telefon-error' : undefined}
className={`w-full border rounded-xl px-4 py-3.5 text-base transition-colors ${fieldErrors.telefon ? 'border-danger bg-danger/5' : 'border-border'}`}
placeholder="+43 664 1234567" placeholder="+43 664 1234567"
/> />
{fieldErrors.telefon && <p className="text-danger text-xs mt-1">{fieldErrors.telefon}</p>} {fieldErrors.telefon && <p id="telefon-error" className="text-danger text-sm mt-1.5" role="alert">{fieldErrors.telefon}</p>}
</div> </div>
{/* Email */} {/* Email */}
<div> <div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5"> <label htmlFor="email" className="block text-sm font-medium mb-2">
E-Mail <span className="text-danger">*</span> E-Mail <span className="text-danger" aria-label="erforderlich">*</span>
</label> </label>
<input <input
id="email" id="email"
@@ -469,10 +476,12 @@ export default function PoolBuchungPage() {
onBlur={handleBlur} onBlur={handleBlur}
autoComplete="email" autoComplete="email"
required required
className={`w-full border rounded-xl px-4 py-3 text-base transition-colors ${fieldErrors.email ? 'border-danger' : 'border-border'}`} aria-required="true"
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
className={`w-full border rounded-xl px-4 py-3.5 text-base transition-colors ${fieldErrors.email ? 'border-danger bg-danger/5' : 'border-border'}`}
placeholder="max@beispiel.at" placeholder="max@beispiel.at"
/> />
{fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>} {fieldErrors.email && <p id="email-error" className="text-danger text-sm mt-1.5" role="alert">{fieldErrors.email}</p>}
</div> </div>
{/* Erinnerung */} {/* Erinnerung */}
@@ -481,22 +490,25 @@ export default function PoolBuchungPage() {
type="checkbox" type="checkbox"
checked={erinnerung} checked={erinnerung}
onChange={(e) => setErinnerung(e.target.checked)} onChange={(e) => setErinnerung(e.target.checked)}
className="mt-0.5 w-5 h-5 rounded border-border text-accent accent-accent flex-shrink-0" className="mt-0.5 w-6 h-6 rounded border-2 border-border text-accent accent-accent flex-shrink-0 focus:ring-2 focus:ring-accent focus:ring-offset-2"
/> />
<span className="text-sm text-text-muted leading-snug"> <span className="text-sm leading-snug">
Ich möchte nächstes Jahr per E-Mail an die Pool-Befüllung erinnert werden. Ich möchte nächstes Jahr per E-Mail an die Pool-Befüllung erinnert werden.
</span> </span>
</label> </label>
{/* CAPTCHA */} {/* CAPTCHA */}
<div className="flex justify-center pt-2"> <div className="pt-2">
<div ref={turnstileContainerRef} /> <p className="text-sm text-text-muted mb-3 text-center">Sicherheitsüberprüfung</p>
<div className="flex justify-center">
<div ref={turnstileContainerRef} />
</div>
</div> </div>
</div> </div>
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm"> <div className="mt-4 p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm" role="alert" aria-live="polite">
{error} {error}
</div> </div>
)} )}

View File

@@ -117,9 +117,10 @@ function StornoContent() {
// Loading // Loading
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex flex-col bg-bg"> <div className="min-h-screen flex flex-col bg-bg" role="status" aria-label="Seite wird geladen">
<Header back={{ href: '/', label: 'Zurück' }} /> <Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-6 w-full"> <main className="flex-1 max-w-lg mx-auto px-4 py-6 w-full">
<p className="text-text-muted text-sm text-center mb-4">Daten werden geladen...</p>
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
<div className="skeleton h-8 w-48" /> <div className="skeleton h-8 w-48" />
<div className="skeleton h-4 w-64" /> <div className="skeleton h-4 w-64" />
@@ -139,14 +140,17 @@ function StornoContent() {
<main className="flex-1 flex items-center justify-center px-4"> <main className="flex-1 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl border border-border p-8 max-w-sm w-full text-center animate-slide-up"> <div className="bg-white rounded-2xl border border-border p-8 max-w-sm w-full text-center animate-slide-up">
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-danger" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8 text-danger" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold mb-2">Ungültiger Link</h3> <h3 className="text-lg font-bold mb-2">Ungültiger Link</h3>
<p className="text-text-muted text-sm"> <p className="text-text-muted text-sm mb-3">
Dieser Storno-Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt. Dieser Storno-Link ist ungültig oder abgelaufen.
</p> </p>
<a href="tel:+4372435060" className="text-sm text-accent hover:underline font-medium">
Gemeindeamt anrufen: +43 7243 50600
</a>
</div> </div>
</main> </main>
<Footer /> <Footer />
@@ -190,7 +194,7 @@ function StornoContent() {
return ( return (
<div className="min-h-screen flex flex-col bg-bg"> <div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} /> <Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-5 w-full"> <main id="main-content" className="flex-1 max-w-lg mx-auto px-4 py-5 w-full">
<div className="mb-5"> <div className="mb-5">
<h2 className="text-xl font-bold text-primary">Buchung stornieren</h2> <h2 className="text-xl font-bold text-primary">Buchung stornieren</h2>
<p className="text-text-muted text-sm mt-1"> <p className="text-text-muted text-sm mt-1">
@@ -225,7 +229,7 @@ function StornoContent() {
</div> </div>
{error && ( {error && (
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm mb-5"> <div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm mb-5" role="alert" aria-live="polite">
{error} {error}
</div> </div>
)} )}

View File

@@ -111,9 +111,10 @@ function WasserzaehlerContent() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex flex-col bg-bg"> <div className="min-h-screen flex flex-col bg-bg" role="status" aria-label="Seite wird geladen">
<Header back={{ href: '/', label: 'Zurück' }} /> <Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-6 w-full"> <main className="flex-1 max-w-lg mx-auto px-4 py-6 w-full">
<p className="text-text-muted text-sm text-center mb-4">Daten werden geladen...</p>
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
<div className="skeleton h-8 w-48" /> <div className="skeleton h-8 w-48" />
<div className="skeleton h-4 w-64" /> <div className="skeleton h-4 w-64" />
@@ -133,16 +134,19 @@ function WasserzaehlerContent() {
<main className="flex-1 flex items-center justify-center px-4"> <main className="flex-1 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl border border-border p-8 max-w-sm w-full text-center animate-slide-up"> <div className="bg-white rounded-2xl border border-border p-8 max-w-sm w-full text-center animate-slide-up">
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-danger" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8 text-danger" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold mb-2">Ungültiger Zugang</h3> <h3 className="text-lg font-bold mb-2">Ungültiger Zugang</h3>
<p className="text-text-muted text-sm"> <p className="text-text-muted text-sm mb-3">
{!token {!token
? 'Bitte nutzen Sie den QR-Code auf Ihrem Ableseblatt um diese Seite aufzurufen.' ? 'Bitte nutzen Sie den QR-Code auf Ihrem Ableseblatt um diese Seite aufzurufen.'
: 'Der verwendete Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt.'} : 'Der verwendete Link ist ungültig oder abgelaufen.'}
</p> </p>
<a href="tel:+4372435060" className="text-sm text-accent hover:underline font-medium">
Gemeindeamt anrufen: +43 7243 50600
</a>
</div> </div>
</main> </main>
<Footer /> <Footer />
@@ -153,7 +157,7 @@ function WasserzaehlerContent() {
return ( return (
<div className="min-h-screen flex flex-col bg-bg"> <div className="min-h-screen flex flex-col bg-bg">
<Header back={{ href: '/', label: 'Zurück' }} /> <Header back={{ href: '/', label: 'Zurück' }} />
<main className="flex-1 max-w-lg mx-auto px-4 py-5 w-full"> <main id="main-content" className="flex-1 max-w-lg mx-auto px-4 py-5 w-full">
<div className="mb-5"> <div className="mb-5">
<h2 className="text-xl font-bold text-primary"> <h2 className="text-xl font-bold text-primary">
Wasserzähler-Ablesung Wasserzähler-Ablesung
@@ -221,7 +225,7 @@ function WasserzaehlerContent() {
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm"> <div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm" role="alert" aria-live="polite">
{error} {error}
</div> </div>
)} )}

View File

@@ -162,9 +162,9 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
</div> </div>
{/* Weekday headers */} {/* Weekday headers */}
<div className="grid grid-cols-7 gap-1 mb-1"> <div className="grid grid-cols-7 gap-2 mb-2">
{WEEKDAYS.map((d) => ( {WEEKDAYS.map((d) => (
<div key={d} className="text-center text-[11px] font-semibold text-text-muted/60 py-1 uppercase"> <div key={d} className="text-center text-xs font-bold text-text-muted/60 py-1 uppercase">
{d} {d}
</div> </div>
))} ))}
@@ -172,15 +172,15 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
{/* Days grid */} {/* Days grid */}
{loading ? ( {loading ? (
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-2">
{Array.from({ length: 35 }).map((_, i) => ( {Array.from({ length: 35 }).map((_, i) => (
<div key={i} className="skeleton w-11 h-11 mx-auto" /> <div key={i} className="skeleton w-12 h-12 mx-auto" />
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-2">
{days.map((day, i) => { {days.map((day, i) => {
if (!day) return <div key={`empty-${i}`} className="w-11 h-11" />; if (!day) return <div key={`empty-${i}`} className="w-12 h-12" />;
const status = getDayStatus(day); const status = getDayStatus(day);
const dateStr = formatDate(day); const dateStr = formatDate(day);
@@ -188,9 +188,13 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
const usedM3 = auslastungM3[dateStr] || 0; const usedM3 = auslastungM3[dateStr] || 0;
const freeM3 = maxM3PerDay - usedM3; const freeM3 = maxM3PerDay - usedM3;
const statusText = status === 'disabled' ? 'nicht verfügbar'
: status === 'full' ? 'keine Kapazität'
: status === 'partial' ? 'begrenzt verfügbar'
: 'verfügbar';
const ariaLabel = isBrunnen const ariaLabel = isBrunnen
? `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, verfügbar` ? `${day.getDate()}. ${MONTH_NAMES[viewMonth]} ${viewYear}, ${statusText}`
: `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, ${status === 'full' ? 'nicht genug Kapazität' : freeM3 + ' m³ frei'}`; : `${day.getDate()}. ${MONTH_NAMES[viewMonth]} ${viewYear}, ${statusText}${status !== 'disabled' && status !== 'full' ? ', ' + freeM3 + ' m³ frei' : ''}`;
return ( return (
<button <button
@@ -215,7 +219,7 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
)} )}
{/* Legend */} {/* 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 flex-wrap items-center justify-center gap-3 sm:gap-5 mt-4 pt-3 border-t border-border/50 text-xs text-text-muted">
<div className="flex items-center gap-1.5"> <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" /> <span className="w-2.5 h-2.5 rounded-sm bg-success/20 border border-success/30" />
Frei Frei

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ReactNode, useRef, useEffect } from 'react';
interface ConfirmationModalProps { interface ConfirmationModalProps {
title: string; title: string;
@@ -22,9 +22,47 @@ function generateCalendarUrl(title: string, dateStr: string): string {
} }
export default function ConfirmationModal({ title, message, onClose, details, calendarEvent, children }: ConfirmationModalProps) { export default function ConfirmationModal({ title, message, onClose, details, calendarEvent, children }: ConfirmationModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusable = modal.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length > 0) focusable[0].focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'Tab' && focusable.length > 0) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus(); e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus(); e.preventDefault();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return ( 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
<div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl max-w-md w-full p-6 pb-8 animate-slide-up"> 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"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl max-w-md w-full p-6 animate-slide-up"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
{/* Animated Checkmark */} {/* 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"> <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"> <svg className="w-10 h-10 text-success" fill="none" viewBox="0 0 24 24">
@@ -39,7 +77,7 @@ export default function ConfirmationModal({ title, message, onClose, details, ca
</svg> </svg>
</div> </div>
<h3 className="text-xl font-bold text-center mb-1">{title}</h3> <h3 id="modal-title" className="text-xl font-bold text-center mb-1">{title}</h3>
<p className="text-text-muted text-center text-sm mb-5">{message}</p> <p className="text-text-muted text-center text-sm mb-5">{message}</p>
{/* Detail Card */} {/* Detail Card */}
@@ -62,7 +100,8 @@ export default function ConfirmationModal({ title, message, onClose, details, ca
href={generateCalendarUrl(calendarEvent.title, calendarEvent.date)} href={generateCalendarUrl(calendarEvent.title, calendarEvent.date)}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" aria-label="In Google Kalender speichern (öffnet neues Fenster)"
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 focus:ring-2 focus:ring-accent focus:ring-offset-2"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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" />

View File

@@ -2,13 +2,13 @@ export default function Footer() {
return ( return (
<footer className="mt-auto border-t border-border/50 bg-white"> <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"> <div className="max-w-2xl mx-auto px-4 py-5 text-center space-y-1">
<p className="text-xs text-text-muted"> <p className="text-sm text-text-muted">
Gemeindeamt Weißkirchen an der Traun Gemeindeamt Weißkirchen an der Traun
</p> </p>
<p className="text-[11px] text-text-muted/70"> <p className="text-xs text-text-muted">
<a href="tel:+4372435060" className="hover:text-primary">+43 7243 50600</a> <a href="tel:+4372435060" className="hover:text-primary underline-offset-2 hover:underline transition-colors">+43 7243 50600</a>
{' | '} {' | '}
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at" className="hover:text-primary"> <a href="mailto:gemeinde@weisskirchen.ooe.gv.at" className="hover:text-primary underline-offset-2 hover:underline transition-colors">
gemeinde@weisskirchen.ooe.gv.at gemeinde@weisskirchen.ooe.gv.at
</a> </a>
</p> </p>

View File

@@ -12,10 +12,10 @@ export default function Header({ back }: HeaderProps) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
href={back.href} href={back.href}
className="flex items-center gap-1 text-white/80 hover:text-white transition-colors -ml-1 py-1" className="flex items-center gap-1 text-white/80 hover:text-white transition-colors -ml-1 px-2 py-2 rounded-lg focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary"
aria-label={back.label} aria-label={back.label}
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg> </svg>
<span className="text-sm">{back.label}</span> <span className="text-sm">{back.label}</span>

View File

@@ -12,7 +12,14 @@ interface ProgressBarProps {
export default function ProgressBar({ steps }: ProgressBarProps) { export default function ProgressBar({ steps }: ProgressBarProps) {
return ( return (
<div className="flex items-center gap-1 w-full" role="progressbar" aria-label="Fortschritt"> <div
className="flex items-center gap-1 w-full"
role="progressbar"
aria-label="Buchungsprozess Fortschritt"
aria-valuenow={steps.filter(s => s.done).length + (steps.some(s => s.active) ? 1 : 0)}
aria-valuemin={0}
aria-valuemax={steps.length}
>
{steps.map((step, i) => ( {steps.map((step, i) => (
<div key={step.label} className="flex items-center flex-1 last:flex-none"> <div key={step.label} className="flex items-center flex-1 last:flex-none">
{/* Step indicator */} {/* Step indicator */}
@@ -34,7 +41,7 @@ export default function ProgressBar({ steps }: ProgressBarProps) {
i + 1 i + 1
)} )}
</div> </div>
<span className={`text-[10px] mt-1 font-medium ${ <span className={`text-xs mt-1 font-semibold ${
step.done ? 'text-success' : step.active ? 'text-accent' : 'text-text-muted/50' step.done ? 'text-success' : step.active ? 'text-accent' : 'text-text-muted/50'
}`}> }`}>
{step.label} {step.label}