Fix reschedule token handling and improve admin notifications

- Fix getBookingByToken to only accept booking_access tokens
- Add sweepExpiredRescheduleProposals with admin notifications
- Return isExpired flag instead of throwing errors for expired proposals
- Fix email template to use actual token expiry time
- Remove duplicate admin emails in acceptReschedule
- Add one-click accept/decline support via URL parameters
This commit is contained in:
2025-10-05 16:11:37 +02:00
parent 97c1d3493f
commit a8cec16d7a
6 changed files with 1433 additions and 36 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
@@ -57,12 +57,27 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
const [rescheduleProposal, setRescheduleProposal] = useState<any | null>(null);
const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const [showDeclineConfirm, setShowDeclineConfirm] = useState(false);
const [oneClickAction, setOneClickAction] = useState<string | null>(null);
const [oneClickLoading, setOneClickLoading] = useState(false);
// Fetch booking details
const { data: booking, isLoading, error, refetch } = useQuery(
const { data: booking, isLoading, error, refetch, error: bookingError } = useQuery(
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
);
// Try fetching reschedule proposal if booking not found or error
useQuery({
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
enabled: !!token && (!!bookingError || !booking),
onSuccess: (data: any) => setRescheduleProposal(data),
onError: () => setRescheduleProposal(null),
});
// Cancellation mutation
const cancelMutation = useMutation({
...queryClient.cancellation.cancelByToken.mutationOptions(),
@@ -85,12 +100,88 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
},
});
const acceptMutation = useMutation({
...queryClient.bookings.acceptReschedule.mutationOptions(),
onSuccess: (result: any) => {
setRescheduleResult({ success: true, message: result.message });
setIsAccepting(false);
setShowDeclineConfirm(false);
refetch();
},
onError: (error: any) => {
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
setIsAccepting(false);
}
});
const declineMutation = useMutation({
...queryClient.bookings.declineReschedule.mutationOptions(),
onSuccess: (result: any) => {
setRescheduleResult({ success: true, message: result.message });
setIsDeclining(false);
setShowDeclineConfirm(false);
},
onError: (error: any) => {
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
setIsDeclining(false);
}
});
const handleCancel = () => {
setIsCancelling(true);
setCancellationResult(null);
cancelMutation.mutate({ token });
};
// Handle one-click actions from URL parameters
useEffect(() => {
if (rescheduleProposal && !oneClickAction) {
const urlParams = new URLSearchParams(window.location.search);
const action = urlParams.get('action');
if (action === 'accept' || action === 'decline') {
setOneClickAction(action);
}
}
}, [rescheduleProposal, oneClickAction]);
// Auto-execute one-click action
useEffect(() => {
if (oneClickAction && rescheduleProposal && !oneClickLoading && !rescheduleResult) {
setOneClickLoading(true);
if (oneClickAction === 'accept') {
const confirmAccept = window.confirm(
`Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date} um ${rescheduleProposal.proposed.time} Uhr akzeptieren?`
);
if (confirmAccept) {
acceptMutation.mutate({ token });
} else {
setOneClickLoading(false);
setOneClickAction(null);
}
} else if (oneClickAction === 'decline') {
const confirmDecline = window.confirm(
`Möchtest du den Vorschlag ablehnen? Dein ursprünglicher Termin am ${rescheduleProposal.original.date} um ${rescheduleProposal.original.time} Uhr bleibt dann bestehen.`
);
if (confirmDecline) {
declineMutation.mutate({ token });
} else {
setOneClickLoading(false);
setOneClickAction(null);
}
}
}
}, [oneClickAction, rescheduleProposal, oneClickLoading, rescheduleResult, acceptMutation, declineMutation, token]);
// Reset one-click loading when mutations complete
useEffect(() => {
if (rescheduleResult) {
setOneClickLoading(false);
setOneClickAction(null);
}
}, [rescheduleResult]);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
@@ -104,7 +195,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
);
}
if (error) {
if (error && !rescheduleProposal) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
@@ -140,7 +231,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
);
}
if (!booking) {
if (!booking && !rescheduleProposal) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
@@ -161,6 +252,141 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
);
}
if (rescheduleProposal) {
const isExpired = rescheduleProposal.isExpired;
const handleAccept = () => {
setIsAccepting(true);
setRescheduleResult(null);
acceptMutation.mutate({ token });
};
const handleDecline = () => {
setIsDeclining(true);
setRescheduleResult(null);
declineMutation.mutate({ token });
};
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
<span className={`px-4 py-2 rounded-full text-sm font-semibold bg-orange-100 text-orange-800`}>
Terminänderung vorgeschlagen
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Vorschlag zur Terminänderung</h1>
<p className="text-gray-600 mt-1">Bitte bestätige, ob der neue Termin für dich passt.</p>
</div>
{oneClickLoading && (
<div className="mb-6 p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500 mr-3"></div>
<p className="text-blue-800">
{oneClickAction === 'accept' ? 'Akzeptiere Termin...' : 'Lehne Termin ab...'}
</p>
</div>
</div>
)}
{rescheduleResult && (
<div className={`mb-6 p-4 rounded-lg ${rescheduleResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<p className={rescheduleResult.success ? 'text-green-800' : 'text-red-800'}>
{rescheduleResult.message}
</p>
</div>
)}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Vergleich</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4 bg-gray-50">
<div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
</div>
<div className="border rounded-lg p-4 bg-orange-50">
<div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date} um {rescheduleProposal.proposed.time} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
</div>
</div>
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
Bitte antworte bis {new Date(rescheduleProposal.expiresAt).toLocaleDateString('de-DE')} {new Date(rescheduleProposal.expiresAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ({rescheduleProposal.hoursUntilExpiry} Stunden).
</div>
</div>
{!isExpired && !rescheduleResult && !oneClickLoading && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleAccept}
disabled={isAccepting}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isAccepting ? 'Akzeptiere...' : 'Neuen Termin akzeptieren'}
</button>
<button
onClick={() => setShowDeclineConfirm(true)}
disabled={isDeclining}
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Vorschlag ablehnen
</button>
</div>
<div className="mt-3 text-sm text-gray-600">Wenn du ablehnst, bleibt dein ursprünglicher Termin bestehen.</div>
</div>
)}
{isExpired && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
Diese Terminänderung ist abgelaufen. Dein ursprünglicher Termin bleibt bestehen. Bei Fragen kontaktiere uns bitte.
</p>
</div>
)}
{showDeclineConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h4 className="text-lg font-semibold text-gray-900 mb-2">Vorschlag ablehnen?</h4>
<p className="text-sm text-gray-700 mb-4">
Bist du sicher, dass du den neuen Terminvorschlag ablehnen möchtest?<br />
Dein ursprünglicher Termin am {rescheduleProposal.original.date} um {rescheduleProposal.original.time} bleibt dann bestehen.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeclineConfirm(false)}
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200"
>
Abbrechen
</button>
<button
onClick={handleDecline}
disabled={isDeclining}
className="flex-1 bg-red-600 text-white py-2 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{isDeclining ? 'Lehne ab...' : 'Ja, ablehnen'}
</button>
</div>
</div>
</div>
)}
<div className="text-center">
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}
const statusInfo = getStatusInfo(booking.status);
return (
@@ -285,11 +511,11 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">E-Mail:</span>
<span className="font-medium text-gray-900">{booking.customerEmail}</span>
<span className="font-medium text-gray-900">{booking.customerEmail || '—'}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Telefon:</span>
<span className="font-medium text-gray-900">{booking.customerPhone}</span>
<span className="font-medium text-gray-900">{booking.customerPhone || '—'}</span>
</div>
</div>
{booking.notes && (