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:
Michael
2026-03-06 20:10:13 +01:00
parent 7bbfc49212
commit 4993fbd886
5 changed files with 469 additions and 1 deletions

View File

@@ -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
View 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 }
);
}
}

View File

@@ -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
View 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>
);
}

View File

@@ -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';
} }