mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
added settings to make a publicly available site with all courses
This commit is contained in:
parent
06d1290921
commit
8718700fd8
19 changed files with 2756 additions and 2 deletions
1671
package-lock.json
generated
1671
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
5
src/app/(main)/settings/page.tsx
Normal file
5
src/app/(main)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import SettingsView from "@/src/modules/settings/ui/views/settings-view";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <SettingsView />;
|
||||
}
|
||||
45
src/app/business/[slug]/page.tsx
Normal file
45
src/app/business/[slug]/page.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
220
src/components/ui/calendar.tsx
Normal file
220
src/components/ui/calendar.tsx
Normal 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 }
|
||||
35
src/components/ui/switch.tsx
Normal file
35
src/components/ui/switch.tsx
Normal 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
15
src/lib/slug.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type Course = {
|
|||
weekdays: string[];
|
||||
status: string;
|
||||
createdBy: string;
|
||||
businessId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
|
|
|||
236
src/modules/courses/ui/components/PublicCourseCard.tsx
Normal file
236
src/modules/courses/ui/components/PublicCourseCard.tsx
Normal 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;
|
||||
197
src/modules/courses/ui/views/business-public-view.tsx
Normal file
197
src/modules/courses/ui/views/business-public-view.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
94
src/modules/settings/ui/components/BusinessPublicToggle.tsx
Normal file
94
src/modules/settings/ui/components/BusinessPublicToggle.tsx
Normal 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;
|
||||
58
src/modules/settings/ui/views/settings-view.tsx
Normal file
58
src/modules/settings/ui/views/settings-view.tsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue