Storno-Link in Bestätigungsmail + Erinnerung nächstes Jahr
- Bestätigungsmail enthält roten Storno-Button mit sicherem Token - /storno?token=XXX Seite zeigt Buchungsdetails + Stornierung - Storno-Bestätigungsmail mit Neu-Buchen-Button - Checkbox im Buchungsformular für Erinnerung nächstes Jahr - Neue DB-Felder: storno_token (unique), erinnerung_naechstes_jahr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
|
import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
function getResend() {
|
function getResend() {
|
||||||
return new Resend(process.env.RESEND_API_KEY);
|
return new Resend(process.env.RESEND_API_KEY);
|
||||||
@@ -9,7 +10,7 @@ function getResend() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken } = body;
|
const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken, erinnerung_naechstes_jahr } = body;
|
||||||
|
|
||||||
// Prüfen ob Admin eingeloggt ist (kein CAPTCHA nötig)
|
// Prüfen ob Admin eingeloggt ist (kein CAPTCHA nötig)
|
||||||
const authClient = await createServerSupabaseClient();
|
const authClient = await createServerSupabaseClient();
|
||||||
@@ -108,6 +109,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Storno-Token generieren
|
||||||
|
const stornoToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Buchung speichern
|
// Buchung speichern
|
||||||
const { data: buchung, error: insertError } = await supabase
|
const { data: buchung, error: insertError } = await supabase
|
||||||
.from('buchungen')
|
.from('buchungen')
|
||||||
@@ -121,6 +125,8 @@ export async function POST(request: NextRequest) {
|
|||||||
wunschdatum,
|
wunschdatum,
|
||||||
status: 'aktiv',
|
status: 'aktiv',
|
||||||
erstellt_von: isAdmin ? 'admin' : 'buerger',
|
erstellt_von: isAdmin ? 'admin' : 'buerger',
|
||||||
|
storno_token: stornoToken,
|
||||||
|
erinnerung_naechstes_jahr: !!erinnerung_naechstes_jahr,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -141,6 +147,9 @@ export async function POST(request: NextRequest) {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||||
|
const stornoLink = `${baseUrl}/storno?token=${stornoToken}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: 'Gemeindeamt Weißkirchen <gemeinde@datacrew.at>',
|
from: 'Gemeindeamt Weißkirchen <gemeinde@datacrew.at>',
|
||||||
@@ -177,6 +186,12 @@ export async function POST(request: NextRequest) {
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="text-align: center; margin: 25px 0; padding: 20px 0; border-top: 1px solid #e2e8f0;">
|
||||||
|
<p style="font-size: 13px; color: #94a3b8; margin: 0 0 12px 0;">Termin stornieren?</p>
|
||||||
|
<a href="${stornoLink}" style="display: inline-block; background-color: #dc2626; color: white; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-weight: 600; font-size: 14px;">
|
||||||
|
Buchung stornieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<p style="font-size: 14px; color: #64748b;">
|
<p style="font-size: 14px; color: #64748b;">
|
||||||
Bei Fragen wenden Sie sich bitte an:<br>
|
Bei Fragen wenden Sie sich bitte an:<br>
|
||||||
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at">gemeinde@weisskirchen.ooe.gv.at</a><br>
|
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at">gemeinde@weisskirchen.ooe.gv.at</a><br>
|
||||||
|
|||||||
173
src/app/api/storno/route.ts
Normal file
173
src/app/api/storno/route.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createServiceClient } from '@/lib/supabase/server';
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
|
function getResend() {
|
||||||
|
return new Resend(process.env.RESEND_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: Buchung per Storno-Token laden
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Token fehlt.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('buchungen')
|
||||||
|
.select('id, name, wunschdatum, wasserquelle, wassermenge_m3, status')
|
||||||
|
.eq('storno_token', token)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ungültiger Storno-Link.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Storno GET Error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Interner Serverfehler.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Buchung stornieren
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { token } = body;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Token fehlt.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
|
||||||
|
// Buchung laden
|
||||||
|
const { data: buchung, error: fetchError } = await supabase
|
||||||
|
.from('buchungen')
|
||||||
|
.select('id, name, email, wunschdatum, wasserquelle, wassermenge_m3, status')
|
||||||
|
.eq('storno_token', token)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError || !buchung) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ungültiger Storno-Link.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buchung.status === 'storniert') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Diese Buchung wurde bereits storniert.' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status auf storniert setzen
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('buchungen')
|
||||||
|
.update({ status: 'storniert' })
|
||||||
|
.eq('id', buchung.id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('Storno Update Error:', updateError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Fehler beim Stornieren.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storno-Bestätigungsmail senden
|
||||||
|
const datumFormatiert = new Date(buchung.wunschdatum + 'T00:00:00').toLocaleDateString('de-AT', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getResend().emails.send({
|
||||||
|
from: 'Gemeindeamt Weißkirchen <gemeinde@datacrew.at>',
|
||||||
|
to: buchung.email,
|
||||||
|
subject: `Stornierung Ihrer Pool-Befüllung — ${datumFormatiert}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<div style="background-color: #0d2b4e; color: white; padding: 20px; text-align: center;">
|
||||||
|
<h2 style="margin: 0;">Gemeindeamt Weißkirchen</h2>
|
||||||
|
<p style="margin: 5px 0 0 0; opacity: 0.7; font-size: 14px;">an der Traun</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 30px; background: #f8fafc;">
|
||||||
|
<p>Sehr geehrte/r <strong>${buchung.name}</strong>,</p>
|
||||||
|
<p>Ihre Pool-Befüllung wurde erfolgreich storniert.</p>
|
||||||
|
<div style="background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Status:</td>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold; text-align: right; color: #dc2626;">Storniert</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Termin:</td>
|
||||||
|
<td style="padding: 8px 0; text-align: right; text-decoration: line-through; color: #94a3b8;">${datumFormatiert}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Wasserquelle:</td>
|
||||||
|
<td style="padding: 8px 0; text-align: right; text-decoration: line-through; color: #94a3b8;">${buchung.wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung'}</td>
|
||||||
|
</tr>
|
||||||
|
${buchung.wassermenge_m3 ? `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #64748b; font-size: 14px;">Wassermenge:</td>
|
||||||
|
<td style="padding: 8px 0; text-align: right; text-decoration: line-through; color: #94a3b8;">${buchung.wassermenge_m3} m³</td>
|
||||||
|
</tr>
|
||||||
|
` : ''}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin: 25px 0;">
|
||||||
|
<a href="${baseUrl}/pool" style="display: inline-block; background-color: #0d2b4e; color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
||||||
|
Neuen Termin buchen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 14px; color: #64748b;">
|
||||||
|
Bei Fragen wenden Sie sich bitte an:<br>
|
||||||
|
<a href="mailto:gemeinde@weisskirchen.ooe.gv.at">gemeinde@weisskirchen.ooe.gv.at</a><br>
|
||||||
|
Tel: +43 7243 50600
|
||||||
|
</p>
|
||||||
|
<p>Mit freundlichen Grüßen,<br><strong>Gemeindeamt Weißkirchen an der Traun</strong></p>
|
||||||
|
</div>
|
||||||
|
<div style="background-color: #091d35; color: rgba(255,255,255,0.5); padding: 15px; text-align: center; font-size: 12px;">
|
||||||
|
Gemeindeplatz 1, 4616 Weißkirchen an der Traun
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Storno-Mail Versand fehlgeschlagen:', emailError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Storno POST Error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Interner Serverfehler.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export default function PoolBuchungPage() {
|
|||||||
telefon: '',
|
telefon: '',
|
||||||
email: '',
|
email: '',
|
||||||
});
|
});
|
||||||
|
const [erinnerung, setErinnerung] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
@@ -143,6 +144,7 @@ export default function PoolBuchungPage() {
|
|||||||
wassermenge_m3: wassermenge ? parseFloat(wassermenge) : null,
|
wassermenge_m3: wassermenge ? parseFloat(wassermenge) : null,
|
||||||
wunschdatum: selectedDate,
|
wunschdatum: selectedDate,
|
||||||
captchaToken,
|
captchaToken,
|
||||||
|
erinnerung_naechstes_jahr: erinnerung,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -167,6 +169,7 @@ export default function PoolBuchungPage() {
|
|||||||
setWassermenge('');
|
setWassermenge('');
|
||||||
setSelectedDate(null);
|
setSelectedDate(null);
|
||||||
setCaptchaToken('');
|
setCaptchaToken('');
|
||||||
|
setErinnerung(false);
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setStep('wasser');
|
setStep('wasser');
|
||||||
setShowConfirmation(false);
|
setShowConfirmation(false);
|
||||||
@@ -472,6 +475,19 @@ export default function PoolBuchungPage() {
|
|||||||
{fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>}
|
{fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Erinnerung */}
|
||||||
|
<label className="flex items-start gap-3 p-3 bg-accent/5 border border-accent/15 rounded-xl cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-muted leading-snug">
|
||||||
|
Ich möchte nächstes Jahr per E-Mail an die Pool-Befüllung erinnert werden.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* CAPTCHA */}
|
{/* CAPTCHA */}
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-2">
|
||||||
<div ref={turnstileContainerRef} />
|
<div ref={turnstileContainerRef} />
|
||||||
|
|||||||
262
src/app/storno/page.tsx
Normal file
262
src/app/storno/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
|
interface StornoBuchung {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
wunschdatum: string;
|
||||||
|
wasserquelle: 'brunnen' | 'ortswasserleitung';
|
||||||
|
wassermenge_m3: number | null;
|
||||||
|
status: 'aktiv' | 'storniert' | 'erledigt';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StornoPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<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-6 w-full">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="skeleton h-8 w-48" />
|
||||||
|
<div className="skeleton h-4 w-64" />
|
||||||
|
<div className="skeleton h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<StornoContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StornoContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const [buchung, setBuchung] = useState<StornoBuchung | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [tokenError, setTokenError] = useState(false);
|
||||||
|
const [storniert, setStorniert] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setTokenError(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBuchung() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/storno?token=${token}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
setTokenError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setBuchung(data);
|
||||||
|
if (data.status === 'storniert') {
|
||||||
|
setStorniert(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTokenError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBuchung();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleStorno = async () => {
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/storno', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
setStorniert(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || 'Fehler beim Stornieren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStorniert(true);
|
||||||
|
} catch {
|
||||||
|
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const datumFormatiert = buchung
|
||||||
|
? new Date(buchung.wunschdatum + 'T00:00:00').toLocaleDateString('de-AT', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const wasserquelleLabel = buchung?.wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung';
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading) {
|
||||||
|
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-6 w-full">
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
<div className="skeleton h-8 w-48" />
|
||||||
|
<div className="skeleton h-4 w-64" />
|
||||||
|
<div className="skeleton h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid token
|
||||||
|
if (tokenError || !buchung) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-bg">
|
||||||
|
<Header back={{ href: '/', label: 'Zurück' }} />
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already cancelled
|
||||||
|
if (storniert) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-bg">
|
||||||
|
<Header back={{ href: '/', label: 'Zurück' }} />
|
||||||
|
<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-success/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold mb-2">Buchung storniert</h3>
|
||||||
|
<p className="text-text-muted text-sm mb-2">
|
||||||
|
Ihre Pool-Befüllung am <strong>{datumFormatiert}</strong> wurde erfolgreich storniert.
|
||||||
|
</p>
|
||||||
|
<p className="text-text-muted text-sm mb-6">
|
||||||
|
Sie erhalten eine Bestätigung per E-Mail.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/pool"
|
||||||
|
className="inline-block w-full bg-primary text-white py-3.5 rounded-xl font-semibold text-base hover:bg-primary-light active:scale-[0.98] transition-all text-center"
|
||||||
|
>
|
||||||
|
Neuen Termin buchen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show booking details + cancel button
|
||||||
|
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">
|
||||||
|
<div className="mb-5">
|
||||||
|
<h2 className="text-xl font-bold text-primary">Buchung stornieren</h2>
|
||||||
|
<p className="text-text-muted text-sm mt-1">
|
||||||
|
Möchten Sie Ihre Pool-Befüllung wirklich stornieren?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking details */}
|
||||||
|
<div className="bg-white rounded-2xl border border-border p-4 space-y-0 mb-5">
|
||||||
|
<div className="flex justify-between py-2.5 border-b border-border/40">
|
||||||
|
<span className="text-sm text-text-muted">Termin</span>
|
||||||
|
<span className="text-sm font-semibold">{datumFormatiert}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2.5 border-b border-border/40">
|
||||||
|
<span className="text-sm text-text-muted">Wasserquelle</span>
|
||||||
|
<span className="text-sm font-medium">{wasserquelleLabel}</span>
|
||||||
|
</div>
|
||||||
|
{buchung.wassermenge_m3 && (
|
||||||
|
<div className="flex justify-between py-2.5 border-b border-border/40">
|
||||||
|
<span className="text-sm text-text-muted">Wassermenge</span>
|
||||||
|
<span className="text-sm font-medium">{buchung.wassermenge_m3} m³</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between py-2.5">
|
||||||
|
<span className="text-sm text-text-muted">Status</span>
|
||||||
|
<span className="text-sm font-semibold text-success">Aktiv</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-warning/5 border border-warning/20 rounded-xl text-sm text-warning mb-5">
|
||||||
|
Nach der Stornierung wird der Termin freigegeben und kann von anderen gebucht werden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-danger/5 border border-danger/20 rounded-xl text-danger text-sm mb-5">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={handleStorno}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full bg-danger text-white py-3.5 rounded-xl font-semibold text-base hover:bg-danger/90 active:scale-[0.98] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
Wird storniert...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Stornierung bestätigen'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/pool"
|
||||||
|
className="block w-full py-2.5 text-sm text-text-muted font-medium hover:text-primary transition-colors text-center"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export interface Buchung {
|
|||||||
wunschdatum: string;
|
wunschdatum: string;
|
||||||
status: 'aktiv' | 'storniert' | 'erledigt';
|
status: 'aktiv' | 'storniert' | 'erledigt';
|
||||||
notiz: string | null;
|
notiz: string | null;
|
||||||
|
storno_token: string | null;
|
||||||
|
erinnerung_naechstes_jahr: boolean;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
erstellt_von: 'buerger' | 'admin';
|
erstellt_von: 'buerger' | 'admin';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user