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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<p className="text-sm text-text-muted mb-3 text-center">Sicherheitsüberprüfung</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
<div ref={turnstileContainerRef} />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user