added white labeling feature

This commit is contained in:
Rajpreet0 2026-08-26 17:54:21 +02:00
parent 80493f5040
commit 04e294bc10
31 changed files with 1457 additions and 35 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Business" ADD COLUMN "branding" JSONB;

View file

@ -112,6 +112,7 @@ model Business {
stripeAccountStatus String? // "pending" | "active" | "restricted" stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung.
branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING).
manager Manager @relation(fields: [managerId], references: [id]) manager Manager @relation(fields: [managerId], references: [id])
courses Course[] courses Course[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View file

@ -1,13 +1,42 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { AlertTriangle } from "lucide-react";
import { requireManager } from "@/src/lib/auth/getUser"; import { requireManager } from "@/src/lib/auth/getUser";
import { getBusinessBranding } from "@/src/modules/settings/actions/business-branding-actions";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
import SettingsBrandingPageView from "@/src/modules/settings/ui/views/settings-branding-page-view"; import SettingsBrandingPageView from "@/src/modules/settings/ui/views/settings-branding-page-view";
import SettingsLoading from "../loading"; import SettingsLoading from "../loading";
export default async function SettingsBrandingPage() { export default async function SettingsBrandingPage() {
await requireManager(); const manager = await requireManager();
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
const firstBusinessId = businesses[0]?.id;
const brandingResult = firstBusinessId ? await getBusinessBranding(firstBusinessId) : null;
// Ein fehlgeschlagener initialer Load darf nicht still in ein editierbares
// Default-Branding umgewandelt werden - sonst könnte "Speichern" ein
// tatsächlich vorhandenes, nur gerade nicht ladbares Branding überschreiben.
if (firstBusinessId && !brandingResult?.success) {
return (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{brandingResult?.error ?? "Branding-Einstellungen konnten nicht geladen werden."}</p>
</div>
);
}
const initialBranding = brandingResult?.success
? brandingResult.branding
: {
companyName: KUMASY_BRANDING.companyName,
logoUrl: null,
primaryColor: KUMASY_BRANDING.primaryColor,
accentColor: KUMASY_BRANDING.accentColor,
};
return ( return (
<Suspense fallback={<SettingsLoading />}> <Suspense fallback={<SettingsLoading />}>
<SettingsBrandingPageView /> <SettingsBrandingPageView businesses={businesses} initialBranding={initialBranding} />
</Suspense> </Suspense>
); );
} }

View file

@ -10,6 +10,7 @@ import {
generateBookingRefundEmail, generateBookingRefundEmail,
type BookingRefundReason, type BookingRefundReason,
} from "@/src/lib/mail/templates/booking-refund"; } from "@/src/lib/mail/templates/booking-refund";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
/** /**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive. * Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@ -621,7 +622,7 @@ async function sendBookingConfirmation(
timeFrom: true, timeFrom: true,
timeTo: true, timeTo: true,
coverImage: true, coverImage: true,
business: { select: { name: true, address: true } }, business: { select: { name: true, address: true, branding: true } },
}, },
}); });
if (!course || !course.business) return; if (!course || !course.business) return;
@ -638,6 +639,7 @@ async function sendBookingConfirmation(
myCoursesUrl: `${baseUrl}/courses/myCourses`, myCoursesUrl: `${baseUrl}/courses/myCourses`,
coverImageUrl: course.coverImage, coverImageUrl: course.coverImage,
companyName: process.env.COMPANY_NAME, companyName: process.env.COMPANY_NAME,
branding: toEmailBranding(course.business),
}); });
await sendMail({ to: recipientEmail, subject, html, text }); await sendMail({ to: recipientEmail, subject, html, text });
@ -657,7 +659,7 @@ async function sendBookingRefund(
where: { id: courseId }, where: { id: courseId },
select: { select: {
name: true, name: true,
business: { select: { name: true } }, business: { select: { name: true, branding: true } },
}, },
}); });
if (!course || !course.business) return; if (!course || !course.business) return;
@ -669,6 +671,7 @@ async function sendBookingRefund(
refundPercent: 100, refundPercent: 100,
reason, reason,
companyName: process.env.COMPANY_NAME, companyName: process.env.COMPANY_NAME,
branding: toEmailBranding(course.business),
}); });
await sendMail({ to: recipientEmail, subject, html, text }); await sendMail({ to: recipientEmail, subject, html, text });

View file

@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { createAdminClient } from "@/src/lib/supabase/admin";
import { getUserData, isManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
/**
* POST /api/upload/business-logo
* Erwartet multipart/form-data mit "file" und "businessId". Anders als
* /api/upload/course-image reicht hier ein globaler Manager-Check nicht -
* ein Logo wird über eine eigene Settings-Action gespeichert (nicht Teil
* eines größeren, ownership-geprüften Form-Saves), daher muss die
* Berechtigung für genau dieses Business schon beim Upload geprüft werden.
*
* getUserData() statt requireManager() - Letzteres löst bei fehlender Rolle
* einen next/navigation-Redirect aus, der in einer API-Route, die JSON
* zurückgeben soll, falsch wäre (und im try/catch hier sogar als 500
* fehlinterpretiert würde statt als redirect zu funktionieren).
*/
export async function POST(request: Request) {
try {
const userData = await getUserData();
if (!userData || !isManager(userData)) {
return NextResponse.json({ error: "Keine Berechtigung" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File | null;
const businessId = formData.get("businessId") as string | null;
if (!file || !businessId) {
return NextResponse.json({ error: "Datei und Business erforderlich" }, { status: 400 });
}
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true },
});
if (!business || business.managerId !== userData.id) {
return NextResponse.json(
{ error: "Keine Berechtigung für dieses Business" },
{ status: 403 }
);
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Nur Bilddateien erlaubt" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "Datei darf maximal 5MB groß sein" }, { status: 400 });
}
const extension =
file.name
.split(".")
.pop()
?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
const safePath = `business-logos/${businessId}/${Date.now()}.${extension}`;
const adminClient = createAdminClient();
const buffer = Buffer.from(await file.arrayBuffer());
const { data, error } = await adminClient.storage.from("avatars").upload(safePath, buffer, {
contentType: file.type,
upsert: true,
cacheControl: "3600",
});
if (error) {
console.error("Storage upload error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
const {
data: { publicUrl },
} = adminClient.storage.from("avatars").getPublicUrl(data.path);
return NextResponse.json({ publicUrl }, { status: 201 });
} catch (error) {
console.error("Error in /api/upload/business-logo:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View file

@ -26,7 +26,16 @@ export async function GET(
); );
} }
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />); // Ein fehlgeschlagener Logo-Nachlade-Versuch (z.B. unerreichbare/gelöschte
// Storage-URL) darf den Download nicht blockieren - zweiter Versuch ohne
// Logo, statt dem Manager/Kunden gar keine Rechnung auszuliefern.
let buffer: Buffer;
try {
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
} catch (renderError) {
console.error("Rechnungs-Rendering mit Logo fehlgeschlagen, Retry ohne Logo:", renderError);
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} includeLogo={false} />);
}
return new NextResponse(new Uint8Array(buffer), { return new NextResponse(new Uint8Array(buffer), {
headers: { headers: {

View file

@ -85,7 +85,20 @@ export async function GET(request: Request) {
} }
usedNames.add(fileName); usedNames.add(fileName);
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />); // Ein fehlgeschlagener Logo-Nachlade-Versuch für EINE Rechnung darf
// nicht das gesamte ZIP abbrechen - Retry ohne Logo für genau diese PDF.
let buffer: Buffer;
try {
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
} catch (renderError) {
console.error(
`Rechnungs-Rendering mit Logo fehlgeschlagen (${invoiceData.invoiceNumber}), Retry ohne Logo:`,
renderError
);
buffer = await renderToBuffer(
<InvoicePdfDocument data={invoiceData} includeLogo={false} />
);
}
archive.append(buffer, { name: fileName }); archive.append(buffer, { name: fileName });
} }

View file

@ -4,6 +4,8 @@ import {
getPublishedCoursesForBusiness, getPublishedCoursesForBusiness,
} from "@/src/modules/courses/actions/booking-actions"; } from "@/src/modules/courses/actions/booking-actions";
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view"; import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
import { businessBrandingSchema } from "@/src/modules/settings/schemas/business-branding-schema";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
interface BusinessPageProps { interface BusinessPageProps {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@ -18,9 +20,24 @@ export default async function BusinessPage({ params }: BusinessPageProps) {
notFound(); notFound();
} }
// Immer fertige, nicht-null Branding-Werte an die Client-View reichen - sie
// soll nie ein rohes Json-Blob oder null-Farben handhaben müssen.
const parsedBranding = businessBrandingSchema.safeParse(businessResult.business.branding);
const branding = parsedBranding.success
? parsedBranding.data
: {
companyName: businessResult.business.name,
logoUrl: null,
primaryColor: KUMASY_BRANDING.primaryColor,
accentColor: KUMASY_BRANDING.accentColor,
};
const business = { const business = {
...businessResult.business, ...businessResult.business,
slug: businessResult.business.slug, slug: businessResult.business.slug,
logoUrl: branding.logoUrl,
primaryColor: branding.primaryColor,
accentColor: branding.accentColor,
}; };
const from = new Date(); const from = new Date();

View file

@ -1,6 +1,9 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions"; import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
interface CancelPageProps { interface CancelPageProps {
@ -10,7 +13,9 @@ interface CancelPageProps {
export default async function CourseBookingCancelPage({ params }: CancelPageProps) { export default async function CourseBookingCancelPage({ params }: CancelPageProps) {
const { courseId } = await params; const { courseId } = await params;
const result = await getCourseSummaryForConfirmation(courseId); const result = await getCourseSummaryForConfirmation(courseId);
const businessSlug = result.success ? (result.course.business?.slug ?? null) : null; const business = result.success ? result.course.business : null;
const businessSlug = business?.slug ?? null;
const branding = business ? toEmailBranding(business) : null;
return ( return (
<div className="max-w-2xl mx-auto p-6"> <div className="max-w-2xl mx-auto p-6">
@ -26,10 +31,33 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
Die Zahlung wurde abgebrochen. Es wurde nichts berechnet. Die Zahlung wurde abgebrochen. Es wurde nichts berechnet.
</p> </p>
{branding?.logoUrl && (
<div className="relative w-12 h-12 mx-auto mb-6 rounded-md overflow-hidden bg-white border border-gray-200">
<Image
src={branding.logoUrl}
alt={branding.companyName}
fill
className="object-contain p-1"
/>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col sm:flex-row gap-3 justify-center">
{businessSlug && ( {businessSlug && (
<Link href={`/business/${businessSlug}`}> <Link href={`/business/${businessSlug}`}>
<Button className="w-full sm:w-auto">Erneut versuchen</Button> <Button
className="w-full sm:w-auto"
style={
branding
? {
backgroundColor: branding.accentColor,
color: getContrastTextColor(branding.accentColor),
}
: undefined
}
>
Erneut versuchen
</Button>
</Link> </Link>
)} )}
<Link href="/"> <Link href="/">

View file

@ -1,6 +1,8 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions"; import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react"; import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react";
interface SuccessPageProps { interface SuccessPageProps {
@ -14,6 +16,7 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
const course = result.success ? result.course : null; const course = result.success ? result.course : null;
const businessSlug = course?.business?.slug ?? null; const businessSlug = course?.business?.slug ?? null;
const branding = course?.business ? toEmailBranding(course.business) : null;
const formattedDate = course const formattedDate = course
? new Date(course.date).toLocaleDateString("de-DE", { ? new Date(course.date).toLocaleDateString("de-DE", {
@ -44,7 +47,19 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
{course && ( {course && (
<div className="bg-gray-50 rounded-lg p-5 text-left mb-6"> <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> <div className="flex items-start justify-between gap-3 mb-1">
<h2 className="text-lg font-bold text-gray-900">{course.name}</h2>
{branding?.logoUrl && (
<div className="relative w-10 h-10 shrink-0 rounded-md overflow-hidden bg-white border border-gray-200">
<Image
src={branding.logoUrl}
alt={branding.companyName}
fill
className="object-contain p-1"
/>
</div>
)}
</div>
{course.business?.name && ( {course.business?.name && (
<p className="text-sm text-gray-500 mb-4">{course.business.name}</p> <p className="text-sm text-gray-500 mb-4">{course.business.name}</p>
)} )}
@ -70,7 +85,12 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between"> <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-sm text-gray-500">Bezahlt</span>
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span> <span
className="text-lg font-bold"
style={{ color: branding?.accentColor ?? "#111827" }}
>
{formattedPrice}
</span>
</div> </div>
</div> </div>
)} )}

View file

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/src/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -12,6 +12,8 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache"; import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
import { executeBookingCancellation } from "./cancel-booking-shared"; import { executeBookingCancellation } from "./cancel-booking-shared";
import { combineDateAndTime } from "@/src/lib/course-time"; import { combineDateAndTime } from "@/src/lib/course-time";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
export type UserBooking = { export type UserBooking = {
bookingId: string; bookingId: string;
@ -37,6 +39,8 @@ export type UserBooking = {
trainersMap: Record<string, { label: string; pbSrc?: string }>; trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string; paymentStatus: string;
isPast: boolean; isPast: boolean;
businessName: string;
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
}; };
export async function getUserBookings(): Promise< export async function getUserBookings(): Promise<
@ -71,6 +75,7 @@ export async function getUserBookings(): Promise<
coverImage: true, coverImage: true,
description: true, description: true,
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } }, _count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: { select: { name: true, branding: true } },
}, },
}, },
}, },
@ -124,6 +129,7 @@ export async function getUserBookings(): Promise<
return bookingsRaw.map((b) => { return bookingsRaw.map((b) => {
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom); const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
return { return {
bookingId: b.id, bookingId: b.id,
courseId: b.course.id, courseId: b.course.id,
@ -143,6 +149,12 @@ export async function getUserBookings(): Promise<
trainersMap, trainersMap,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
isPast: courseStart < now, isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName,
businessBranding: {
logoUrl: branding.logoUrl,
accentColor: branding.accentColor,
accentTextColor: branding.accentTextColor,
},
} satisfies UserBooking; } satisfies UserBooking;
}); });
}, },
@ -261,6 +273,7 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
name: true, name: true,
slug: true, slug: true,
address: true, address: true,
branding: true,
}, },
}, },
}, },
@ -291,6 +304,7 @@ export async function getBusinessBySlug(slug: string) {
title: true, title: true,
slug: true, slug: true,
isPublic: true, isPublic: true,
branding: true,
}, },
}); });

View file

@ -3,6 +3,7 @@ import { refundStripePayment } from "@/src/lib/stripe-refund";
import { sendMail } from "@/src/lib/mail/resend"; import { sendMail } from "@/src/lib/mail/resend";
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund"; import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy"; import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
export type CancelBookingResult = export type CancelBookingResult =
| { success: true; refundedAmountCents: number; refundPercent: number } | { success: true; refundedAmountCents: number; refundPercent: number }
@ -23,7 +24,7 @@ async function sendCancellationEmail(
booking: { booking: {
userId: string | null; userId: string | null;
guestLeadId: string | null; guestLeadId: string | null;
course: { name: string; business: { name: string } | null }; course: { name: string; business: { name: string; branding: unknown } | null };
}, },
amountRefundedCents: number, amountRefundedCents: number,
originalAmountCents: number, originalAmountCents: number,
@ -56,6 +57,7 @@ async function sendCancellationEmail(
reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"), reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"),
originalAmountCents, originalAmountCents,
companyName: process.env.COMPANY_NAME, companyName: process.env.COMPANY_NAME,
branding: toEmailBranding(booking.course.business),
}); });
await sendMail({ to: recipientEmail, subject, html, text }); await sendMail({ to: recipientEmail, subject, html, text });
@ -94,7 +96,7 @@ export async function executeBookingCancellation(
date: true, date: true,
timeFrom: true, timeFrom: true,
businessId: true, businessId: true,
business: { select: { name: true } }, business: { select: { name: true, branding: true } },
}, },
}, },
}, },

View file

@ -10,6 +10,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { checkUserBookingStatus } from "../../actions/booking-actions"; import { checkUserBookingStatus } from "../../actions/booking-actions";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner"; import { toast } from "sonner";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und // useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
@ -75,6 +76,9 @@ interface PublicCourseCardProps {
course: PublicCourse; course: PublicCourse;
business?: BusinessInfo; business?: BusinessInfo;
defaultOpen?: boolean; // öffnet das Detail-Popup beim Mount (für Deep-Links) defaultOpen?: boolean; // öffnet das Detail-Popup beim Mount (für Deep-Links)
/** Business-Akzentfarbe für Buchen-Button/Fortschrittsbalken. Ohne Wert
* bleibt das App-Standarddesign (bg-blue) unverändert. */
accentColor?: string;
} }
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => { const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
@ -124,13 +128,18 @@ export const BookingDialog = ({
onOpenChange, onOpenChange,
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge // Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
paymentStatus, paymentStatus,
accentColor,
}: { }: {
course: PublicCourse; course: PublicCourse;
business?: BusinessInfo; business?: BusinessInfo;
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
paymentStatus?: string; paymentStatus?: string;
accentColor?: string;
}) => { }) => {
const accentButtonStyle = accentColor
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
: undefined;
const sanitizedDescription = useSanitizedHtml(course.description); const sanitizedDescription = useSanitizedHtml(course.description);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false); const [isBooked, setIsBooked] = useState(false);
@ -419,6 +428,7 @@ export const BookingDialog = ({
onClick={() => handleBooking()} onClick={() => handleBooking()}
disabled={isSubmitting || isFull} disabled={isSubmitting || isFull}
className="flex-1 sm:flex-initial" className="flex-1 sm:flex-initial"
style={!isFull ? accentButtonStyle : undefined}
> >
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"} {isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
</Button> </Button>
@ -457,6 +467,7 @@ export const BookingDialog = ({
onClick={() => handleBooking({ asGuest: true })} onClick={() => handleBooking({ asGuest: true })}
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())} disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
className="w-full" className="w-full"
style={accentButtonStyle}
> >
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"} {isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
</Button> </Button>
@ -468,7 +479,12 @@ export const BookingDialog = ({
); );
}; };
const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCourseCardProps) => { const PublicCourseCard = ({
course,
business,
defaultOpen = false,
accentColor,
}: PublicCourseCardProps) => {
const [dialogOpen, setDialogOpen] = useState(defaultOpen); const [dialogOpen, setDialogOpen] = useState(defaultOpen);
const cardSafeCurrent = Math.max(0, course.currentParticipants); const cardSafeCurrent = Math.max(0, course.currentParticipants);
@ -551,8 +567,11 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
<div className="w-full h-2 bg-white/40 rounded-full overflow-hidden"> <div className="w-full h-2 bg-white/40 rounded-full overflow-hidden">
<div <div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`} className={`h-full ${isFull ? "bg-red-500" : accentColor ? "" : "bg-blue-500"}`}
style={{ width: `${fillPercent}%` }} style={{
width: `${fillPercent}%`,
backgroundColor: !isFull && accentColor ? accentColor : undefined,
}}
/> />
</div> </div>
@ -561,7 +580,15 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
Ausgebucht Ausgebucht
</Button> </Button>
) : ( ) : (
<Button className="w-full" onClick={() => setDialogOpen(true)}> <Button
className="w-full"
onClick={() => setDialogOpen(true)}
style={
accentColor
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
: undefined
}
>
Jetzt anmelden Jetzt anmelden
</Button> </Button>
)} )}
@ -573,6 +600,7 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
business={business} business={business}
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
accentColor={accentColor}
/> />
</> </>
); );

View file

@ -122,7 +122,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
</span> </span>
</div> </div>
{/* Kursname */} {/* Business + Kursname */}
<div className="flex items-center gap-2">
{booking.businessBranding.logoUrl && (
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
<Image
src={booking.businessBranding.logoUrl}
alt={booking.businessName}
fill
className="object-contain p-0.5"
/>
</div>
)}
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
</div>
<h3 className="font-semibold text-gray-900 text-base leading-tight"> <h3 className="font-semibold text-gray-900 text-base leading-tight">
{booking.courseName} {booking.courseName}
</h3> </h3>
@ -170,6 +183,7 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
paymentStatus={effectiveStatus} paymentStatus={effectiveStatus}
accentColor={booking.businessBranding.accentColor}
/> />
<CancelBookingConfirmDialog <CancelBookingConfirmDialog

View file

@ -2,10 +2,12 @@
import { useState, useTransition, useMemo } from "react"; import { useState, useTransition, useMemo } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Image from "next/image";
import PublicCourseCard from "../components/PublicCourseCard"; import PublicCourseCard from "../components/PublicCourseCard";
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions"; import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
import { Calendar } from "@/src/components/ui/calendar"; import { Calendar } from "@/src/components/ui/calendar";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
interface Business { interface Business {
id: string; id: string;
@ -14,6 +16,9 @@ interface Business {
email: string; email: string;
title: string; title: string;
slug: string; slug: string;
logoUrl: string | null;
primaryColor: string;
accentColor: string;
} }
interface TrainerProfile { interface TrainerProfile {
@ -119,6 +124,21 @@ const BusinessPublicView = ({
// Reset active sport if it no longer exists after a window refetch // Reset active sport if it no longer exists after a window refetch
const resolvedSport = allSports.includes(activeSport) ? activeSport : "Alle"; const resolvedSport = allSports.includes(activeSport) ? activeSport : "Alle";
const accentTextColor = useMemo(
() => getContrastTextColor(business.accentColor),
[business.accentColor]
);
const primaryTextColor = useMemo(
() => getContrastTextColor(business.primaryColor),
[business.primaryColor]
);
// Sekundärtext (Adresse/Titel) etwas dezenter als die Überschrift, aber im
// selben Hell/Dunkel-Lager - sonst könnte z.B. bei dunklem primaryColor eine
// "abgeschwächte" graue Sekundärfarbe erneut unlesbar werden.
const primaryMutedTextColor =
primaryTextColor === "#FFFFFF" ? "rgba(255,255,255,0.75)" : "#4B5563";
const displayedCourses = useMemo(() => { const displayedCourses = useMemo(() => {
const dayStart = new Date(selectedDate); const dayStart = new Date(selectedDate);
dayStart.setHours(0, 0, 0, 0); dayStart.setHours(0, 0, 0, 0);
@ -145,16 +165,44 @@ const BusinessPublicView = ({
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
<div className="border-b border-gray-200 bg-white/90 backdrop-blur-sm"> <div
className="border-b border-gray-200 backdrop-blur-sm"
style={{ backgroundColor: business.primaryColor }}
>
<div className="max-w-6xl mx-auto px-6 py-10"> <div className="max-w-6xl mx-auto px-6 py-10">
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1> <div className="flex items-center gap-4">
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>} {business.logoUrl && (
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-600"> <div className="relative w-14 h-14 shrink-0 rounded-lg overflow-hidden bg-white border border-gray-200">
<Image
src={business.logoUrl}
alt={business.name}
fill
className="object-contain p-1"
/>
</div>
)}
<h1 className="text-4xl font-bold" style={{ color: primaryTextColor }}>
{business.name}
</h1>
</div>
{business.title && (
<p className="text-xl mt-1" style={{ color: primaryMutedTextColor }}>
{business.title}
</p>
)}
<div
className="mt-4 flex flex-col sm:flex-row gap-2 text-sm"
style={{ color: primaryMutedTextColor }}
>
<span>{business.address}</span> <span>{business.address}</span>
{business.email && ( {business.email && (
<> <>
<span className="hidden sm:inline">·</span> <span className="hidden sm:inline">·</span>
<a href={`mailto:${business.email}`} className="hover:underline text-blue-600"> <a
href={`mailto:${business.email}`}
className="hover:underline"
style={{ color: primaryTextColor }}
>
{business.email} {business.email}
</a> </a>
</> </>
@ -193,9 +241,14 @@ const BusinessPublicView = ({
onClick={() => setActiveSport(sport)} onClick={() => setActiveSport(sport)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${ className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
resolvedSport === sport resolvedSport === sport
? "bg-gray-900 text-white" ? ""
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50" : "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
}`} }`}
style={
resolvedSport === sport
? { backgroundColor: business.accentColor, color: accentTextColor }
: undefined
}
> >
{sport === "Alle" ? "Alle" : capitalize(sport)} {sport === "Alle" ? "Alle" : capitalize(sport)}
</button> </button>
@ -221,6 +274,7 @@ const BusinessPublicView = ({
course={course} course={course}
business={{ address: business.address, email: business.email }} business={{ address: business.address, email: business.email }}
defaultOpen={course.id === deepLinkCourseId} defaultOpen={course.id === deepLinkCourseId}
accentColor={business.accentColor}
/> />
))} ))}
</div> </div>

View file

@ -4,6 +4,8 @@ import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser"; import { requireManager } from "@/src/lib/auth/getUser";
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import { combineDateAndTime } from "@/src/lib/course-time"; import { combineDateAndTime } from "@/src/lib/course-time";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
export type KundeBooking = { export type KundeBooking = {
bookingId: string; bookingId: string;
@ -31,6 +33,8 @@ export type KundeBooking = {
trainersMap: Record<string, { label: string; pbSrc?: string }>; trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string; paymentStatus: string;
isPast: boolean; isPast: boolean;
businessName: string;
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
}; };
export type KundeDetail = { export type KundeDetail = {
@ -98,6 +102,7 @@ export async function getKundeDetail(
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/ // Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr. // refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } }, _count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: { select: { name: true, branding: true } },
}, },
}, },
}, },
@ -154,6 +159,7 @@ export async function getKundeDetail(
const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => { const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => {
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom); const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
return { return {
bookingId: b.id, bookingId: b.id,
courseId: b.course.id, courseId: b.course.id,
@ -173,6 +179,12 @@ export async function getKundeDetail(
trainersMap, trainersMap,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
isPast: courseStart < now, isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName,
businessBranding: {
logoUrl: branding.logoUrl,
accentColor: branding.accentColor,
accentTextColor: branding.accentTextColor,
},
}; };
}); });

View file

@ -111,7 +111,20 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
</span> </span>
</div> </div>
{/* Kursname */} {/* Business + Kursname */}
<div className="flex items-center gap-2">
{booking.businessBranding.logoUrl && (
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
<Image
src={booking.businessBranding.logoUrl}
alt={booking.businessName}
fill
className="object-contain p-0.5"
/>
</div>
)}
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
</div>
<h3 className="font-semibold text-gray-900 text-base leading-tight"> <h3 className="font-semibold text-gray-900 text-base leading-tight">
{booking.courseName} {booking.courseName}
</h3> </h3>
@ -160,6 +173,7 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
paymentStatus={booking.paymentStatus} paymentStatus={booking.paymentStatus}
accentColor={booking.businessBranding.accentColor}
/> />
<CancelBookingConfirmDialog <CancelBookingConfirmDialog

View file

@ -0,0 +1,95 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { revalidatePath, revalidateTag } from "next/cache";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
import { businessBrandingSchema, type BusinessBranding } from "../schemas/business-branding-schema";
export async function getBusinessBranding(
businessId: string
): Promise<{ success: true; branding: BusinessBranding } | { success: false; error: string }> {
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
const manager = await requireManager();
try {
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true, name: true, branding: true },
});
if (!business || business.managerId !== manager.id) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
// Kein gespeicherter Wert (null) ist der einzige erwartete "leer"-Fall -
// Firmenname wird mit dem Business-Namen vorbefüllt (bessere UX beim
// ersten Öffnen), Farben mit dem Kumasy-Standard. Ein VORHANDENER, aber
// nicht parsebarer Wert ist dagegen ein echter Fehler und wird NICHT
// still zu einem editierbaren Default - sonst könnte ein Manager per
// "Speichern" versehentlich echte, nur nicht mehr lesbare Branding-Daten
// überschreiben, ohne je einen Fehler gesehen zu haben.
if (business.branding === null) {
return {
success: true,
branding: {
companyName: business.name,
logoUrl: null,
primaryColor: KUMASY_BRANDING.primaryColor,
accentColor: KUMASY_BRANDING.accentColor,
},
};
}
const parsed = businessBrandingSchema.safeParse(business.branding);
if (!parsed.success) {
console.error("Invalid stored business branding:", parsed.error);
return { success: false, error: "Branding-Einstellungen konnten nicht geladen werden." };
}
return { success: true, branding: parsed.data };
} catch (error) {
console.error("Error fetching business branding:", error);
return { success: false, error: "Branding-Einstellungen konnten nicht geladen werden." };
}
}
export async function saveBusinessBranding(
businessId: string,
branding: BusinessBranding
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
try {
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true },
});
if (!business || business.managerId !== manager.id) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const parsed = businessBrandingSchema.safeParse(branding);
if (!parsed.success) {
return { success: false, error: "Ungültige Branding-Einstellungen." };
}
const updated = await prisma.business.update({
where: { id: businessId },
data: { branding: parsed.data },
select: { slug: true },
});
revalidatePath("/settings/branding");
// Die öffentliche Buchungsseite cached getBusinessBySlug unter genau
// diesem Tag (siehe booking-actions.ts) - ohne Revalidierung würde sie
// bis zu 1h altes Branding weiter ausliefern.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (updated.slug) (revalidateTag as any)(`business-slug-${updated.slug}`);
return { success: true };
} catch (error) {
console.error("Error saving business branding:", error);
return { success: false, error: "Branding-Einstellungen konnten nicht gespeichert werden." };
}
}

View file

@ -0,0 +1,24 @@
/**
* Leitet eine gut lesbare Textfarbe (schwarz oder weiß) für einen gegebenen
* Hex-Hintergrund ab, statt sie als eigenes Formularfeld zu erheben - so
* bleibt der Farbwähler bei zwei Farben (Primär/Akzent), ohne dass eine falsch
* gewählte Akzentfarbe zu unlesbarem Button-/Badge-Text führt.
*
* WCAG-relative Luminanz (gammakorrigiert) mit Schwellwert 0.5: unterhalb
* gilt der Hintergrund als dunkel genug für weißen Text, sonst schwarz.
*/
export function getContrastTextColor(hex: string): string {
const match = /^#([0-9a-fA-F]{6})$/.exec(hex);
if (!match) return "#1E293B";
const r = parseInt(match[1].slice(0, 2), 16) / 255;
const g = parseInt(match[1].slice(2, 4), 16) / 255;
const b = parseInt(match[1].slice(4, 6), 16) / 255;
const toLinear = (channel: number) =>
channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
return luminance > 0.5 ? "#1E293B" : "#FFFFFF";
}

View file

@ -0,0 +1,27 @@
import { KUMASY_BRANDING, type EmailBranding } from "@/src/lib/mail/templates/tokens";
import { businessBrandingSchema } from "../schemas/business-branding-schema";
import { getContrastTextColor } from "./contrast";
/**
* Wandelt das rohe `Business.branding`-Json in ein `EmailBranding`-Objekt um,
* das direkt an die Mail-Templates/PDF/öffentliche Seite weitergereicht
* werden kann. Wirft NIE - jeder Aufrufer läuft in einem Hot Path (Stripe-
* Webhook, PDF-Download, öffentliche Buchungsseite), der trotz fehlendem oder
* korruptem Branding weiterlaufen muss. Das unterscheidet diese Funktion
* bewusst vom Settings-Read-Pfad (getBusinessBranding), der korrupte Daten
* als harten Fehler behandelt, damit "Speichern" nie versehentlich echte
* Branding-Daten überschreibt.
*/
export function toEmailBranding(business: { name: string; branding: unknown }): EmailBranding {
const parsed = businessBrandingSchema.safeParse(business.branding);
if (!parsed.success) return KUMASY_BRANDING;
return {
companyName: parsed.data.companyName,
logoUrl: parsed.data.logoUrl,
logoAlt: parsed.data.companyName,
primaryColor: parsed.data.primaryColor,
accentColor: parsed.data.accentColor,
accentTextColor: getContrastTextColor(parsed.data.accentColor),
};
}

View file

@ -0,0 +1,17 @@
import { z } from "zod";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
// Nur 6-stelliges Hex statt der breiteren Farbnotation, die sanitizeColor in
// den Mail-Templates akzeptiert (dort defense-in-depth für beliebige Quellen) -
// dieses Schema validiert ausschließlich Werte aus <input type="color">, das
// immer #rrggbb liefert.
const hexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Ungültige Farbe");
export const businessBrandingSchema = z.object({
companyName: z.string().trim().min(1).max(100),
logoUrl: z.string().url().nullable().default(null),
primaryColor: hexColorSchema.default(KUMASY_BRANDING.primaryColor),
accentColor: hexColorSchema.default(KUMASY_BRANDING.accentColor),
});
export type BusinessBranding = z.infer<typeof businessBrandingSchema>;

View file

@ -0,0 +1,243 @@
"use client";
import { useId, useState } from "react";
import Image from "next/image";
import { Loader2, ImagePlus, X, Mail, Globe, FileText } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import { Progress } from "@/src/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { downsizeImage } from "@/src/lib/image/downsize";
import { getContrastTextColor } from "../../lib/contrast";
import type { BusinessBranding } from "../../schemas/business-branding-schema";
import ColorPickerField from "./ColorPickerField";
import LogoCropDialog from "./LogoCropDialog";
import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview";
import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview";
import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview";
interface BusinessBrandingEditorProps {
businessId: string;
initialBranding: BusinessBranding;
onSave: (branding: BusinessBranding) => Promise<boolean>;
isSaving: boolean;
}
const BusinessBrandingEditor = ({
businessId,
initialBranding,
onSave,
isSaving,
}: BusinessBrandingEditorProps) => {
const idPrefix = useId();
const [companyName, setCompanyName] = useState(initialBranding.companyName);
const [logoUrl, setLogoUrl] = useState(initialBranding.logoUrl);
const [primaryColor, setPrimaryColor] = useState(initialBranding.primaryColor);
const [accentColor, setAccentColor] = useState(initialBranding.accentColor);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
const [pendingCropFile, setPendingCropFile] = useState<File | null>(null);
const accentTextColor = getContrastTextColor(accentColor);
const uploadLogo = async (file: File) => {
setIsUploadingLogo(true);
setUploadPhase("optimizing");
try {
const resizedFile = await downsizeImage(file);
setUploadPhase("uploading");
const formData = new FormData();
formData.append("file", resizedFile);
formData.append("businessId", businessId);
const res = await fetch("/api/upload/business-logo", { method: "POST", body: formData });
const json = await res.json();
if (!res.ok) throw new Error(json.error);
setLogoUrl(json.publicUrl);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen");
} finally {
setUploadPhase(null);
setIsUploadingLogo(false);
}
};
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Auswahl im <input> zurücksetzen, damit dieselbe Datei danach erneut
// gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht).
e.target.value = "";
if (!file) return;
setPendingCropFile(file);
};
const handleCropConfirm = (croppedFile: File) => {
setPendingCropFile(null);
uploadLogo(croppedFile);
};
const handleSave = async () => {
const trimmedName = companyName.trim();
if (!trimmedName) {
toast.error("Firmenname darf nicht leer sein.");
return;
}
await onSave({
companyName: trimmedName,
logoUrl,
primaryColor,
accentColor,
});
};
return (
<div className="grid gap-8 lg:grid-cols-2 lg:items-start">
{/* Formular */}
<div className="space-y-6 rounded-xl border border-border bg-card p-6">
{/* Firmenname */}
<div className="flex flex-col gap-1.5">
<label
htmlFor={`${idPrefix}-company-name`}
className="text-sm font-medium text-foreground"
>
Firmenname
</label>
<p className="text-xs text-muted-foreground">
Erscheint in E-Mails, auf der Buchungsseite und auf Rechnungen.
</p>
<Input
id={`${idPrefix}-company-name`}
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
maxLength={100}
className="max-w-sm"
/>
</div>
{/* Logo */}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-foreground">Logo</span>
<p className="text-xs text-muted-foreground">
Wird in E-Mails, auf der Buchungsseite und auf Rechnungen angezeigt. Ohne Logo wird der
Firmenname als Schriftzug verwendet.
</p>
<div className="mt-1">
{logoUrl ? (
<div className="relative h-24 w-24 overflow-hidden rounded-xl border border-border bg-white">
<Image src={logoUrl} alt="Logo" fill className="object-contain p-2" sizes="96px" />
<button
type="button"
onClick={() => setLogoUrl(null)}
aria-label="Logo entfernen"
className="absolute top-1 right-1 rounded-full bg-white p-1 shadow hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<X size={14} />
</button>
</div>
) : (
<label className="flex h-24 w-24 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-input px-2 text-center transition-colors hover:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50">
{isUploadingLogo ? (
<div className="flex w-full flex-col items-center gap-1.5 px-2">
<Loader2 size={16} className="animate-spin text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">
{uploadPhase === "optimizing" ? "Optimiert…" : "Lädt hoch…"}
</span>
<Progress value={uploadPhase === "optimizing" ? 40 : 85} className="w-full" />
</div>
) : (
<>
<ImagePlus size={18} className="mb-1 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Auswählen</span>
</>
)}
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isUploadingLogo}
onChange={handleFileSelected}
/>
</label>
)}
</div>
<p className="text-xs text-muted-foreground">Max. 5 MB, danach zuschneidbar.</p>
</div>
{/* Farben */}
<div className="flex flex-wrap gap-6">
<ColorPickerField
label="Primärfarbe"
description="Hintergrund im E-Mail-Header."
value={primaryColor}
onChange={setPrimaryColor}
/>
<ColorPickerField
label="Akzentfarbe"
description="Für Buttons und Hervorhebungen."
value={accentColor}
onChange={setAccentColor}
/>
</div>
<Button onClick={handleSave} disabled={isSaving || isUploadingLogo}>
{isSaving ? "Speichert…" : "Speichern"}
</Button>
</div>
{/* Vorschau */}
<div className="lg:sticky lg:top-6">
<Tabs defaultValue="email">
<TabsList>
<TabsTrigger value="email">
<Mail size={14} className="mr-1.5" />
E-Mail
</TabsTrigger>
<TabsTrigger value="public-page">
<Globe size={14} className="mr-1.5" />
Buchungsseite
</TabsTrigger>
<TabsTrigger value="invoice">
<FileText size={14} className="mr-1.5" />
Rechnung
</TabsTrigger>
</TabsList>
<TabsContent value="email" className="mt-4">
<EmailBrandingPreview
companyName={companyName || "Firmenname"}
logoUrl={logoUrl}
primaryColor={primaryColor}
accentColor={accentColor}
accentTextColor={accentTextColor}
/>
</TabsContent>
<TabsContent value="public-page" className="mt-4">
<PublicPageBrandingPreview
companyName={companyName || "Firmenname"}
logoUrl={logoUrl}
accentColor={accentColor}
accentTextColor={accentTextColor}
/>
</TabsContent>
<TabsContent value="invoice" className="mt-4">
<InvoiceBrandingPreview
companyName={companyName || "Firmenname"}
logoUrl={logoUrl}
accentColor={accentColor}
/>
</TabsContent>
</Tabs>
</div>
<LogoCropDialog
file={pendingCropFile}
onCancel={() => setPendingCropFile(null)}
onConfirm={handleCropConfirm}
/>
</div>
);
};
export default BusinessBrandingEditor;

View file

@ -0,0 +1,93 @@
"use client";
import { useId, useRef, useState } from "react";
import { Input } from "@/src/components/ui/input";
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
interface ColorPickerFieldProps {
label: string;
description?: string;
value: string;
onChange: (value: string) => void;
}
/**
* Natives <input type="color"> sieht je nach Browser/OS sehr unterschiedlich
* aus (voller eckiger Farbklecks, kein eigenes Design, inkonsistentes
* Hover/Focus) - deshalb bleibt es hier unsichtbar und dient nur als
* Steuerung für den nativen Farbwähler-Dialog. Sichtbar ist ein eigener,
* ins Design passender Swatch-Button plus editierbares Hex-Textfeld.
*/
const ColorPickerField = ({ label, description, value, onChange }: ColorPickerFieldProps) => {
const id = useId();
const colorInputRef = useRef<HTMLInputElement>(null);
const [hexDraft, setHexDraft] = useState(value);
const [hexError, setHexError] = useState(false);
const handleNativeColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setHexDraft(e.target.value);
setHexError(false);
};
const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setHexDraft(next);
if (HEX_PATTERN.test(next)) {
onChange(next);
setHexError(false);
} else {
setHexError(true);
}
};
const handleHexBlur = () => {
// Ungültige Eingabe beim Verlassen des Felds auf den zuletzt gültigen
// Wert zurücksetzen, statt einen kaputten Hex-Code stehen zu lassen.
if (!HEX_PATTERN.test(hexDraft)) {
setHexDraft(value);
setHexError(false);
}
};
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={`${id}-hex`} className="text-sm font-medium text-foreground">
{label}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => colorInputRef.current?.click()}
className="relative h-9 w-9 shrink-0 rounded-md border border-input shadow-xs transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
style={{ backgroundColor: HEX_PATTERN.test(value) ? value : undefined }}
aria-label={`${label} auswählen`}
>
<input
ref={colorInputRef}
type="color"
value={value}
onChange={handleNativeColorChange}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-hidden="true"
tabIndex={-1}
/>
</button>
<Input
id={`${id}-hex`}
value={hexDraft}
onChange={handleHexInputChange}
onBlur={handleHexBlur}
aria-invalid={hexError}
maxLength={7}
className="w-28 font-mono uppercase"
spellCheck={false}
/>
</div>
</div>
);
};
export default ColorPickerField;

View file

@ -0,0 +1,170 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { Button } from "@/src/components/ui/button";
import { Slider } from "@/src/components/ui/slider";
const CANVAS_SIZE = 320;
const OUTPUT_SIZE = 512;
interface LogoCropDialogProps {
file: File | null;
onCancel: () => void;
onConfirm: (croppedFile: File) => void;
}
/**
* Einfacher Zoom/Pan-Crop direkt per Canvas, ohne zusätzliche Dependency -
* analog zum bestehenden Canvas-Ansatz in downsizeImage. Schneidet immer
* quadratisch zu (Logos werden überall als Quadrat/Kreis dargestellt: Header,
* Rechnung, öffentliche Seite), damit kein gestrecktes oder schief
* zugeschnittenes Logo entstehen kann.
*/
const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragState = useRef<{
startX: number;
startY: number;
origin: { x: number; y: number };
} | null>(null);
const draw = useCallback(
(currentZoom: number, currentOffset: { x: number; y: number }, img?: HTMLImageElement) => {
const canvas = canvasRef.current;
const image = img ?? imageRef.current;
if (!canvas || !image) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = CANVAS_SIZE;
canvas.height = CANVAS_SIZE;
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
ctx.fillStyle = "#f5f5f5";
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
const scale = baseScale * currentZoom;
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const x = (CANVAS_SIZE - drawWidth) / 2 + currentOffset.x;
const y = (CANVAS_SIZE - drawHeight) / 2 + currentOffset.y;
ctx.drawImage(image, x, y, drawWidth, drawHeight);
},
[]
);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
const img = new window.Image();
img.onload = () => {
imageRef.current = img;
setZoom(1);
setOffset({ x: 0, y: 0 });
draw(1, { x: 0, y: 0 }, img);
};
img.src = url;
return () => URL.revokeObjectURL(url);
}, [file, draw]);
const handleZoomChange = ([value]: number[]) => {
setZoom(value);
draw(value, offset);
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
dragState.current = { startX: e.clientX, startY: e.clientY, origin: offset };
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragState.current) return;
const dx = e.clientX - dragState.current.startX;
const dy = e.clientY - dragState.current.startY;
const next = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy };
setOffset(next);
draw(zoom, next);
};
const handlePointerUp = () => {
dragState.current = null;
};
const handleConfirm = () => {
const image = imageRef.current;
if (!image) return;
const outputCanvas = document.createElement("canvas");
outputCanvas.width = OUTPUT_SIZE;
outputCanvas.height = OUTPUT_SIZE;
const ctx = outputCanvas.getContext("2d");
if (!ctx) return;
const outputScale = OUTPUT_SIZE / CANVAS_SIZE;
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
const scale = baseScale * zoom * outputScale;
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const x = (OUTPUT_SIZE - drawWidth) / 2 + offset.x * outputScale;
const y = (OUTPUT_SIZE - drawHeight) / 2 + offset.y * outputScale;
ctx.drawImage(image, x, y, drawWidth, drawHeight);
outputCanvas.toBlob((blob) => {
if (!blob) return;
onConfirm(new File([blob], "logo.png", { type: "image/png" }));
}, "image/png");
};
return (
<Dialog open={!!file} onOpenChange={(open) => !open && onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Logo zuschneiden</DialogTitle>
<DialogDescription>
Ziehe das Bild, um den Ausschnitt zu verschieben, und nutze den Regler zum Zoomen.
</DialogDescription>
</DialogHeader>
<div className="flex justify-center">
<canvas
ref={canvasRef}
width={CANVAS_SIZE}
height={CANVAS_SIZE}
className="rounded-lg border border-input touch-none cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
/>
</div>
<div className="flex items-center gap-3 px-1">
<span className="text-xs text-muted-foreground shrink-0">Zoom</span>
<Slider min={1} max={3} step={0.05} value={[zoom]} onValueChange={handleZoomChange} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>
Abbrechen
</Button>
<Button onClick={handleConfirm}>Übernehmen</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default LogoCropDialog;

View file

@ -0,0 +1,57 @@
interface EmailBrandingPreviewProps {
companyName: string;
logoUrl: string | null;
primaryColor: string;
accentColor: string;
accentTextColor: string;
}
/**
* Bildet den Header/Footer-Aufbau von renderEmailLayout (src/lib/mail/templates/layout.ts)
* nach - dieselbe Struktur (Header-Band in primaryColor, Logo oder
* Text-Wordmark, Akzentfarbe am Button, Footer mit Firmenname), damit die
* Vorschau nicht von der tatsächlich versendeten Mail abweicht.
*/
const EmailBrandingPreview = ({
companyName,
logoUrl,
primaryColor,
accentColor,
accentTextColor,
}: EmailBrandingPreviewProps) => {
return (
<div className="overflow-hidden rounded-xl border border-border bg-white shadow-sm">
<div
className="flex flex-col items-center px-8 py-10 text-center"
style={{ backgroundColor: primaryColor }}
>
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- Vorschau spiegelt das <img>-Markup der echten Mail (kein next/image dort möglich)
<img src={logoUrl} alt={companyName} className="mb-4 max-h-10 max-w-40 object-contain" />
) : (
<p className="mb-3 text-sm font-bold tracking-wide text-white uppercase">{companyName}</p>
)}
<h1 className="text-2xl font-semibold text-white">Buchung bestätigt</h1>
</div>
<div className="space-y-4 p-8">
<p className="text-sm text-gray-700">
Hallo, deine Buchung für <strong>Beispielkurs</strong> wurde bestätigt.
</p>
<span
className="inline-block rounded-full px-5 py-2 text-sm font-semibold"
style={{ backgroundColor: accentColor, color: accentTextColor }}
>
Meine Buchungen ansehen
</span>
</div>
<div className="border-t border-gray-200 bg-gray-50 px-8 py-6 text-center">
<p className="text-sm text-gray-600">{companyName}</p>
<p className="mt-1 text-xs text-gray-400">Diese E-Mail wurde automatisch generiert.</p>
</div>
</div>
);
};
export default EmailBrandingPreview;

View file

@ -0,0 +1,53 @@
interface InvoiceBrandingPreviewProps {
companyName: string;
logoUrl: string | null;
accentColor: string;
}
/**
* Bildet den Kopf/Summen-Bereich von invoice-pdf-document.tsx nach (Logo
* rechts oben, Akzentfarbe an "Rechnung"-Titel und Gesamtbetrag) - das PDF
* selbst lässt sich hier nicht rendern, daher als HTML-Annäherung an dieselbe
* Struktur.
*/
const InvoiceBrandingPreview = ({
companyName,
logoUrl,
accentColor,
}: InvoiceBrandingPreviewProps) => {
return (
<div className="rounded-xl border border-border bg-white p-6 font-serif text-sm text-gray-900 shadow-sm">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold">{companyName || "Firmenname"}</p>
<p className="text-xs text-gray-500">Musterstraße 1, 12345 Musterstadt</p>
<p className="text-xs text-gray-500">kontakt@example.com</p>
</div>
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- Vorschau der PDF-Struktur, kein DOM-Bild im echten Dokument
<img src={logoUrl} alt={companyName} className="h-16 w-16 object-contain" />
) : null}
</div>
<p className="mt-8 text-lg font-bold" style={{ color: accentColor }}>
Rechnung
</p>
<div className="mt-4 flex justify-between border-b border-gray-200 pb-2 text-xs font-semibold text-gray-500">
<span>Leistung</span>
<span>Betrag</span>
</div>
<div className="flex justify-between border-b border-gray-200 py-3 text-xs">
<span>Beispielkurs · Termin: 01.01.2026</span>
<span>49,00 €</span>
</div>
<div className="mt-3 flex justify-end gap-6 text-sm font-bold">
<span>Gesamtbetrag</span>
<span style={{ color: accentColor }}>49,00 €</span>
</div>
</div>
);
};
export default InvoiceBrandingPreview;

View file

@ -0,0 +1,66 @@
interface PublicPageBrandingPreviewProps {
companyName: string;
logoUrl: string | null;
accentColor: string;
accentTextColor: string;
}
/**
* Bildet den Header-Aufbau von business-public-view.tsx nach (Logo neben dem
* Firmennamen, Akzentfarbe am aktiven Filter-Pill und am Mailto-Link), damit
* die Vorschau exakt zeigt, wie die Werte auf der echten Buchungsseite
* ankommen.
*/
const PublicPageBrandingPreview = ({
companyName,
logoUrl,
accentColor,
accentTextColor,
}: PublicPageBrandingPreviewProps) => {
return (
<div className="overflow-hidden rounded-xl border border-border bg-gray-50">
<div className="border-b border-gray-200 bg-white/90 px-6 py-6">
<div className="flex items-center gap-3">
{logoUrl ? (
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-white">
{/* eslint-disable-next-line @next/next/no-img-element -- Vorschau, kein Next-Image-Optimizer nötig für kleinen Vorschau-Kontext */}
<img src={logoUrl} alt={companyName} className="h-full w-full object-contain p-1" />
</div>
) : null}
<h2 className="text-xl font-bold text-gray-900">{companyName || "Firmenname"}</h2>
</div>
<div className="mt-3 flex gap-2 text-xs text-gray-600">
<span>Musterstraße 1, 12345 Musterstadt</span>
<span>·</span>
<a
href="#"
className="hover:underline"
style={{ color: accentColor }}
onClick={(e) => e.preventDefault()}
>
kontakt@example.com
</a>
</div>
</div>
<div className="px-6 py-5">
<div className="flex flex-wrap gap-2">
<span
className="rounded-full px-4 py-1.5 text-sm font-medium"
style={{ backgroundColor: accentColor, color: accentTextColor }}
>
Alle
</span>
<span className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-sm font-medium text-gray-600">
Yoga
</span>
<span className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-sm font-medium text-gray-600">
Pilates
</span>
</div>
</div>
</div>
);
};
export default PublicPageBrandingPreview;

View file

@ -1,5 +1,131 @@
const SettingsBrandingPageView = () => { "use client";
return <div>SettingsBrandingPageView</div>;
import { useState, useTransition } from "react";
import { Megaphone, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import BusinessBrandingEditor from "../components/BusinessBrandingEditor";
import { getBusinessBranding, saveBusinessBranding } from "../../actions/business-branding-actions";
import type { BusinessBranding } from "../../schemas/business-branding-schema";
interface SettingsBrandingPageViewProps {
businesses: { id: string; name: string }[];
initialBranding: BusinessBranding;
}
const SettingsBrandingPageView = ({
businesses,
initialBranding,
}: SettingsBrandingPageViewProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
const [branding, setBranding] = useState(initialBranding);
// Getrennt von "kein Branding gespeichert" - eine fehlgeschlagene Anfrage
// darf niemals als leeres/Default-Branding dargestellt werden, sonst würde
// ein "Speichern" versehentlich das tatsächlich gespeicherte Branding mit
// Defaults überschreiben, ohne dass der Manager einen Fehler sieht.
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingBranding, startLoadingBranding] = useTransition();
const [isSaving, startSaving] = useTransition();
const handleBusinessChange = (businessId: string) => {
const previousBusinessId = selectedBusinessId;
setSelectedBusinessId(businessId);
setLoadError(null);
startLoadingBranding(async () => {
const result = await getBusinessBranding(businessId);
if (result.success) {
setBranding(result.branding);
} else {
// Auswahl zurücksetzen, damit die UI nicht unter dem neuen
// Business-Namen die Werte des vorherigen Business anzeigt und ein
// Speichern sie fälschlich der neuen Business-ID zuordnet.
setSelectedBusinessId(previousBusinessId);
setLoadError(result.error);
toast.error(result.error);
}
});
};
const handleSave = (newBranding: BusinessBranding): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
try {
const result = await saveBusinessBranding(selectedBusinessId, newBranding);
if (result.success) {
toast.success("Branding gespeichert.");
resolve(true);
} else {
toast.error(result.error);
resolve(false);
}
} catch {
toast.error("Branding konnte nicht gespeichert werden.");
resolve(false);
}
});
});
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl flex items-center gap-2 font-bold">
<Megaphone /> Branding
</h1>
<p className="text-sm text-gray-500 mt-1">
Platziere dein Logo und nimm deine Farben — für E-Mails, die Buchungsseite und Rechnungen.
</p>
</div>
{businesses.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{loadError ? (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{loadError} Bitte lade die Seite neu, um das Branding zu bearbeiten.</p>
</div>
) : (
<div
className={loadingBranding ? "opacity-60 transition-opacity" : "transition-opacity"}
>
<BusinessBrandingEditor
key={selectedBusinessId}
businessId={selectedBusinessId}
initialBranding={branding}
onSave={handleSave}
isSaving={isSaving}
/>
</div>
)}
</div>
)}
</div>
);
}; };
export default SettingsBrandingPageView; export default SettingsBrandingPageView;

View file

@ -2,6 +2,7 @@ import { prisma } from "@/src/lib/prisma";
import type { ManagerData } from "@/src/lib/auth/getUser"; import type { ManagerData } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where"; import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
import type { ZahlungenFilters } from "./zahlungen-actions"; import type { ZahlungenFilters } from "./zahlungen-actions";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
// Obergrenze für den ZIP-Sammel-Export, damit ein sehr weiter Filter nicht // Obergrenze für den ZIP-Sammel-Export, damit ein sehr weiter Filter nicht
// eine unbegrenzte Zahl an PDFs auf einmal rendert. // eine unbegrenzte Zahl an PDFs auf einmal rendert.
@ -32,6 +33,8 @@ export type InvoiceData = {
email: string; email: string;
ustId: string | null; ustId: string | null;
banking: string | null; banking: string | null;
logoUrl: string | null;
accentColor: string;
}; };
course: { course: {
name: string; name: string;
@ -117,6 +120,7 @@ const invoiceBookingSelect = {
email: true, email: true,
ustId: true, ustId: true,
banking: true, banking: true,
branding: true,
}, },
}, },
}, },
@ -159,6 +163,8 @@ function toInvoiceData(
city: null, city: null,
}; };
const branding = toEmailBranding(booking.course.business);
return { return {
invoiceNumber, invoiceNumber,
issueDate: issuedAt.toISOString(), issueDate: issuedAt.toISOString(),
@ -168,6 +174,8 @@ function toInvoiceData(
email: booking.course.business.email, email: booking.course.business.email,
ustId: booking.course.business.ustId, ustId: booking.course.business.ustId,
banking: booking.course.business.banking, banking: booking.course.business.banking,
logoUrl: branding.logoUrl,
accentColor: branding.accentColor,
}, },
course: { course: {
name: booking.course.name, name: booking.course.name,

View file

@ -1,9 +1,27 @@
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer"; import { Document, Page, Text, View, Image, StyleSheet } from "@react-pdf/renderer";
import type { InvoiceData } from "../../actions/invoice-actions"; import type { InvoiceData } from "../../actions/invoice-actions";
/**
* @react-pdf/renderer lädt Bilder zur Renderzeit serverseitig über die
* angegebene URL nach - eine unerreichbare/kaputte Logo-URL darf den
* gesamten Rechnungs-Download nicht zum Absturz bringen. Nur http(s)
* zulassen (kein data:/file:/beliebiges Protokoll).
*/
function isSafeImageUrl(url: string | null): url is string {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { padding: 48, fontSize: 10, color: "#1a1a1a", fontFamily: "Helvetica" }, page: { padding: 48, fontSize: 10, color: "#1a1a1a", fontFamily: "Helvetica" },
header: { marginBottom: 32 }, header: { marginBottom: 32, flexDirection: "row", justifyContent: "space-between" },
headerText: { flexDirection: "column" },
logo: { width: 64, height: 64, objectFit: "contain" },
businessName: { fontSize: 14, fontWeight: 700, marginBottom: 4 }, businessName: { fontSize: 14, fontWeight: 700, marginBottom: 4 },
muted: { color: "#666666" }, muted: { color: "#666666" },
title: { fontSize: 18, fontWeight: 700, marginTop: 32, marginBottom: 4 }, title: { fontSize: 18, fontWeight: 700, marginTop: 32, marginBottom: 4 },
@ -65,9 +83,16 @@ function formatDate(iso: string): string {
interface InvoicePdfDocumentProps { interface InvoicePdfDocumentProps {
data: InvoiceData; data: InvoiceData;
/**
* Erlaubt dem Route-Handler, nach einem fehlgeschlagenen Render-Versuch
* (z.B. unerreichbare Logo-URL - @react-pdf/renderer lädt Bilder zur
* Renderzeit serverseitig nach) gezielt ohne Logo neu zu rendern, statt den
* gesamten Rechnungs-Download scheitern zu lassen.
*/
includeLogo?: boolean;
} }
export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) { export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocumentProps) {
const { const {
business, business,
course, course,
@ -85,17 +110,23 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}` ? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}`
: null; : null;
const hasLogo = includeLogo && isSafeImageUrl(business.logoUrl);
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.businessName}>{business.name}</Text> <View style={styles.headerText}>
<Text style={styles.muted}>{business.address}</Text> <Text style={styles.businessName}>{business.name}</Text>
<Text style={styles.muted}>{business.email}</Text> <Text style={styles.muted}>{business.address}</Text>
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null} <Text style={styles.muted}>{business.email}</Text>
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
</View>
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer's Image hat kein alt-Prop, nicht das DOM-<img> */}
{hasLogo ? <Image src={business.logoUrl!} style={styles.logo} /> : null}
</View> </View>
<Text style={styles.title}>Rechnung</Text> <Text style={[styles.title, { color: business.accentColor }]}>Rechnung</Text>
<View style={styles.metaRow}> <View style={styles.metaRow}>
<View style={styles.metaBlock}> <View style={styles.metaBlock}>
@ -141,7 +172,9 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>Gesamtbetrag</Text> <Text style={styles.totalLabel}>Gesamtbetrag</Text>
<Text style={styles.totalValue}>{formatPrice(amountCents)}</Text> <Text style={[styles.totalValue, { color: business.accentColor }]}>
{formatPrice(amountCents)}
</Text>
</View> </View>
<Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}> <Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}>