added settings to make a publicly available site with all courses

This commit is contained in:
Rajpreet0 2026-05-04 23:33:02 +02:00
parent 06d1290921
commit 8718700fd8
19 changed files with 2756 additions and 2 deletions

1671
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -34,13 +34,16 @@
"@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"lucide-react": "^0.552.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.4",
"prisma": "^6.19.0",
"radix-ui": "^1.4.3",
"react": "19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.0",
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14",

View file

@ -97,11 +97,15 @@ model Business {
banking String?
plan String?
subscription Json?
slug String? @unique
isPublic Boolean @default(false)
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([managerId])
@@index([slug])
}
model Course {
@ -122,6 +126,8 @@ model Course {
weekdays String[] // For custom frequency
status String @default("draft") // draft, published
createdBy String // Manager ID who created the course
businessId String?
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
bookings CourseBooking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -129,6 +135,7 @@ model Course {
@@index([createdBy])
@@index([date])
@@index([status])
@@index([businessId])
}
model Room {

View file

@ -0,0 +1,5 @@
import SettingsView from "@/src/modules/settings/ui/views/settings-view";
export default function SettingsPage() {
return <SettingsView />;
}

View file

@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import {
getBusinessBySlug,
getPublishedCoursesForBusiness,
} from "@/src/modules/courses/actions/booking-actions";
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
interface BusinessPageProps {
params: Promise<{ slug: string }>;
}
export default async function BusinessPage({ params }: BusinessPageProps) {
const { slug } = await params;
const businessResult = await getBusinessBySlug(slug);
if (!businessResult.success || !businessResult.business || !businessResult.business.slug) {
notFound();
}
const business = {
...businessResult.business,
slug: businessResult.business.slug,
};
const from = new Date();
from.setHours(0, 0, 0, 0);
const to = new Date(from);
to.setDate(to.getDate() + 30);
const coursesResult = await getPublishedCoursesForBusiness(businessResult.business.id, {
from,
to,
});
return (
<BusinessPublicView
business={business}
initialCourses={
(coursesResult.courses ?? []) as Parameters<typeof BusinessPublicView>[0]["initialCourses"]
}
initialWindow={{ from, to }}
/>
);
}

View file

@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/src/lib/utils"
import { Button, buttonVariants } from "@/src/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View file

@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/src/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

15
src/lib/slug.ts Normal file
View file

@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
export async function generateUniqueSlug(name: string, prisma: PrismaClient): Promise<string> {
const base = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 60);
let slug = base || "business";
let counter = 2;
while (await prisma.business.findUnique({ where: { slug } })) {
slug = `${base}-${counter++}`;
}
return slug;
}

View file

@ -4,7 +4,15 @@ import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
// Protected routes
const protectedRoutes = ["/dashboard", "/profile", "/trainings", "/courses"];
const protectedRoutes = [
"/dashboard",
"/profile",
"/trainings",
"/courses",
"/settings",
"/employee",
"/rooms",
];
const authRoutes = ["/login", "/register", "/forgot-password"];
const isProtectedRoute = protectedRoutes.some((route) =>

View file

@ -201,3 +201,56 @@ export async function checkUserBookingStatus(courseId: string) {
};
}
}
export async function getBusinessBySlug(slug: string) {
try {
const business = await prisma.business.findUnique({
where: { slug },
select: {
id: true,
name: true,
address: true,
email: true,
title: true,
slug: true,
isPublic: true,
},
});
if (!business || !business.isPublic) {
return { success: false, error: "Seite nicht gefunden" };
}
return { success: true, business };
} catch (error) {
console.error("Error fetching business by slug:", error);
return { success: false, error: "Fehler beim Laden" };
}
}
export async function getPublishedCoursesForBusiness(
businessId: string,
options: { from: Date; to: Date }
) {
try {
const courses = await prisma.course.findMany({
where: {
businessId,
status: "published",
date: { gte: options.from, lte: options.to },
},
include: {
_count: { select: { bookings: true } },
},
orderBy: { date: "asc" },
});
return {
success: true,
courses: courses.map((c) => ({ ...c, currentParticipants: c._count.bookings })),
};
} catch (error) {
console.error("Error fetching courses for business:", error);
return { success: false, error: "Fehler beim Laden der Kurse", courses: [] };
}
}

View file

@ -13,6 +13,7 @@ import { getUserData, isManager, isEmployee } from "@/src/lib/auth/getUser";
import { courseSchema, publishedCourseSchema, type CourseFormData } from "../schemas/course-schema";
import { revalidatePath } from "next/cache";
import { Prisma } from "@prisma/client";
import { generateUniqueSlug } from "@/src/lib/slug";
/**
* Erstellt einen neuen Kurs in der Datenbank
@ -83,6 +84,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
weekdays: validatedData.weekdays || [],
status,
createdBy: creatorId,
businessId: validatedData.businessId ?? null,
},
});
@ -378,6 +380,9 @@ export async function updateCourse(
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
status,
...(validatedData.businessId !== undefined
? { businessId: validatedData.businessId || null }
: {}),
},
});
@ -508,3 +513,75 @@ export async function getMySportTypes() {
return { success: false, error: "Fehler beim Laden der Sportarten", sports: [] };
}
}
export async function getMyBusinesses() {
try {
const userData = await getUserData();
if (!userData || (!isManager(userData) && !isEmployee(userData))) {
return { success: false, error: "Unauthorized", businesses: [] };
}
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id;
const businesses = await prisma.business.findMany({
where: { managerId },
select: { id: true, name: true, slug: true, isPublic: true },
orderBy: { name: "asc" },
});
return { success: true, businesses };
} catch (error) {
console.error("Error fetching businesses:", error);
return { success: false, error: "Fehler beim Laden der Businesses", businesses: [] };
}
}
export async function toggleBusinessPublic(businessId: string, isPublic: boolean) {
try {
const userData = await getUserData();
if (!userData || !isManager(userData)) {
return { success: false, error: "Keine Berechtigung" };
}
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true, slug: true, name: true },
});
if (!business || business.managerId !== userData.id) {
return { success: false, error: "Business nicht gefunden" };
}
// Generate slug on first publish if missing
let slug = business.slug;
if (!slug) {
slug = await generateUniqueSlug(business.name, prisma);
}
await prisma.business.update({
where: { id: businessId },
data: { isPublic, slug },
});
// Auto-assign courses that belong to this manager but have no businessId yet.
// Only do this when there is exactly one business — if there are multiple,
// the manager must assign courses manually via the course edit form.
const totalBusinesses = await prisma.business.count({ where: { managerId: userData.id } });
if (totalBusinesses === 1) {
await prisma.course.updateMany({
where: { createdBy: userData.id, businessId: null },
data: { businessId },
});
}
revalidatePath("/settings");
revalidatePath(`/business/${slug}`);
return { success: true, slug };
} catch (error) {
console.error("Error toggling business public:", error);
return { success: false, error: "Fehler beim Aktualisieren" };
}
}

View file

@ -17,6 +17,7 @@ export const courseSchema = z
isStandingOrder: z.boolean(),
frequency: z.string().optional(),
weekdays: z.array(z.string()).optional(),
businessId: z.string().optional(),
})
.refine(
(data) => {
@ -54,6 +55,7 @@ export const publishedCourseSchema = z
isStandingOrder: z.boolean(),
frequency: z.string().optional(),
weekdays: z.array(z.string()).optional(),
businessId: z.string().optional(),
})
.refine(
(data) => {

View file

@ -16,6 +16,7 @@ export type Course = {
weekdays: string[];
status: string;
createdBy: string;
businessId: string | null;
createdAt: Date;
updatedAt: Date;
};

View file

@ -0,0 +1,236 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/src/components/ui/dialog";
import { bookCourse, checkUserBookingStatus } from "../../actions/booking-actions";
import { toast } from "sonner";
const levelConfig: Record<string, { label: string; bg: string; text: string }> = {
any: { label: "Jedes Niveau", bg: "bg-gray-200", text: "text-gray-700" },
beginner: { label: "Anfänger", bg: "bg-blue-200", text: "text-blue-700" },
advanced: { label: "Fortgeschrittene", bg: "bg-orange-200", text: "text-orange-700" },
pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" },
};
interface PublicCourse {
id: string;
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
maxParticipants: number;
price: number;
currentParticipants: number;
description?: string;
room?: string;
}
interface PublicCourseCardProps {
course: PublicCourse;
}
// Inner dialog content — only mounts when dialog opens, so booking status
// is checked lazily rather than on every card render.
const BookingDialog = ({
course,
open,
onOpenChange,
}: {
course: PublicCourse;
open: boolean;
onOpenChange: (v: boolean) => void;
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const isFull = course.currentParticipants >= course.maxParticipants;
const spotsLeft = course.maxParticipants - course.currentParticipants;
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
useEffect(() => {
if (!open) return;
setIsCheckingStatus(true);
checkUserBookingStatus(course.id).then((result) => {
if (result.success) setIsBooked(result.isBooked);
setIsCheckingStatus(false);
});
}, [open, course.id]);
const handleBooking = async () => {
setIsSubmitting(true);
try {
const result = await bookCourse(course.id);
if (result.success) {
toast.success(result.message);
setIsBooked(true);
} else if (result.error?.includes("angemeldet sein")) {
window.open("/login", "_blank");
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
}
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{course.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-gray-500">
<span>{formattedDate}</span>
<span>
{course.timeFrom} – {course.timeTo}
</span>
</div>
{course.sport.length > 0 && <p className="text-gray-600">{course.sport.join(", ")}</p>}
<div className="flex items-center justify-between">
<span
className={`text-xs font-semibold px-2 py-1 rounded-lg ${levelConfig[course.level]?.bg ?? "bg-gray-200"} ${levelConfig[course.level]?.text ?? "text-gray-700"}`}
>
{levelConfig[course.level]?.label ?? "Jedes Niveau"}
</span>
<span className="font-semibold">{formattedPrice}</span>
</div>
<div className={`text-sm font-medium ${isFull ? "text-red-600" : "text-gray-700"}`}>
{isFull
? "Ausgebucht"
: `${spotsLeft} ${spotsLeft === 1 ? "Platz" : "Plätze"} verfügbar`}
</div>
{/* Participant bar */}
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
style={{
width: `${course.maxParticipants > 0 ? (course.currentParticipants / course.maxParticipants) * 100 : 0}%`,
}}
/>
</div>
{course.description && (
<div
className="prose prose-sm max-w-none text-gray-600 pt-1"
dangerouslySetInnerHTML={{ __html: course.description }}
/>
)}
</div>
<div className="pt-2">
{isCheckingStatus ? (
<Button disabled className="w-full">
Lade Status…
</Button>
) : isBooked ? (
<Button disabled className="w-full">
✓ Bereits angemeldet
</Button>
) : (
<Button onClick={handleBooking} disabled={isSubmitting || isFull} className="w-full">
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Jetzt anmelden"}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
};
const PublicCourseCard = ({ course }: PublicCourseCardProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const isFull = course.currentParticipants >= course.maxParticipants;
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
const fillPercent =
course.maxParticipants > 0 ? (course.currentParticipants / course.maxParticipants) * 100 : 0;
return (
<>
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-gray-500 text-sm">{formattedDate}</p>
<p className="text-gray-500 text-sm">
{course.timeFrom} – {course.timeTo}
</p>
</div>
<h2 className="text-xl font-semibold text-gray-900">{course.name}</h2>
<p className="text-gray-500 text-sm">{course.sport.join(", ")}</p>
<div className="flex items-center justify-between">
<span
className={`text-xs font-semibold px-2 py-1 rounded-lg ${currentLevel.bg} ${currentLevel.text}`}
>
{currentLevel.label}
</span>
<span className="text-lg font-semibold">{formattedPrice}</span>
</div>
<div className="flex items-center justify-between">
<span className={isFull ? "text-red-600 font-semibold text-sm" : "text-gray-700 text-sm"}>
{course.currentParticipants}/{course.maxParticipants} Teilnehmer
</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
style={{ width: `${fillPercent}%` }}
/>
</div>
{isFull ? (
<Button disabled variant="outline" className="w-full">
Ausgebucht
</Button>
) : (
<Button className="w-full" onClick={() => setDialogOpen(true)}>
Jetzt anmelden
</Button>
)}
</div>
<BookingDialog course={course} open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
};
export default PublicCourseCard;

View file

@ -0,0 +1,197 @@
"use client";
import { useState, useTransition } from "react";
import { CalendarIcon, X } from "lucide-react";
import PublicCourseCard from "../components/PublicCourseCard";
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
import { Calendar } from "@/src/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
import { Button } from "@/src/components/ui/button";
interface Business {
id: string;
name: string;
address: string;
email: string;
title: string;
slug: string;
}
interface PublicCourse {
id: string;
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
maxParticipants: number;
price: number;
currentParticipants: number;
description?: string;
room?: string;
}
interface BusinessPublicViewProps {
business: Business;
initialCourses: PublicCourse[];
initialWindow: { from: Date; to: Date };
}
const BusinessPublicView = ({
business,
initialCourses,
initialWindow,
}: BusinessPublicViewProps) => {
const [loadedCourses, setLoadedCourses] = useState<PublicCourse[]>(initialCourses);
const [loadedWindow, setLoadedWindow] = useState(initialWindow);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [calendarOpen, setCalendarOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleDateSelect = (date: Date | undefined) => {
setCalendarOpen(false);
setSelectedDate(date);
if (!date) return;
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
// If within loaded window — filter client-side, no fetch needed
if (dayStart >= loadedWindow.from && dayEnd <= loadedWindow.to) {
return;
}
// Outside window — fetch ±15 day window around chosen date
const newFrom = new Date(date);
newFrom.setDate(newFrom.getDate() - 15);
newFrom.setHours(0, 0, 0, 0);
const newTo = new Date(date);
newTo.setDate(newTo.getDate() + 15);
newTo.setHours(23, 59, 59, 999);
startTransition(async () => {
const result = await getPublishedCoursesForBusiness(business.id, {
from: newFrom,
to: newTo,
});
if (result.success && result.courses) {
setLoadedCourses(result.courses as PublicCourse[]);
setLoadedWindow({ from: newFrom, to: newTo });
}
});
};
const handleReset = () => {
setSelectedDate(undefined);
if (
loadedWindow.from.getTime() !== initialWindow.from.getTime() ||
loadedWindow.to.getTime() !== initialWindow.to.getTime()
) {
startTransition(async () => {
const result = await getPublishedCoursesForBusiness(business.id, initialWindow);
if (result.success && result.courses) {
setLoadedCourses(result.courses as PublicCourse[]);
setLoadedWindow(initialWindow);
}
});
}
};
const displayedCourses = selectedDate
? loadedCourses.filter((c) => {
const d = new Date(c.date);
const dayStart = new Date(selectedDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(selectedDate);
dayEnd.setHours(23, 59, 59, 999);
return d >= dayStart && d <= dayEnd;
})
: loadedCourses;
const formattedSelectedDate = selectedDate
? selectedDate.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })
: null;
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-5xl mx-auto px-6 py-10">
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1>
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>}
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-600">
<span>{business.address}</span>
{business.email && (
<>
<span className="hidden sm:inline">·</span>
<a href={`mailto:${business.email}`} className="hover:underline text-blue-600">
{business.email}
</a>
</>
)}
</div>
</div>
</div>
{/* Courses Section */}
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<h2 className="text-2xl font-semibold text-gray-900">Unsere Kurse</h2>
<div className="flex items-center gap-2">
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<CalendarIcon size={16} />
{formattedSelectedDate ?? "Datum filtern"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
{selectedDate && (
<button
onClick={handleReset}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800"
title="Filter zurücksetzen"
>
<X size={14} />
</button>
)}
</div>
</div>
{isPending ? (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : displayedCourses.length === 0 ? (
<div className="text-center py-16 text-gray-500">
{selectedDate
? "Für diesen Tag sind keine Kurse verfügbar."
: "Aktuell sind keine Kurse verfügbar."}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{displayedCourses.map((course) => (
<PublicCourseCard key={course.id} course={course} />
))}
</div>
)}
</div>
</div>
);
};
export default BusinessPublicView;

View file

@ -8,7 +8,7 @@ import RichTextEditor from "@/src/components/layout/RichTextEditor";
import { Button } from "@/src/components/ui/button";
import { Checkbox } from "@/src/components/ui/checkbox";
import { Label } from "@/src/components/ui/label";
import { createCourse, updateCourse } from "../../actions/course-actions";
import { createCourse, updateCourse, getMyBusinesses } from "../../actions/course-actions";
import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import { toast } from "sonner";
@ -80,6 +80,8 @@ const CourseCreateView = ({
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
const [selectedBusinessId, setSelectedBusinessId] = useState("");
const [businesses, setBusinesses] = useState<Array<{ value: string; label: string }>>([]);
const {
deleteDialogOpen,
@ -93,6 +95,19 @@ const CourseCreateView = ({
onSuccess: () => router.push("/courses"),
});
// Load businesses from database
useEffect(() => {
const loadBusinesses = async () => {
const result = await getMyBusinesses();
if (result.success) {
const opts = result.businesses.map((b) => ({ value: b.id, label: b.name }));
setBusinesses(opts);
if (opts.length === 1) setSelectedBusinessId(opts[0].value);
}
};
loadBusinesses();
}, []);
// Load rooms from database
useEffect(() => {
const loadRooms = async () => {
@ -160,6 +175,7 @@ const CourseCreateView = ({
setIsStandingOrder(initialData.isStandingOrder || false);
setSelectedFrequency(initialData.frequency || "");
setSelectedWeekdays(initialData.weekdays || []);
setSelectedBusinessId(initialData.businessId ?? "");
}
}, [mode, initialData, customSports]);
@ -244,6 +260,7 @@ const CourseCreateView = ({
isStandingOrder,
frequency: selectedFrequency || undefined,
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
businessId: selectedBusinessId || undefined,
};
if (mode === "edit" && !courseId) {
@ -298,6 +315,15 @@ const CourseCreateView = ({
)}
</div>
<div className="mt-6 flex flex-col gap-4 max-w-xl">
{businesses.length > 1 && (
<GenericDropdown
label="Standort"
selected={selectedBusinessId}
onSelect={setSelectedBusinessId}
options={businesses}
placeholder="Standort auswählen"
/>
)}
<div>
<InputComponent
isLabel

View file

@ -15,6 +15,7 @@ const menuTitles: Record<string, string> = {
"/rooms/edit": "Bearbeite den Raum",
"/employee": "Mitarbeiter Übersicht",
"/employee/create": "Mitarbeiter erstellen",
"/settings": "Einstellungen",
};
const dynamicRoutes: Array<{

View file

@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { Switch } from "@/src/components/ui/switch";
import { Label } from "@/src/components/ui/label";
import { Link, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { toggleBusinessPublic } from "@/src/modules/courses/actions/course-actions";
interface BusinessPublicToggleProps {
businessId: string;
businessName: string;
initialIsPublic: boolean;
initialSlug: string | null;
}
const BusinessPublicToggle = ({
businessId,
businessName,
initialIsPublic,
initialSlug,
}: BusinessPublicToggleProps) => {
const [isPublic, setIsPublic] = useState(initialIsPublic);
const [slug, setSlug] = useState(initialSlug);
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const publicUrl = slug
? `${typeof window !== "undefined" ? window.location.origin : ""}/business/${slug}`
: null;
const handleToggle = async (checked: boolean) => {
setIsLoading(true);
const result = await toggleBusinessPublic(businessId, checked);
if (result.success) {
setIsPublic(checked);
if (result.slug) setSlug(result.slug);
toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt");
} else {
toast.error(result.error ?? "Fehler beim Aktualisieren");
}
setIsLoading(false);
};
const handleCopy = () => {
if (!publicUrl) return;
navigator.clipboard.writeText(publicUrl);
setCopied(true);
toast.success("Link kopiert");
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="border border-gray-200 rounded-xl p-5 bg-white flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">{businessName}</h3>
<p className="text-sm text-gray-500 mt-0.5">Öffentliche Kursseite</p>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`toggle-${businessId}`} className="text-sm text-gray-600 cursor-pointer">
{isPublic ? "Veröffentlicht" : "Nicht öffentlich"}
</Label>
<Switch
id={`toggle-${businessId}`}
checked={isPublic}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
{isPublic && publicUrl && (
<div className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<Link size={15} className="text-gray-400 shrink-0" />
<span className="text-sm text-gray-700 truncate flex-1">{publicUrl}</span>
<button
onClick={handleCopy}
className="shrink-0 p-1 rounded hover:bg-gray-200 transition"
title="Link kopieren"
>
{copied ? (
<Check size={15} className="text-green-600" />
) : (
<Copy size={15} className="text-gray-500" />
)}
</button>
</div>
)}
</div>
);
};
export default BusinessPublicToggle;

View file

@ -0,0 +1,58 @@
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
import BusinessPublicToggle from "../components/BusinessPublicToggle";
const SettingsView = async () => {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-2">Einstellungen</h1>
<p className="text-gray-500">Keine weiteren Einstellungen verfügbar.</p>
</div>
);
}
const businesses = await prisma.business.findMany({
where: { managerId: userData.id },
select: { id: true, name: true, isPublic: true, slug: true },
orderBy: { name: "asc" },
});
// If manager has exactly one business, auto-assign any unassigned courses to it
if (businesses.length === 1) {
await prisma.course.updateMany({
where: { createdBy: userData.id, businessId: null },
data: { businessId: businesses[0].id },
});
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Einstellungen</h1>
<p className="text-gray-500 mt-1">Verwalte die öffentlichen Seiten deiner Businesses</p>
</div>
{businesses.length === 0 ? (
<p className="text-gray-400">Noch kein Business angelegt.</p>
) : (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Öffentliche Seiten</h2>
{businesses.map((business) => (
<BusinessPublicToggle
key={business.id}
businessId={business.id}
businessName={business.name}
initialIsPublic={business.isPublic}
initialSlug={business.slug}
/>
))}
</div>
)}
</div>
);
};
export default SettingsView;