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-card: #ffffff;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-muted: #475569;
--color-border: #e2e8f0;
}
@@ -75,9 +75,9 @@ body {
/* ── Calendar day styles ── */
.cal-day {
@apply w-11 h-11 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all;
min-width: 44px;
min-height: 44px;
@apply w-12 h-12 rounded-xl flex items-center justify-center text-sm cursor-pointer transition-all;
min-width: 48px;
min-height: 48px;
}
.cal-day:hover:not(.cal-disabled):not(.cal-full) {
@apply bg-accent/15 text-accent;
@@ -115,3 +115,27 @@ input:focus, select:focus, textarea:focus {
z-index: 10;
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 (
<html lang="de">
<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}
</body>
</html>

View File

@@ -24,7 +24,7 @@ export default function Home() {
</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 */}
{isSaison && (
<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
</p>
</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" />
</svg>
</Link>
@@ -76,7 +76,7 @@ export default function Home() {
QR-Code scannen, Zählerstand eingeben
</p>
</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" />
</svg>
</Link>

View File

@@ -192,7 +192,7 @@ export default function PoolBuchungPage() {
/>
<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 */}
<div className="mb-6 px-2">
<ProgressBar steps={steps} />
@@ -247,8 +247,8 @@ export default function PoolBuchungPage() {
{/* Wassermenge bei Ortswasserleitung */}
{wasserquelle === 'ortswasserleitung' && (
<div className="animate-fade-in">
<label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-1.5">
Geschätzte Wassermenge (m³) <span className="text-danger">*</span>
<label htmlFor="wassermenge_m3" className="block text-sm font-medium mb-2">
Geschätzte Wassermenge (m³) <span className="text-danger" aria-label="erforderlich">*</span>
</label>
<input
id="wassermenge_m3"
@@ -258,18 +258,21 @@ export default function PoolBuchungPage() {
onChange={(e) => { setWassermenge(e.target.value); setError(''); }}
min="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"
/>
<p className="text-xs text-text-muted mt-1.5">
Die Kapazität ist pro Tag begrenzt. Anhand Ihrer Menge wird die Verfügbarkeit berechnet.
<p id="wassermenge_help" className="text-xs text-text-muted mt-1.5">
1 m³ = 1.000 Liter. Die Kapazität ist pro Tag begrenzt.
</p>
</div>
)}
</div>
{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}
</div>
)}
@@ -329,7 +332,7 @@ export default function PoolBuchungPage() {
)}
{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}
</div>
)}
@@ -398,8 +401,8 @@ export default function PoolBuchungPage() {
<div className="space-y-4">
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1.5">
Name <span className="text-danger">*</span>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Name <span className="text-danger" aria-label="erforderlich">*</span>
</label>
<input
id="name"
@@ -410,15 +413,17 @@ export default function PoolBuchungPage() {
onBlur={handleBlur}
autoComplete="name"
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"
/>
{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>
{/* Adresse */}
<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
</label>
<input
@@ -428,15 +433,15 @@ export default function PoolBuchungPage() {
value={formData.strasse}
onChange={handleChange}
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"
/>
</div>
{/* Telefon */}
<div>
<label htmlFor="telefon" className="block text-sm font-medium mb-1.5">
Telefon <span className="text-danger">*</span>
<label htmlFor="telefon" className="block text-sm font-medium mb-2">
Telefon <span className="text-danger" aria-label="erforderlich">*</span>
</label>
<input
id="telefon"
@@ -448,16 +453,18 @@ export default function PoolBuchungPage() {
onBlur={handleBlur}
autoComplete="tel"
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"
/>
{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>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-Mail <span className="text-danger">*</span>
<label htmlFor="email" className="block text-sm font-medium mb-2">
E-Mail <span className="text-danger" aria-label="erforderlich">*</span>
</label>
<input
id="email"
@@ -469,10 +476,12 @@ export default function PoolBuchungPage() {
onBlur={handleBlur}
autoComplete="email"
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"
/>
{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>
{/* Erinnerung */}
@@ -481,22 +490,25 @@ export default function PoolBuchungPage() {
type="checkbox"
checked={erinnerung}
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.
</span>
</label>
{/* CAPTCHA */}
<div className="flex justify-center pt-2">
<div ref={turnstileContainerRef} />
<div className="pt-2">
<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>
{/* 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}
</div>
)}

View File

@@ -117,9 +117,10 @@ function StornoContent() {
// Loading
if (loading) {
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' }} />
<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="skeleton h-8 w-48" />
<div className="skeleton h-4 w-64" />
@@ -139,14 +140,17 @@ function StornoContent() {
<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="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" />
</svg>
</div>
<h3 className="text-lg font-bold mb-2">Ungültiger Link</h3>
<p className="text-text-muted text-sm">
Dieser Storno-Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt.
<p className="text-text-muted text-sm mb-3">
Dieser Storno-Link ist ungültig oder abgelaufen.
</p>
<a href="tel:+4372435060" className="text-sm text-accent hover:underline font-medium">
Gemeindeamt anrufen: +43 7243 50600
</a>
</div>
</main>
<Footer />
@@ -190,7 +194,7 @@ function StornoContent() {
return (
<div className="min-h-screen flex flex-col bg-bg">
<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">
<h2 className="text-xl font-bold text-primary">Buchung stornieren</h2>
<p className="text-text-muted text-sm mt-1">
@@ -225,7 +229,7 @@ function StornoContent() {
</div>
{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}
</div>
)}

View File

@@ -111,9 +111,10 @@ function WasserzaehlerContent() {
if (loading) {
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' }} />
<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="skeleton h-8 w-48" />
<div className="skeleton h-4 w-64" />
@@ -133,16 +134,19 @@ function WasserzaehlerContent() {
<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="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" />
</svg>
</div>
<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
? '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>
<a href="tel:+4372435060" className="text-sm text-accent hover:underline font-medium">
Gemeindeamt anrufen: +43 7243 50600
</a>
</div>
</main>
<Footer />
@@ -153,7 +157,7 @@ function WasserzaehlerContent() {
return (
<div className="min-h-screen flex flex-col bg-bg">
<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">
<h2 className="text-xl font-bold text-primary">
Wasserzähler-Ablesung
@@ -221,7 +225,7 @@ function WasserzaehlerContent() {
{/* 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}
</div>
)}

View File

@@ -162,9 +162,9 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
</div>
{/* 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) => (
<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}
</div>
))}
@@ -172,15 +172,15 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
{/* Days grid */}
{loading ? (
<div className="grid grid-cols-7 gap-1">
<div className="grid grid-cols-7 gap-2">
{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 className="grid grid-cols-7 gap-1">
<div className="grid grid-cols-7 gap-2">
{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 dateStr = formatDate(day);
@@ -188,9 +188,13 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
const usedM3 = auslastungM3[dateStr] || 0;
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
? `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, verfügbar`
: `${day.getDate()}. ${MONTH_NAMES[viewMonth]}, ${status === 'full' ? 'nicht genug Kapazität' : freeM3 + ' m³ frei'}`;
? `${day.getDate()}. ${MONTH_NAMES[viewMonth]} ${viewYear}, ${statusText}`
: `${day.getDate()}. ${MONTH_NAMES[viewMonth]} ${viewYear}, ${statusText}${status !== 'disabled' && status !== 'full' ? ', ' + freeM3 + ' m³ frei' : ''}`;
return (
<button
@@ -215,7 +219,7 @@ export default function BookingCalendar({ onDateSelect, selectedDate, requestedM
)}
{/* 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">
<span className="w-2.5 h-2.5 rounded-sm bg-success/20 border border-success/30" />
Frei

View File

@@ -1,6 +1,6 @@
'use client';
import { ReactNode } from 'react';
import { ReactNode, useRef, useEffect } from 'react';
interface ConfirmationModalProps {
title: string;
@@ -22,9 +22,47 @@ function generateCalendarUrl(title: string, dateStr: string): string {
}
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 (
<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">
<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"
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 */}
<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">
@@ -39,7 +77,7 @@ export default function ConfirmationModal({ title, message, onClose, details, ca
</svg>
</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>
{/* Detail Card */}
@@ -62,7 +100,8 @@ export default function ConfirmationModal({ title, message, onClose, details, ca
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"
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">
<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 (
<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">
<p className="text-sm 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>
<p className="text-xs text-text-muted">
<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
</a>
</p>

View File

@@ -12,10 +12,10 @@ export default function Header({ back }: HeaderProps) {
<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"
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}
>
<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" />
</svg>
<span className="text-sm">{back.label}</span>

View File

@@ -12,7 +12,14 @@ interface ProgressBarProps {
export default function ProgressBar({ steps }: ProgressBarProps) {
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) => (
<div key={step.label} className="flex items-center flex-1 last:flex-none">
{/* Step indicator */}
@@ -34,7 +41,7 @@ export default function ProgressBar({ steps }: ProgressBarProps) {
i + 1
)}
</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.label}