mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:41:16 +00:00
discraded old booking mechanism, added Try again button in Cancel Page and refactored success page
This commit is contained in:
parent
3713f6ef68
commit
e7521facc2
6 changed files with 118 additions and 238 deletions
|
|
@ -203,7 +203,6 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
}
|
||||
|
||||
revalidatePath("/courses");
|
||||
revalidatePath(`/courses/book/${courseId}`);
|
||||
revalidatePath("/courses/myCourses");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Link from "next/link";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
||||
import { XCircle } from "lucide-react";
|
||||
|
||||
interface CancelPageProps {
|
||||
|
|
@ -8,6 +9,8 @@ interface CancelPageProps {
|
|||
|
||||
export default async function CourseBookingCancelPage({ params }: CancelPageProps) {
|
||||
const { courseId } = await params;
|
||||
const result = await getCourseSummaryForConfirmation(courseId);
|
||||
const businessSlug = result.success ? (result.course.business?.slug ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
|
|
@ -24,9 +27,11 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
|
|||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href={`/courses/book/${courseId}`}>
|
||||
<Button className="w-full sm:w-auto">Erneut versuchen</Button>
|
||||
</Link>
|
||||
{businessSlug && (
|
||||
<Link href={`/business/${businessSlug}`}>
|
||||
<Button className="w-full sm:w-auto">Erneut versuchen</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/">
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
Zur Startseite
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { getCourseForBooking } from "@/src/modules/courses/actions/booking-actions";
|
||||
import CourseBookingView from "@/src/modules/courses/ui/views/course-booking-view";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
interface CourseBookingPageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -8,13 +7,18 @@ interface CourseBookingPageProps {
|
|||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die alte direkte Buchungs-View ist abgelöst – gebucht wird nur noch über die
|
||||
* öffentliche Business-Seite. Alte (geteilte) Links werden hierher geleitet und
|
||||
* auf die öffentliche Seite des zugehörigen Business weitergeleitet.
|
||||
*/
|
||||
export default async function CourseBookingPage({ params }: CourseBookingPageProps) {
|
||||
const { courseId } = await params;
|
||||
const result = await getCourseForBooking(courseId);
|
||||
const result = await getCourseSummaryForConfirmation(courseId);
|
||||
|
||||
if (!result.success || !result.course) {
|
||||
if (!result.success || !result.course.business?.slug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <CourseBookingView course={result.course} />;
|
||||
redirect(`/business/${result.course.business.slug}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
||||
import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react";
|
||||
|
||||
interface SuccessPageProps {
|
||||
params: Promise<{ courseId: string }>;
|
||||
|
|
@ -9,6 +10,23 @@ interface SuccessPageProps {
|
|||
|
||||
export default async function CourseBookingSuccessPage({ params }: SuccessPageProps) {
|
||||
const { courseId } = await params;
|
||||
const result = await getCourseSummaryForConfirmation(courseId);
|
||||
|
||||
const course = result.success ? result.course : null;
|
||||
const businessSlug = course?.business?.slug ?? null;
|
||||
|
||||
const formattedDate = course
|
||||
? new Date(course.date).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
|
||||
const formattedPrice = course
|
||||
? new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(course.price)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
|
|
@ -24,6 +42,39 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
|||
Vielen Dank für Ihre Buchung. Sie erhalten in Kürze eine Bestätigungs-E-Mail.
|
||||
</p>
|
||||
|
||||
{course && (
|
||||
<div className="bg-gray-50 rounded-lg p-5 text-left mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-1">{course.name}</h2>
|
||||
{course.business?.name && (
|
||||
<p className="text-sm text-gray-500 mb-4">{course.business.name}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Calendar size={15} className="text-gray-400 shrink-0" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Clock size={15} className="text-gray-400 shrink-0" />
|
||||
<span>
|
||||
{course.timeFrom} – {course.timeTo}
|
||||
</span>
|
||||
</div>
|
||||
{course.business?.address && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MapPin size={15} className="text-gray-400 shrink-0" />
|
||||
<span>{course.business.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Bezahlt</span>
|
||||
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 mb-8">
|
||||
Ihre Buchung wird gerade verarbeitet. Falls Sie den Kurs nicht in Ihrer Übersicht sehen,
|
||||
warten Sie bitte einen Moment und laden die Seite neu.
|
||||
|
|
@ -33,11 +84,13 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
|||
<Link href="/courses/myCourses">
|
||||
<Button className="w-full sm:w-auto">Meine Kurse ansehen</Button>
|
||||
</Link>
|
||||
<Link href={`/courses/book/${courseId}`}>
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
Zurück zum Kurs
|
||||
</Button>
|
||||
</Link>
|
||||
{businessSlug && (
|
||||
<Link href={`/business/${businessSlug}`}>
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
Weitere Kurse ansehen
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,56 +10,6 @@
|
|||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData } from "@/src/lib/auth/getUser";
|
||||
|
||||
/**
|
||||
* Ruft einen Kurs mit Buchungsinformationen ab (öffentlich)
|
||||
*
|
||||
* Diese Funktion gibt Kursinformationen inkl. aktueller Teilnehmerzahl zurück.
|
||||
* Sie ist öffentlich zugänglich für die Buchungsseite.
|
||||
*
|
||||
* @param courseId - Die eindeutige ID des Kurses
|
||||
* @returns Ein Objekt mit success-Flag und Kursdaten
|
||||
*/
|
||||
export async function getCourseForBooking(courseId: string) {
|
||||
try {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { bookings: { where: { paymentStatus: "paid" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Kurs nicht gefunden",
|
||||
};
|
||||
}
|
||||
|
||||
if (course.status !== "published") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Dieser Kurs ist nicht verfügbar",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
course: {
|
||||
...course,
|
||||
currentParticipants: course._count.bookings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching course for booking:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Fehler beim Laden des Kurses",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Überprüft, ob ein User bereits für einen Kurs gebucht hat
|
||||
*
|
||||
|
|
@ -100,6 +50,47 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine kompakte Kurs-Zusammenfassung inkl. Business-Slug.
|
||||
* Wird für die Success-Page (Kursdetails anzeigen) und für den Redirect
|
||||
* der alten Buchungs-View auf die öffentliche Business-Seite genutzt.
|
||||
*
|
||||
* @param courseId - Die eindeutige ID des Kurses
|
||||
*/
|
||||
export async function getCourseSummaryForConfirmation(courseId: string) {
|
||||
try {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
sport: true,
|
||||
date: true,
|
||||
timeFrom: true,
|
||||
timeTo: true,
|
||||
price: true,
|
||||
room: true,
|
||||
business: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
address: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
return { success: false as const, error: "Kurs nicht gefunden" };
|
||||
}
|
||||
|
||||
return { success: true as const, course };
|
||||
} catch (error) {
|
||||
console.error("Error fetching course summary:", error);
|
||||
return { success: false as const, error: "Fehler beim Laden des Kurses" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBusinessBySlug(slug: string) {
|
||||
try {
|
||||
const business = await prisma.business.findUnique({
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CourseBookingViewProps {
|
||||
course: {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string[];
|
||||
date: Date;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
description: string;
|
||||
maxParticipants: number;
|
||||
currentParticipants: number;
|
||||
room: string;
|
||||
price: number;
|
||||
};
|
||||
}
|
||||
|
||||
const CourseBookingView = ({ course }: CourseBookingViewProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isAlreadyBooked, setIsAlreadyBooked] = useState(false);
|
||||
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
|
||||
|
||||
const spotsAvailable = course.maxParticipants - course.currentParticipants;
|
||||
const isFull = spotsAvailable <= 0;
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(course.price);
|
||||
|
||||
// Check if user already booked this course
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
const result = await checkUserBookingStatus(course.id);
|
||||
if (result.success) {
|
||||
setIsAlreadyBooked(result.isBooked);
|
||||
}
|
||||
setIsCheckingStatus(false);
|
||||
};
|
||||
checkStatus();
|
||||
}, [course.id]);
|
||||
|
||||
const handleBooking = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/stripe/checkout/course", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ courseId: course.id }),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.assign("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
let data: { url?: string; error?: string } = {};
|
||||
try {
|
||||
data = (await response.json()) as { url?: string; error?: string };
|
||||
} catch {
|
||||
// fallback error message below
|
||||
}
|
||||
|
||||
if (!response.ok || !data.url) {
|
||||
toast.error(data.error || "Fehler bei der Buchung");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(data.url);
|
||||
} catch (error) {
|
||||
toast.error("Ein unerwarteter Fehler ist aufgetreten");
|
||||
console.error(error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">{course.name}</h1>
|
||||
<p className="text-lg text-gray-600">{course.sport.join(", ")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Datum</h2>
|
||||
<p className="text-gray-900">{formattedDate}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Uhrzeit</h2>
|
||||
<p className="text-gray-900">
|
||||
{course.timeFrom} - {course.timeTo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Raum</h2>
|
||||
<p className="text-gray-900">{course.room}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Verfügbare Plätze</h2>
|
||||
<p className="text-gray-900">
|
||||
{course.currentParticipants} / {course.maxParticipants} Teilnehmer
|
||||
{isFull && <span className="text-red-500 ml-2">(Ausgebucht)</span>}
|
||||
{!isFull && (
|
||||
<span className="text-green-600 ml-2">({spotsAvailable} Plätze frei)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Preis</h2>
|
||||
<p className="text-2xl font-bold text-gray-900">{formattedPrice}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700 mb-2">Was Sie mitbringen sollten</h2>
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: course.description }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{isCheckingStatus ? (
|
||||
<Button disabled className="flex-1">
|
||||
Lade Status...
|
||||
</Button>
|
||||
) : isAlreadyBooked ? (
|
||||
<Button disabled className="flex-1">
|
||||
Bereits angemeldet
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleBooking} disabled={isSubmitting || isFull} className="flex-1">
|
||||
{isSubmitting
|
||||
? "Wird weitergeleitet..."
|
||||
: isFull
|
||||
? "Ausgebucht"
|
||||
: `Jetzt buchen - ${formattedPrice}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAlreadyBooked && (
|
||||
<p className="text-green-600 text-center mt-4">✓ Sie sind für diesen Kurs angemeldet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseBookingView;
|
||||
Loading…
Add table
Add a link
Reference in a new issue