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 { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server';
|
||||
import { Resend } from 'resend';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function getResend() {
|
||||
return new Resend(process.env.RESEND_API_KEY);
|
||||
@@ -9,7 +10,7 @@ function getResend() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
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)
|
||||
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
|
||||
const { data: buchung, error: insertError } = await supabase
|
||||
.from('buchungen')
|
||||
@@ -121,6 +125,8 @@ export async function POST(request: NextRequest) {
|
||||
wunschdatum,
|
||||
status: 'aktiv',
|
||||
erstellt_von: isAdmin ? 'admin' : 'buerger',
|
||||
storno_token: stornoToken,
|
||||
erinnerung_naechstes_jahr: !!erinnerung_naechstes_jahr,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
@@ -141,6 +147,9 @@ export async function POST(request: NextRequest) {
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||
const stornoLink = `${baseUrl}/storno?token=${stornoToken}`;
|
||||
|
||||
try {
|
||||
await getResend().emails.send({
|
||||
from: 'Gemeindeamt Weißkirchen <gemeinde@datacrew.at>',
|
||||
@@ -177,6 +186,12 @@ export async function POST(request: NextRequest) {
|
||||
</tr>
|
||||
</table>
|
||||
</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;">
|
||||
Bei Fragen wenden Sie sich bitte an:<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: '',
|
||||
email: '',
|
||||
});
|
||||
const [erinnerung, setErinnerung] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
@@ -143,6 +144,7 @@ export default function PoolBuchungPage() {
|
||||
wassermenge_m3: wassermenge ? parseFloat(wassermenge) : null,
|
||||
wunschdatum: selectedDate,
|
||||
captchaToken,
|
||||
erinnerung_naechstes_jahr: erinnerung,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -167,6 +169,7 @@ export default function PoolBuchungPage() {
|
||||
setWassermenge('');
|
||||
setSelectedDate(null);
|
||||
setCaptchaToken('');
|
||||
setErinnerung(false);
|
||||
setFieldErrors({});
|
||||
setStep('wasser');
|
||||
setShowConfirmation(false);
|
||||
@@ -472,6 +475,19 @@ export default function PoolBuchungPage() {
|
||||
{fieldErrors.email && <p className="text-danger text-xs mt-1">{fieldErrors.email}</p>}
|
||||
</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 */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<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;
|
||||
status: 'aktiv' | 'storniert' | 'erledigt';
|
||||
notiz: string | null;
|
||||
storno_token: string | null;
|
||||
erinnerung_naechstes_jahr: boolean;
|
||||
erstellt_am: string;
|
||||
erstellt_von: 'buerger' | 'admin';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user