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:
@@ -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 && (
|
||||
|
Reference in New Issue
Block a user