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 && ( +
+ {branding.companyName} +
+ )} +
{businessSlug && ( - + )} 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 && ( +
+ {branding.companyName} +
+ )} +
{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 ) : ( - )} @@ -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.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.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.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 */} +
+ +

+ 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 ? ( +
+ Logo + +
+ ) : ( + + )} +
+

Max. 5 MB, danach zuschneidbar.

+
+ + {/* Farben */} +
+ + +
+ + +
+ + {/* 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 ( +
+ + {description &&

{description}

} +
+ + +
+
+ ); +}; + +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 + +
+ + + + + +
+
+ ); +}; + +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} + ) : ( +

{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 + {companyName} + ) : 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 */} + {companyName} +
+ ) : null} +

{companyName || "Firmenname"}

+
+
+ Musterstraße 1, 12345 Musterstadt + · + e.preventDefault()} + > + kontakt@example.com + +
+
+ +
+
+ + 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 && ( + + )} + + {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)} +