diff --git a/prisma/migrations/20260826144610_add_business_branding/migration.sql b/prisma/migrations/20260826144610_add_business_branding/migration.sql
new file mode 100644
index 0000000..46d6176
--- /dev/null
+++ b/prisma/migrations/20260826144610_add_business_branding/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Business" ADD COLUMN "branding" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4f071b1..8f5eb44 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -112,6 +112,7 @@ model Business {
stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
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])
courses Course[]
createdAt DateTime @default(now())
diff --git a/src/app/(main)/settings/branding/page.tsx b/src/app/(main)/settings/branding/page.tsx
index 72ed781..82b381b 100644
--- a/src/app/(main)/settings/branding/page.tsx
+++ b/src/app/(main)/settings/branding/page.tsx
@@ -1,13 +1,42 @@
import { Suspense } from "react";
+import { AlertTriangle } from "lucide-react";
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 SettingsLoading from "../loading";
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 (
+
+
+
{brandingResult?.error ?? "Branding-Einstellungen konnten nicht geladen werden."}
+
+ );
+ }
+
+ const initialBranding = brandingResult?.success
+ ? brandingResult.branding
+ : {
+ companyName: KUMASY_BRANDING.companyName,
+ logoUrl: null,
+ primaryColor: KUMASY_BRANDING.primaryColor,
+ accentColor: KUMASY_BRANDING.accentColor,
+ };
+
return (
}>
-
+
);
}
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts
index 676a4d9..67fe2ea 100644
--- a/src/app/api/stripe/webhook/route.ts
+++ b/src/app/api/stripe/webhook/route.ts
@@ -10,6 +10,7 @@ import {
generateBookingRefundEmail,
type BookingRefundReason,
} 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.
@@ -621,7 +622,7 @@ async function sendBookingConfirmation(
timeFrom: true,
timeTo: true,
coverImage: true,
- business: { select: { name: true, address: true } },
+ business: { select: { name: true, address: true, branding: true } },
},
});
if (!course || !course.business) return;
@@ -638,6 +639,7 @@ async function sendBookingConfirmation(
myCoursesUrl: `${baseUrl}/courses/myCourses`,
coverImageUrl: course.coverImage,
companyName: process.env.COMPANY_NAME,
+ branding: toEmailBranding(course.business),
});
await sendMail({ to: recipientEmail, subject, html, text });
@@ -657,7 +659,7 @@ async function sendBookingRefund(
where: { id: courseId },
select: {
name: true,
- business: { select: { name: true } },
+ business: { select: { name: true, branding: true } },
},
});
if (!course || !course.business) return;
@@ -669,6 +671,7 @@ async function sendBookingRefund(
refundPercent: 100,
reason,
companyName: process.env.COMPANY_NAME,
+ branding: toEmailBranding(course.business),
});
await sendMail({ to: recipientEmail, subject, html, text });
diff --git a/src/app/api/upload/business-logo/route.ts b/src/app/api/upload/business-logo/route.ts
new file mode 100644
index 0000000..b1972e4
--- /dev/null
+++ b/src/app/api/upload/business-logo/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/zahlungen/[bookingId]/invoice/route.tsx b/src/app/api/zahlungen/[bookingId]/invoice/route.tsx
index 5aecc57..f77d1f0 100644
--- a/src/app/api/zahlungen/[bookingId]/invoice/route.tsx
+++ b/src/app/api/zahlungen/[bookingId]/invoice/route.tsx
@@ -26,7 +26,16 @@ export async function GET(
);
}
- const buffer = await renderToBuffer( );
+ // 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( );
+ } catch (renderError) {
+ console.error("Rechnungs-Rendering mit Logo fehlgeschlagen, Retry ohne Logo:", renderError);
+ buffer = await renderToBuffer( );
+ }
return new NextResponse(new Uint8Array(buffer), {
headers: {
diff --git a/src/app/api/zahlungen/invoices/zip/route.tsx b/src/app/api/zahlungen/invoices/zip/route.tsx
index 0bfd339..3b9aaf6 100644
--- a/src/app/api/zahlungen/invoices/zip/route.tsx
+++ b/src/app/api/zahlungen/invoices/zip/route.tsx
@@ -85,7 +85,20 @@ export async function GET(request: Request) {
}
usedNames.add(fileName);
- const buffer = await renderToBuffer( );
+ // 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( );
+ } catch (renderError) {
+ console.error(
+ `Rechnungs-Rendering mit Logo fehlgeschlagen (${invoiceData.invoiceNumber}), Retry ohne Logo:`,
+ renderError
+ );
+ buffer = await renderToBuffer(
+
+ );
+ }
archive.append(buffer, { name: fileName });
}
diff --git a/src/app/business/[slug]/page.tsx b/src/app/business/[slug]/page.tsx
index 4bf5017..bc2456e 100644
--- a/src/app/business/[slug]/page.tsx
+++ b/src/app/business/[slug]/page.tsx
@@ -4,6 +4,8 @@ import {
getPublishedCoursesForBusiness,
} from "@/src/modules/courses/actions/booking-actions";
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 {
params: Promise<{ slug: string }>;
@@ -18,9 +20,24 @@ export default async function BusinessPage({ params }: BusinessPageProps) {
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 = {
...businessResult.business,
slug: businessResult.business.slug,
+ logoUrl: branding.logoUrl,
+ primaryColor: branding.primaryColor,
+ accentColor: branding.accentColor,
};
const from = new Date();
diff --git a/src/app/courses/book/[courseId]/cancel/page.tsx b/src/app/courses/book/[courseId]/cancel/page.tsx
index 1af0ee3..8c0aa34 100644
--- a/src/app/courses/book/[courseId]/cancel/page.tsx
+++ b/src/app/courses/book/[courseId]/cancel/page.tsx
@@ -1,6 +1,9 @@
+import Image from "next/image";
import Link from "next/link";
import { Button } from "@/src/components/ui/button";
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";
interface CancelPageProps {
@@ -10,7 +13,9 @@ interface CancelPageProps {
export default async function CourseBookingCancelPage({ params }: CancelPageProps) {
const { courseId } = await params;
const result = await getCourseSummaryForConfirmation(courseId);
- const businessSlug = result.success ? (result.course.business?.slug ?? null) : null;
+ const business = result.success ? result.course.business : null;
+ const businessSlug = business?.slug ?? null;
+ const branding = business ? toEmailBranding(business) : null;
return (
@@ -26,10 +31,33 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
Die Zahlung wurde abgebrochen. Es wurde nichts berechnet.
+ {branding?.logoUrl && (
+
+
+
+ )}
+
{businessSlug && (
-
Erneut versuchen
+
+ Erneut versuchen
+
)}
diff --git a/src/app/courses/book/[courseId]/success/page.tsx b/src/app/courses/book/[courseId]/success/page.tsx
index 7cf6649..bd68e4f 100644
--- a/src/app/courses/book/[courseId]/success/page.tsx
+++ b/src/app/courses/book/[courseId]/success/page.tsx
@@ -1,6 +1,8 @@
+import Image from "next/image";
import Link from "next/link";
import { Button } from "@/src/components/ui/button";
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";
interface SuccessPageProps {
@@ -14,6 +16,7 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
const course = result.success ? result.course : null;
const businessSlug = course?.business?.slug ?? null;
+ const branding = course?.business ? toEmailBranding(course.business) : null;
const formattedDate = course
? new Date(course.date).toLocaleDateString("de-DE", {
@@ -44,7 +47,19 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
{course && (
-
{course.name}
+
+
{course.name}
+ {branding?.logoUrl && (
+
+
+
+ )}
+
{course.business?.name && (
{course.business.name}
)}
@@ -70,7 +85,12 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
Bezahlt
- {formattedPrice}
+
+ {formattedPrice}
+
)}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..c5eebd8
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -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
) {
+ return (
+
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts
index 637d2bd..0e1de6b 100644
--- a/src/modules/courses/actions/booking-actions.ts
+++ b/src/modules/courses/actions/booking-actions.ts
@@ -12,6 +12,8 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
import { executeBookingCancellation } from "./cancel-booking-shared";
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 = {
bookingId: string;
@@ -37,6 +39,8 @@ export type UserBooking = {
trainersMap: Record;
paymentStatus: string;
isPast: boolean;
+ businessName: string;
+ businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
};
export async function getUserBookings(): Promise<
@@ -71,6 +75,7 @@ export async function getUserBookings(): Promise<
coverImage: true,
description: true,
_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) => {
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
+ const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
return {
bookingId: b.id,
courseId: b.course.id,
@@ -143,6 +149,12 @@ export async function getUserBookings(): Promise<
trainersMap,
paymentStatus: b.paymentStatus,
isPast: courseStart < now,
+ businessName: b.course.business?.name ?? branding.companyName,
+ businessBranding: {
+ logoUrl: branding.logoUrl,
+ accentColor: branding.accentColor,
+ accentTextColor: branding.accentTextColor,
+ },
} satisfies UserBooking;
});
},
@@ -261,6 +273,7 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
name: true,
slug: true,
address: true,
+ branding: true,
},
},
},
@@ -291,6 +304,7 @@ export async function getBusinessBySlug(slug: string) {
title: true,
slug: true,
isPublic: true,
+ branding: true,
},
});
diff --git a/src/modules/courses/actions/cancel-booking-shared.ts b/src/modules/courses/actions/cancel-booking-shared.ts
index fedcd8b..47b3de0 100644
--- a/src/modules/courses/actions/cancel-booking-shared.ts
+++ b/src/modules/courses/actions/cancel-booking-shared.ts
@@ -3,6 +3,7 @@ import { refundStripePayment } from "@/src/lib/stripe-refund";
import { sendMail } from "@/src/lib/mail/resend";
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
+import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
export type CancelBookingResult =
| { success: true; refundedAmountCents: number; refundPercent: number }
@@ -23,7 +24,7 @@ async function sendCancellationEmail(
booking: {
userId: string | null;
guestLeadId: string | null;
- course: { name: string; business: { name: string } | null };
+ course: { name: string; business: { name: string; branding: unknown } | null };
},
amountRefundedCents: number,
originalAmountCents: number,
@@ -56,6 +57,7 @@ async function sendCancellationEmail(
reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"),
originalAmountCents,
companyName: process.env.COMPANY_NAME,
+ branding: toEmailBranding(booking.course.business),
});
await sendMail({ to: recipientEmail, subject, html, text });
@@ -94,7 +96,7 @@ export async function executeBookingCancellation(
date: true,
timeFrom: true,
businessId: true,
- business: { select: { name: true } },
+ business: { select: { name: true, branding: true } },
},
},
},
diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx
index 87696c6..46e82fd 100644
--- a/src/modules/courses/ui/components/PublicCourseCard.tsx
+++ b/src/modules/courses/ui/components/PublicCourseCard.tsx
@@ -10,6 +10,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
import DOMPurify from "dompurify";
import { checkUserBookingStatus } from "../../actions/booking-actions";
+import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
@@ -75,6 +76,9 @@ interface PublicCourseCardProps {
course: PublicCourse;
business?: BusinessInfo;
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" }) => {
@@ -124,13 +128,18 @@ export const BookingDialog = ({
onOpenChange,
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
paymentStatus,
+ accentColor,
}: {
course: PublicCourse;
business?: BusinessInfo;
open: boolean;
onOpenChange: (v: boolean) => void;
paymentStatus?: string;
+ accentColor?: string;
}) => {
+ const accentButtonStyle = accentColor
+ ? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
+ : undefined;
const sanitizedDescription = useSanitizedHtml(course.description);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
@@ -419,6 +428,7 @@ export const BookingDialog = ({
onClick={() => handleBooking()}
disabled={isSubmitting || isFull}
className="flex-1 sm:flex-initial"
+ style={!isFull ? accentButtonStyle : undefined}
>
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
@@ -457,6 +467,7 @@ export const BookingDialog = ({
onClick={() => handleBooking({ asGuest: true })}
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
className="w-full"
+ style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
@@ -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 cardSafeCurrent = Math.max(0, course.currentParticipants);
@@ -551,8 +567,11 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
@@ -561,7 +580,15 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
Ausgebucht
) : (
- setDialogOpen(true)}>
+ setDialogOpen(true)}
+ style={
+ accentColor
+ ? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
+ : undefined
+ }
+ >
Jetzt anmelden
)}
@@ -573,6 +600,7 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
business={business}
open={dialogOpen}
onOpenChange={setDialogOpen}
+ accentColor={accentColor}
/>
>
);
diff --git a/src/modules/courses/ui/components/UserBookingCard.tsx b/src/modules/courses/ui/components/UserBookingCard.tsx
index 60aa3cc..85f8cac 100644
--- a/src/modules/courses/ui/components/UserBookingCard.tsx
+++ b/src/modules/courses/ui/components/UserBookingCard.tsx
@@ -122,7 +122,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
- {/* Kursname */}
+ {/* Business + Kursname */}
+
+ {booking.businessBranding.logoUrl && (
+
+
+
+ )}
+
{booking.businessName}
+
{booking.courseName}
@@ -170,6 +183,7 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
open={dialogOpen}
onOpenChange={setDialogOpen}
paymentStatus={effectiveStatus}
+ accentColor={booking.businessBranding.accentColor}
/>
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 dayStart = new Date(selectedDate);
dayStart.setHours(0, 0, 0, 0);
@@ -145,16 +165,44 @@ const BusinessPublicView = ({
return (
{/* Header */}
-
+
-
{business.name}
- {business.title &&
{business.title}
}
-
+
+ {business.logoUrl && (
+
+
+
+ )}
+
+ {business.name}
+
+
+ {business.title && (
+
+ {business.title}
+
+ )}
+
{business.address}
{business.email && (
<>
·
-
+
{business.email}
>
@@ -193,9 +241,14 @@ const BusinessPublicView = ({
onClick={() => setActiveSport(sport)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
resolvedSport === sport
- ? "bg-gray-900 text-white"
+ ? ""
: "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)}
@@ -221,6 +274,7 @@ const BusinessPublicView = ({
course={course}
business={{ address: business.address, email: business.email }}
defaultOpen={course.id === deepLinkCourseId}
+ accentColor={business.accentColor}
/>
))}
diff --git a/src/modules/kunden/actions/kunde-detail-actions.ts b/src/modules/kunden/actions/kunde-detail-actions.ts
index 536bb5f..6db3584 100644
--- a/src/modules/kunden/actions/kunde-detail-actions.ts
+++ b/src/modules/kunden/actions/kunde-detail-actions.ts
@@ -4,6 +4,8 @@ import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { unstable_cache } from "next/cache";
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 = {
bookingId: string;
@@ -31,6 +33,8 @@ export type KundeBooking = {
trainersMap: Record
;
paymentStatus: string;
isPast: boolean;
+ businessName: string;
+ businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
};
export type KundeDetail = {
@@ -98,6 +102,7 @@ export async function getKundeDetail(
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
_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 courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
+ const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
return {
bookingId: b.id,
courseId: b.course.id,
@@ -173,6 +179,12 @@ export async function getKundeDetail(
trainersMap,
paymentStatus: b.paymentStatus,
isPast: courseStart < now,
+ businessName: b.course.business?.name ?? branding.companyName,
+ businessBranding: {
+ logoUrl: branding.logoUrl,
+ accentColor: branding.accentColor,
+ accentTextColor: branding.accentTextColor,
+ },
};
});
diff --git a/src/modules/kunden/ui/components/kunde-booking-card.tsx b/src/modules/kunden/ui/components/kunde-booking-card.tsx
index 30ce55e..1e10cdc 100644
--- a/src/modules/kunden/ui/components/kunde-booking-card.tsx
+++ b/src/modules/kunden/ui/components/kunde-booking-card.tsx
@@ -111,7 +111,20 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
- {/* Kursname */}
+ {/* Business + Kursname */}
+
+ {booking.businessBranding.logoUrl && (
+
+
+
+ )}
+
{booking.businessName}
+
{booking.courseName}
@@ -160,6 +173,7 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
open={dialogOpen}
onOpenChange={setDialogOpen}
paymentStatus={booking.paymentStatus}
+ accentColor={booking.businessBranding.accentColor}
/>
{
+ // 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." };
+ }
+}
diff --git a/src/modules/settings/lib/contrast.ts b/src/modules/settings/lib/contrast.ts
new file mode 100644
index 0000000..ee13448
--- /dev/null
+++ b/src/modules/settings/lib/contrast.ts
@@ -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";
+}
diff --git a/src/modules/settings/lib/to-email-branding.ts b/src/modules/settings/lib/to-email-branding.ts
new file mode 100644
index 0000000..2487e5a
--- /dev/null
+++ b/src/modules/settings/lib/to-email-branding.ts
@@ -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),
+ };
+}
diff --git a/src/modules/settings/schemas/business-branding-schema.ts b/src/modules/settings/schemas/business-branding-schema.ts
new file mode 100644
index 0000000..d5b7891
--- /dev/null
+++ b/src/modules/settings/schemas/business-branding-schema.ts
@@ -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 , 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;
diff --git a/src/modules/settings/ui/components/BusinessBrandingEditor.tsx b/src/modules/settings/ui/components/BusinessBrandingEditor.tsx
new file mode 100644
index 0000000..0438949
--- /dev/null
+++ b/src/modules/settings/ui/components/BusinessBrandingEditor.tsx
@@ -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;
+ 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(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) => {
+ const file = e.target.files?.[0];
+ // Auswahl im 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 (
+
+ {/* Formular */}
+
+ {/* Firmenname */}
+
+
+ Firmenname
+
+
+ Erscheint in E-Mails, auf der Buchungsseite und auf Rechnungen.
+
+
setCompanyName(e.target.value)}
+ maxLength={100}
+ className="max-w-sm"
+ />
+
+
+ {/* Logo */}
+
+
Logo
+
+ Wird in E-Mails, auf der Buchungsseite und auf Rechnungen angezeigt. Ohne Logo wird der
+ Firmenname als Schriftzug verwendet.
+
+
+ {logoUrl ? (
+
+
+ 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"
+ >
+
+
+
+ ) : (
+
+ {isUploadingLogo ? (
+
+
+
+ {uploadPhase === "optimizing" ? "Optimiert…" : "Lädt hoch…"}
+
+
+
+ ) : (
+ <>
+
+ Auswählen
+ >
+ )}
+
+
+ )}
+
+
Max. 5 MB, danach zuschneidbar.
+
+
+ {/* Farben */}
+
+
+
+
+
+
+ {isSaving ? "Speichert…" : "Speichern"}
+
+
+
+ {/* Vorschau */}
+
+
+
+
+
+ E-Mail
+
+
+
+ Buchungsseite
+
+
+
+ Rechnung
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setPendingCropFile(null)}
+ onConfirm={handleCropConfirm}
+ />
+
+ );
+};
+
+export default BusinessBrandingEditor;
diff --git a/src/modules/settings/ui/components/ColorPickerField.tsx b/src/modules/settings/ui/components/ColorPickerField.tsx
new file mode 100644
index 0000000..f8c6c54
--- /dev/null
+++ b/src/modules/settings/ui/components/ColorPickerField.tsx
@@ -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 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(null);
+ const [hexDraft, setHexDraft] = useState(value);
+ const [hexError, setHexError] = useState(false);
+
+ const handleNativeColorChange = (e: React.ChangeEvent) => {
+ onChange(e.target.value);
+ setHexDraft(e.target.value);
+ setHexError(false);
+ };
+
+ const handleHexInputChange = (e: React.ChangeEvent) => {
+ 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 (
+
+
+ {label}
+
+ {description &&
{description}
}
+
+ 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`}
+ >
+
+
+
+
+
+ );
+};
+
+export default ColorPickerField;
diff --git a/src/modules/settings/ui/components/LogoCropDialog.tsx b/src/modules/settings/ui/components/LogoCropDialog.tsx
new file mode 100644
index 0000000..2001c13
--- /dev/null
+++ b/src/modules/settings/ui/components/LogoCropDialog.tsx
@@ -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(null);
+ const imageRef = useRef(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) => {
+ dragState.current = { startX: e.clientX, startY: e.clientY, origin: offset };
+ e.currentTarget.setPointerCapture(e.pointerId);
+ };
+
+ const handlePointerMove = (e: React.PointerEvent) => {
+ 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 (
+ !open && onCancel()}>
+
+
+ Logo zuschneiden
+
+ Ziehe das Bild, um den Ausschnitt zu verschieben, und nutze den Regler zum Zoomen.
+
+
+
+
+
+
+
+
+ Zoom
+
+
+
+
+
+ Abbrechen
+
+ Übernehmen
+
+
+
+ );
+};
+
+export default LogoCropDialog;
diff --git a/src/modules/settings/ui/components/branding-previews/EmailBrandingPreview.tsx b/src/modules/settings/ui/components/branding-previews/EmailBrandingPreview.tsx
new file mode 100644
index 0000000..aef394a
--- /dev/null
+++ b/src/modules/settings/ui/components/branding-previews/EmailBrandingPreview.tsx
@@ -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 (
+
+
+ {logoUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element -- Vorschau spiegelt das
-Markup der echten Mail (kein next/image dort möglich)
+
+ ) : (
+
{companyName}
+ )}
+
Buchung bestätigt
+
+
+
+
+ Hallo, deine Buchung für Beispielkurs wurde bestätigt.
+
+
+ Meine Buchungen ansehen
+
+
+
+
+
{companyName}
+
Diese E-Mail wurde automatisch generiert.
+
+
+ );
+};
+
+export default EmailBrandingPreview;
diff --git a/src/modules/settings/ui/components/branding-previews/InvoiceBrandingPreview.tsx b/src/modules/settings/ui/components/branding-previews/InvoiceBrandingPreview.tsx
new file mode 100644
index 0000000..8951aea
--- /dev/null
+++ b/src/modules/settings/ui/components/branding-previews/InvoiceBrandingPreview.tsx
@@ -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 (
+
+
+
+
{companyName || "Firmenname"}
+
Musterstraße 1, 12345 Musterstadt
+
kontakt@example.com
+
+ {logoUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element -- Vorschau der PDF-Struktur, kein DOM-Bild im echten Dokument
+
+ ) : null}
+
+
+
+ Rechnung
+
+
+
+ Leistung
+ Betrag
+
+
+ Beispielkurs · Termin: 01.01.2026
+ 49,00 €
+
+
+
+ Gesamtbetrag
+ 49,00 €
+
+
+ );
+};
+
+export default InvoiceBrandingPreview;
diff --git a/src/modules/settings/ui/components/branding-previews/PublicPageBrandingPreview.tsx b/src/modules/settings/ui/components/branding-previews/PublicPageBrandingPreview.tsx
new file mode 100644
index 0000000..68d1123
--- /dev/null
+++ b/src/modules/settings/ui/components/branding-previews/PublicPageBrandingPreview.tsx
@@ -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 (
+
+
+
+ {logoUrl ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- Vorschau, kein Next-Image-Optimizer nötig für kleinen Vorschau-Kontext */}
+
+
+ ) : null}
+
{companyName || "Firmenname"}
+
+
+
+
+
+
+
+ Alle
+
+
+ Yoga
+
+
+ Pilates
+
+
+
+
+ );
+};
+
+export default PublicPageBrandingPreview;
diff --git a/src/modules/settings/ui/views/settings-branding-page-view.tsx b/src/modules/settings/ui/views/settings-branding-page-view.tsx
index b572afe..18d7b26 100644
--- a/src/modules/settings/ui/views/settings-branding-page-view.tsx
+++ b/src/modules/settings/ui/views/settings-branding-page-view.tsx
@@ -1,5 +1,131 @@
-const SettingsBrandingPageView = () => {
- return SettingsBrandingPageView
;
+"use client";
+
+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(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 => {
+ 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 (
+
+
+
+ Branding
+
+
+ Platziere dein Logo und nimm deine Farben — für E-Mails, die Buchungsseite und Rechnungen.
+
+
+
+ {businesses.length === 0 ? (
+
+
Du hast noch keine Businesses angelegt.
+
+ ) : (
+
+ {businesses.length > 1 && (
+
+
+
+
+
+ {businesses.map((b) => (
+
+ {b.name}
+
+ ))}
+
+
+ )}
+
+ {loadError ? (
+
+
+
{loadError} Bitte lade die Seite neu, um das Branding zu bearbeiten.
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ );
};
export default SettingsBrandingPageView;
diff --git a/src/modules/zahlungen/actions/invoice-actions.ts b/src/modules/zahlungen/actions/invoice-actions.ts
index fdef99b..0675dbf 100644
--- a/src/modules/zahlungen/actions/invoice-actions.ts
+++ b/src/modules/zahlungen/actions/invoice-actions.ts
@@ -2,6 +2,7 @@ import { prisma } from "@/src/lib/prisma";
import type { ManagerData } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
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
// eine unbegrenzte Zahl an PDFs auf einmal rendert.
@@ -32,6 +33,8 @@ export type InvoiceData = {
email: string;
ustId: string | null;
banking: string | null;
+ logoUrl: string | null;
+ accentColor: string;
};
course: {
name: string;
@@ -117,6 +120,7 @@ const invoiceBookingSelect = {
email: true,
ustId: true,
banking: true,
+ branding: true,
},
},
},
@@ -159,6 +163,8 @@ function toInvoiceData(
city: null,
};
+ const branding = toEmailBranding(booking.course.business);
+
return {
invoiceNumber,
issueDate: issuedAt.toISOString(),
@@ -168,6 +174,8 @@ function toInvoiceData(
email: booking.course.business.email,
ustId: booking.course.business.ustId,
banking: booking.course.business.banking,
+ logoUrl: branding.logoUrl,
+ accentColor: branding.accentColor,
},
course: {
name: booking.course.name,
diff --git a/src/modules/zahlungen/ui/components/invoice-pdf-document.tsx b/src/modules/zahlungen/ui/components/invoice-pdf-document.tsx
index e368d96..a56da7d 100644
--- a/src/modules/zahlungen/ui/components/invoice-pdf-document.tsx
+++ b/src/modules/zahlungen/ui/components/invoice-pdf-document.tsx
@@ -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";
+/**
+ * @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({
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 },
muted: { color: "#666666" },
title: { fontSize: 18, fontWeight: 700, marginTop: 32, marginBottom: 4 },
@@ -65,9 +83,16 @@ function formatDate(iso: string): string {
interface InvoicePdfDocumentProps {
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 {
business,
course,
@@ -85,17 +110,23 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}`
: null;
+ const hasLogo = includeLogo && isSafeImageUrl(business.logoUrl);
+
return (
- {business.name}
- {business.address}
- {business.email}
- {business.ustId ? USt-IdNr.: {business.ustId} : null}
+
+ {business.name}
+ {business.address}
+ {business.email}
+ {business.ustId ? USt-IdNr.: {business.ustId} : null}
+
+ {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer's Image hat kein alt-Prop, nicht das DOM- */}
+ {hasLogo ? : null}
- Rechnung
+ Rechnung
@@ -141,7 +172,9 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
Gesamtbetrag
- {formatPrice(amountCents)}
+
+ {formatPrice(amountCents)}
+