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-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user