mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
added white labeling feature
This commit is contained in:
parent
80493f5040
commit
04e294bc10
31 changed files with 1457 additions and 35 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Business" ADD COLUMN "branding" JSONB;
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>{brandingResult?.error ?? "Branding-Einstellungen konnten nicht geladen werden."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initialBranding = brandingResult?.success
|
||||
? brandingResult.branding
|
||||
: {
|
||||
companyName: KUMASY_BRANDING.companyName,
|
||||
logoUrl: null,
|
||||
primaryColor: KUMASY_BRANDING.primaryColor,
|
||||
accentColor: KUMASY_BRANDING.accentColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<SettingsLoading />}>
|
||||
<SettingsBrandingPageView />
|
||||
<SettingsBrandingPageView businesses={businesses} initialBranding={initialBranding} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
84
src/app/api/upload/business-logo/route.ts
Normal file
84
src/app/api/upload/business-logo/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/src/lib/supabase/admin";
|
||||
import { getUserData, isManager } from "@/src/lib/auth/getUser";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
/**
|
||||
* POST /api/upload/business-logo
|
||||
* Erwartet multipart/form-data mit "file" und "businessId". Anders als
|
||||
* /api/upload/course-image reicht hier ein globaler Manager-Check nicht -
|
||||
* ein Logo wird über eine eigene Settings-Action gespeichert (nicht Teil
|
||||
* eines größeren, ownership-geprüften Form-Saves), daher muss die
|
||||
* Berechtigung für genau dieses Business schon beim Upload geprüft werden.
|
||||
*
|
||||
* getUserData() statt requireManager() - Letzteres löst bei fehlender Rolle
|
||||
* einen next/navigation-Redirect aus, der in einer API-Route, die JSON
|
||||
* zurückgeben soll, falsch wäre (und im try/catch hier sogar als 500
|
||||
* fehlinterpretiert würde statt als redirect zu funktionieren).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const userData = await getUserData();
|
||||
if (!userData || !isManager(userData)) {
|
||||
return NextResponse.json({ error: "Keine Berechtigung" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const businessId = formData.get("businessId") as string | null;
|
||||
|
||||
if (!file || !businessId) {
|
||||
return NextResponse.json({ error: "Datei und Business erforderlich" }, { status: 400 });
|
||||
}
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { managerId: true },
|
||||
});
|
||||
|
||||
if (!business || business.managerId !== userData.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Keine Berechtigung für dieses Business" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "Nur Bilddateien erlaubt" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "Datei darf maximal 5MB groß sein" }, { status: 400 });
|
||||
}
|
||||
|
||||
const extension =
|
||||
file.name
|
||||
.split(".")
|
||||
.pop()
|
||||
?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
|
||||
const safePath = `business-logos/${businessId}/${Date.now()}.${extension}`;
|
||||
|
||||
const adminClient = createAdminClient();
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const { data, error } = await adminClient.storage.from("avatars").upload(safePath, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
cacheControl: "3600",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Storage upload error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const {
|
||||
data: { publicUrl },
|
||||
} = adminClient.storage.from("avatars").getPublicUrl(data.path);
|
||||
|
||||
return NextResponse.json({ publicUrl }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error in /api/upload/business-logo:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,16 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
|
||||
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||
// Ein fehlgeschlagener Logo-Nachlade-Versuch (z.B. unerreichbare/gelöschte
|
||||
// Storage-URL) darf den Download nicht blockieren - zweiter Versuch ohne
|
||||
// Logo, statt dem Manager/Kunden gar keine Rechnung auszuliefern.
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||
} catch (renderError) {
|
||||
console.error("Rechnungs-Rendering mit Logo fehlgeschlagen, Retry ohne Logo:", renderError);
|
||||
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} includeLogo={false} />);
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,20 @@ export async function GET(request: Request) {
|
|||
}
|
||||
usedNames.add(fileName);
|
||||
|
||||
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||
// Ein fehlgeschlagener Logo-Nachlade-Versuch für EINE Rechnung darf
|
||||
// nicht das gesamte ZIP abbrechen - Retry ohne Logo für genau diese PDF.
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||
} catch (renderError) {
|
||||
console.error(
|
||||
`Rechnungs-Rendering mit Logo fehlgeschlagen (${invoiceData.invoiceNumber}), Retry ohne Logo:`,
|
||||
renderError
|
||||
);
|
||||
buffer = await renderToBuffer(
|
||||
<InvoicePdfDocument data={invoiceData} includeLogo={false} />
|
||||
);
|
||||
}
|
||||
archive.append(buffer, { name: fileName });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
|
|
@ -26,10 +31,33 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
|
|||
Die Zahlung wurde abgebrochen. Es wurde nichts berechnet.
|
||||
</p>
|
||||
|
||||
{branding?.logoUrl && (
|
||||
<div className="relative w-12 h-12 mx-auto mb-6 rounded-md overflow-hidden bg-white border border-gray-200">
|
||||
<Image
|
||||
src={branding.logoUrl}
|
||||
alt={branding.companyName}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{businessSlug && (
|
||||
<Link href={`/business/${businessSlug}`}>
|
||||
<Button className="w-full sm:w-auto">Erneut versuchen</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
style={
|
||||
branding
|
||||
? {
|
||||
backgroundColor: branding.accentColor,
|
||||
color: getContrastTextColor(branding.accentColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="bg-gray-50 rounded-lg p-5 text-left mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-1">{course.name}</h2>
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<h2 className="text-lg font-bold text-gray-900">{course.name}</h2>
|
||||
{branding?.logoUrl && (
|
||||
<div className="relative w-10 h-10 shrink-0 rounded-md overflow-hidden bg-white border border-gray-200">
|
||||
<Image
|
||||
src={branding.logoUrl}
|
||||
alt={branding.companyName}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{course.business?.name && (
|
||||
<p className="text-sm text-gray-500 mb-4">{course.business.name}</p>
|
||||
)}
|
||||
|
|
@ -70,7 +85,12 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
|||
|
||||
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Bezahlt</span>
|
||||
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{ color: branding?.accentColor ?? "#111827" }}
|
||||
>
|
||||
{formattedPrice}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
|
@ -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<string, { label: string; pbSrc?: string }>;
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
</Button>
|
||||
|
|
@ -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"}
|
||||
</Button>
|
||||
|
|
@ -468,7 +479,12 @@ export const BookingDialog = ({
|
|||
);
|
||||
};
|
||||
|
||||
const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCourseCardProps) => {
|
||||
const PublicCourseCard = ({
|
||||
course,
|
||||
business,
|
||||
defaultOpen = false,
|
||||
accentColor,
|
||||
}: PublicCourseCardProps) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(defaultOpen);
|
||||
|
||||
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
||||
|
|
@ -551,8 +567,11 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
|||
|
||||
<div className="w-full h-2 bg-white/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
className={`h-full ${isFull ? "bg-red-500" : accentColor ? "" : "bg-blue-500"}`}
|
||||
style={{
|
||||
width: `${fillPercent}%`,
|
||||
backgroundColor: !isFull && accentColor ? accentColor : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -561,7 +580,15 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
|||
Ausgebucht
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => setDialogOpen(true)}>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
style={
|
||||
accentColor
|
||||
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Jetzt anmelden
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -573,6 +600,7 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
|||
business={business}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -122,7 +122,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Kursname */}
|
||||
{/* Business + Kursname */}
|
||||
<div className="flex items-center gap-2">
|
||||
{booking.businessBranding.logoUrl && (
|
||||
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
|
||||
<Image
|
||||
src={booking.businessBranding.logoUrl}
|
||||
alt={booking.businessName}
|
||||
fill
|
||||
className="object-contain p-0.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
||||
{booking.courseName}
|
||||
</h3>
|
||||
|
|
@ -170,6 +183,7 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
|||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
paymentStatus={effectiveStatus}
|
||||
accentColor={booking.businessBranding.accentColor}
|
||||
/>
|
||||
|
||||
<CancelBookingConfirmDialog
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import PublicCourseCard from "../components/PublicCourseCard";
|
||||
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
|
||||
import { Calendar } from "@/src/components/ui/calendar";
|
||||
import { de } from "date-fns/locale";
|
||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||
|
||||
interface Business {
|
||||
id: string;
|
||||
|
|
@ -14,6 +16,9 @@ interface Business {
|
|||
email: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
interface TrainerProfile {
|
||||
|
|
@ -119,6 +124,21 @@ const BusinessPublicView = ({
|
|||
// Reset active sport if it no longer exists after a window refetch
|
||||
const resolvedSport = allSports.includes(activeSport) ? activeSport : "Alle";
|
||||
|
||||
const accentTextColor = useMemo(
|
||||
() => getContrastTextColor(business.accentColor),
|
||||
[business.accentColor]
|
||||
);
|
||||
|
||||
const primaryTextColor = useMemo(
|
||||
() => getContrastTextColor(business.primaryColor),
|
||||
[business.primaryColor]
|
||||
);
|
||||
// Sekundärtext (Adresse/Titel) etwas dezenter als die Überschrift, aber im
|
||||
// selben Hell/Dunkel-Lager - sonst könnte z.B. bei dunklem primaryColor eine
|
||||
// "abgeschwächte" graue Sekundärfarbe erneut unlesbar werden.
|
||||
const primaryMutedTextColor =
|
||||
primaryTextColor === "#FFFFFF" ? "rgba(255,255,255,0.75)" : "#4B5563";
|
||||
|
||||
const displayedCourses = useMemo(() => {
|
||||
const dayStart = new Date(selectedDate);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
|
|
@ -145,16 +165,44 @@ const BusinessPublicView = ({
|
|||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white/90 backdrop-blur-sm">
|
||||
<div
|
||||
className="border-b border-gray-200 backdrop-blur-sm"
|
||||
style={{ backgroundColor: business.primaryColor }}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1>
|
||||
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>}
|
||||
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-4">
|
||||
{business.logoUrl && (
|
||||
<div className="relative w-14 h-14 shrink-0 rounded-lg overflow-hidden bg-white border border-gray-200">
|
||||
<Image
|
||||
src={business.logoUrl}
|
||||
alt={business.name}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl font-bold" style={{ color: primaryTextColor }}>
|
||||
{business.name}
|
||||
</h1>
|
||||
</div>
|
||||
{business.title && (
|
||||
<p className="text-xl mt-1" style={{ color: primaryMutedTextColor }}>
|
||||
{business.title}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="mt-4 flex flex-col sm:flex-row gap-2 text-sm"
|
||||
style={{ color: primaryMutedTextColor }}
|
||||
>
|
||||
<span>{business.address}</span>
|
||||
{business.email && (
|
||||
<>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<a href={`mailto:${business.email}`} className="hover:underline text-blue-600">
|
||||
<a
|
||||
href={`mailto:${business.email}`}
|
||||
className="hover:underline"
|
||||
style={{ color: primaryTextColor }}
|
||||
>
|
||||
{business.email}
|
||||
</a>
|
||||
</>
|
||||
|
|
@ -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)}
|
||||
</button>
|
||||
|
|
@ -221,6 +274,7 @@ const BusinessPublicView = ({
|
|||
course={course}
|
||||
business={{ address: business.address, email: business.email }}
|
||||
defaultOpen={course.id === deepLinkCourseId}
|
||||
accentColor={business.accentColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; pbSrc?: string }>;
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,20 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Kursname */}
|
||||
{/* Business + Kursname */}
|
||||
<div className="flex items-center gap-2">
|
||||
{booking.businessBranding.logoUrl && (
|
||||
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
|
||||
<Image
|
||||
src={booking.businessBranding.logoUrl}
|
||||
alt={booking.businessName}
|
||||
fill
|
||||
className="object-contain p-0.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
||||
{booking.courseName}
|
||||
</h3>
|
||||
|
|
@ -160,6 +173,7 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
|
|||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
accentColor={booking.businessBranding.accentColor}
|
||||
/>
|
||||
|
||||
<CancelBookingConfirmDialog
|
||||
|
|
|
|||
95
src/modules/settings/actions/business-branding-actions.ts
Normal file
95
src/modules/settings/actions/business-branding-actions.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||
import { businessBrandingSchema, type BusinessBranding } from "../schemas/business-branding-schema";
|
||||
|
||||
export async function getBusinessBranding(
|
||||
businessId: string
|
||||
): Promise<{ success: true; branding: BusinessBranding } | { success: false; error: string }> {
|
||||
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
|
||||
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
|
||||
const manager = await requireManager();
|
||||
|
||||
try {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { managerId: true, name: true, branding: true },
|
||||
});
|
||||
if (!business || business.managerId !== manager.id) {
|
||||
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||
}
|
||||
|
||||
// Kein gespeicherter Wert (null) ist der einzige erwartete "leer"-Fall -
|
||||
// Firmenname wird mit dem Business-Namen vorbefüllt (bessere UX beim
|
||||
// ersten Öffnen), Farben mit dem Kumasy-Standard. Ein VORHANDENER, aber
|
||||
// nicht parsebarer Wert ist dagegen ein echter Fehler und wird NICHT
|
||||
// still zu einem editierbaren Default - sonst könnte ein Manager per
|
||||
// "Speichern" versehentlich echte, nur nicht mehr lesbare Branding-Daten
|
||||
// überschreiben, ohne je einen Fehler gesehen zu haben.
|
||||
if (business.branding === null) {
|
||||
return {
|
||||
success: true,
|
||||
branding: {
|
||||
companyName: business.name,
|
||||
logoUrl: null,
|
||||
primaryColor: KUMASY_BRANDING.primaryColor,
|
||||
accentColor: KUMASY_BRANDING.accentColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = businessBrandingSchema.safeParse(business.branding);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid stored business branding:", parsed.error);
|
||||
return { success: false, error: "Branding-Einstellungen konnten nicht geladen werden." };
|
||||
}
|
||||
|
||||
return { success: true, branding: parsed.data };
|
||||
} catch (error) {
|
||||
console.error("Error fetching business branding:", error);
|
||||
return { success: false, error: "Branding-Einstellungen konnten nicht geladen werden." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBusinessBranding(
|
||||
businessId: string,
|
||||
branding: BusinessBranding
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
const manager = await requireManager();
|
||||
|
||||
try {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { managerId: true },
|
||||
});
|
||||
if (!business || business.managerId !== manager.id) {
|
||||
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||
}
|
||||
|
||||
const parsed = businessBrandingSchema.safeParse(branding);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Ungültige Branding-Einstellungen." };
|
||||
}
|
||||
|
||||
const updated = await prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { branding: parsed.data },
|
||||
select: { slug: true },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/branding");
|
||||
// Die öffentliche Buchungsseite cached getBusinessBySlug unter genau
|
||||
// diesem Tag (siehe booking-actions.ts) - ohne Revalidierung würde sie
|
||||
// bis zu 1h altes Branding weiter ausliefern.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (updated.slug) (revalidateTag as any)(`business-slug-${updated.slug}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving business branding:", error);
|
||||
return { success: false, error: "Branding-Einstellungen konnten nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
24
src/modules/settings/lib/contrast.ts
Normal file
24
src/modules/settings/lib/contrast.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Leitet eine gut lesbare Textfarbe (schwarz oder weiß) für einen gegebenen
|
||||
* Hex-Hintergrund ab, statt sie als eigenes Formularfeld zu erheben - so
|
||||
* bleibt der Farbwähler bei zwei Farben (Primär/Akzent), ohne dass eine falsch
|
||||
* gewählte Akzentfarbe zu unlesbarem Button-/Badge-Text führt.
|
||||
*
|
||||
* WCAG-relative Luminanz (gammakorrigiert) mit Schwellwert 0.5: unterhalb
|
||||
* gilt der Hintergrund als dunkel genug für weißen Text, sonst schwarz.
|
||||
*/
|
||||
export function getContrastTextColor(hex: string): string {
|
||||
const match = /^#([0-9a-fA-F]{6})$/.exec(hex);
|
||||
if (!match) return "#1E293B";
|
||||
|
||||
const r = parseInt(match[1].slice(0, 2), 16) / 255;
|
||||
const g = parseInt(match[1].slice(2, 4), 16) / 255;
|
||||
const b = parseInt(match[1].slice(4, 6), 16) / 255;
|
||||
|
||||
const toLinear = (channel: number) =>
|
||||
channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
||||
|
||||
const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
|
||||
return luminance > 0.5 ? "#1E293B" : "#FFFFFF";
|
||||
}
|
||||
27
src/modules/settings/lib/to-email-branding.ts
Normal file
27
src/modules/settings/lib/to-email-branding.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { KUMASY_BRANDING, type EmailBranding } from "@/src/lib/mail/templates/tokens";
|
||||
import { businessBrandingSchema } from "../schemas/business-branding-schema";
|
||||
import { getContrastTextColor } from "./contrast";
|
||||
|
||||
/**
|
||||
* Wandelt das rohe `Business.branding`-Json in ein `EmailBranding`-Objekt um,
|
||||
* das direkt an die Mail-Templates/PDF/öffentliche Seite weitergereicht
|
||||
* werden kann. Wirft NIE - jeder Aufrufer läuft in einem Hot Path (Stripe-
|
||||
* Webhook, PDF-Download, öffentliche Buchungsseite), der trotz fehlendem oder
|
||||
* korruptem Branding weiterlaufen muss. Das unterscheidet diese Funktion
|
||||
* bewusst vom Settings-Read-Pfad (getBusinessBranding), der korrupte Daten
|
||||
* als harten Fehler behandelt, damit "Speichern" nie versehentlich echte
|
||||
* Branding-Daten überschreibt.
|
||||
*/
|
||||
export function toEmailBranding(business: { name: string; branding: unknown }): EmailBranding {
|
||||
const parsed = businessBrandingSchema.safeParse(business.branding);
|
||||
if (!parsed.success) return KUMASY_BRANDING;
|
||||
|
||||
return {
|
||||
companyName: parsed.data.companyName,
|
||||
logoUrl: parsed.data.logoUrl,
|
||||
logoAlt: parsed.data.companyName,
|
||||
primaryColor: parsed.data.primaryColor,
|
||||
accentColor: parsed.data.accentColor,
|
||||
accentTextColor: getContrastTextColor(parsed.data.accentColor),
|
||||
};
|
||||
}
|
||||
17
src/modules/settings/schemas/business-branding-schema.ts
Normal file
17
src/modules/settings/schemas/business-branding-schema.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from "zod";
|
||||
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||
|
||||
// Nur 6-stelliges Hex statt der breiteren Farbnotation, die sanitizeColor in
|
||||
// den Mail-Templates akzeptiert (dort defense-in-depth für beliebige Quellen) -
|
||||
// dieses Schema validiert ausschließlich Werte aus <input type="color">, das
|
||||
// immer #rrggbb liefert.
|
||||
const hexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, "Ungültige Farbe");
|
||||
|
||||
export const businessBrandingSchema = z.object({
|
||||
companyName: z.string().trim().min(1).max(100),
|
||||
logoUrl: z.string().url().nullable().default(null),
|
||||
primaryColor: hexColorSchema.default(KUMASY_BRANDING.primaryColor),
|
||||
accentColor: hexColorSchema.default(KUMASY_BRANDING.accentColor),
|
||||
});
|
||||
|
||||
export type BusinessBranding = z.infer<typeof businessBrandingSchema>;
|
||||
243
src/modules/settings/ui/components/BusinessBrandingEditor.tsx
Normal file
243
src/modules/settings/ui/components/BusinessBrandingEditor.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Loader2, ImagePlus, X, Mail, Globe, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Progress } from "@/src/components/ui/progress";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||
import { getContrastTextColor } from "../../lib/contrast";
|
||||
import type { BusinessBranding } from "../../schemas/business-branding-schema";
|
||||
import ColorPickerField from "./ColorPickerField";
|
||||
import LogoCropDialog from "./LogoCropDialog";
|
||||
import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview";
|
||||
import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview";
|
||||
import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview";
|
||||
|
||||
interface BusinessBrandingEditorProps {
|
||||
businessId: string;
|
||||
initialBranding: BusinessBranding;
|
||||
onSave: (branding: BusinessBranding) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const BusinessBrandingEditor = ({
|
||||
businessId,
|
||||
initialBranding,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: BusinessBrandingEditorProps) => {
|
||||
const idPrefix = useId();
|
||||
const [companyName, setCompanyName] = useState(initialBranding.companyName);
|
||||
const [logoUrl, setLogoUrl] = useState(initialBranding.logoUrl);
|
||||
const [primaryColor, setPrimaryColor] = useState(initialBranding.primaryColor);
|
||||
const [accentColor, setAccentColor] = useState(initialBranding.accentColor);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
|
||||
const [pendingCropFile, setPendingCropFile] = useState<File | null>(null);
|
||||
|
||||
const accentTextColor = getContrastTextColor(accentColor);
|
||||
|
||||
const uploadLogo = async (file: File) => {
|
||||
setIsUploadingLogo(true);
|
||||
setUploadPhase("optimizing");
|
||||
try {
|
||||
const resizedFile = await downsizeImage(file);
|
||||
setUploadPhase("uploading");
|
||||
const formData = new FormData();
|
||||
formData.append("file", resizedFile);
|
||||
formData.append("businessId", businessId);
|
||||
const res = await fetch("/api/upload/business-logo", { method: "POST", body: formData });
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error);
|
||||
setLogoUrl(json.publicUrl);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen");
|
||||
} finally {
|
||||
setUploadPhase(null);
|
||||
setIsUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Auswahl im <input> zurücksetzen, damit dieselbe Datei danach erneut
|
||||
// gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht).
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setPendingCropFile(file);
|
||||
};
|
||||
|
||||
const handleCropConfirm = (croppedFile: File) => {
|
||||
setPendingCropFile(null);
|
||||
uploadLogo(croppedFile);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedName = companyName.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("Firmenname darf nicht leer sein.");
|
||||
return;
|
||||
}
|
||||
await onSave({
|
||||
companyName: trimmedName,
|
||||
logoUrl,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-2 lg:items-start">
|
||||
{/* Formular */}
|
||||
<div className="space-y-6 rounded-xl border border-border bg-card p-6">
|
||||
{/* Firmenname */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor={`${idPrefix}-company-name`}
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Firmenname
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Erscheint in E-Mails, auf der Buchungsseite und auf Rechnungen.
|
||||
</p>
|
||||
<Input
|
||||
id={`${idPrefix}-company-name`}
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
maxLength={100}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">Logo</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wird in E-Mails, auf der Buchungsseite und auf Rechnungen angezeigt. Ohne Logo wird der
|
||||
Firmenname als Schriftzug verwendet.
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{logoUrl ? (
|
||||
<div className="relative h-24 w-24 overflow-hidden rounded-xl border border-border bg-white">
|
||||
<Image src={logoUrl} alt="Logo" fill className="object-contain p-2" sizes="96px" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogoUrl(null)}
|
||||
aria-label="Logo entfernen"
|
||||
className="absolute top-1 right-1 rounded-full bg-white p-1 shadow hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex h-24 w-24 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-input px-2 text-center transition-colors hover:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50">
|
||||
{isUploadingLogo ? (
|
||||
<div className="flex w-full flex-col items-center gap-1.5 px-2">
|
||||
<Loader2 size={16} className="animate-spin text-muted-foreground" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{uploadPhase === "optimizing" ? "Optimiert…" : "Lädt hoch…"}
|
||||
</span>
|
||||
<Progress value={uploadPhase === "optimizing" ? 40 : 85} className="w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus size={18} className="mb-1 text-muted-foreground" />
|
||||
<span className="text-[11px] text-muted-foreground">Auswählen</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
disabled={isUploadingLogo}
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Max. 5 MB, danach zuschneidbar.</p>
|
||||
</div>
|
||||
|
||||
{/* Farben */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<ColorPickerField
|
||||
label="Primärfarbe"
|
||||
description="Hintergrund im E-Mail-Header."
|
||||
value={primaryColor}
|
||||
onChange={setPrimaryColor}
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="Akzentfarbe"
|
||||
description="Für Buttons und Hervorhebungen."
|
||||
value={accentColor}
|
||||
onChange={setAccentColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={isSaving || isUploadingLogo}>
|
||||
{isSaving ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Vorschau */}
|
||||
<div className="lg:sticky lg:top-6">
|
||||
<Tabs defaultValue="email">
|
||||
<TabsList>
|
||||
<TabsTrigger value="email">
|
||||
<Mail size={14} className="mr-1.5" />
|
||||
E-Mail
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="public-page">
|
||||
<Globe size={14} className="mr-1.5" />
|
||||
Buchungsseite
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invoice">
|
||||
<FileText size={14} className="mr-1.5" />
|
||||
Rechnung
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email" className="mt-4">
|
||||
<EmailBrandingPreview
|
||||
companyName={companyName || "Firmenname"}
|
||||
logoUrl={logoUrl}
|
||||
primaryColor={primaryColor}
|
||||
accentColor={accentColor}
|
||||
accentTextColor={accentTextColor}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="public-page" className="mt-4">
|
||||
<PublicPageBrandingPreview
|
||||
companyName={companyName || "Firmenname"}
|
||||
logoUrl={logoUrl}
|
||||
accentColor={accentColor}
|
||||
accentTextColor={accentTextColor}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invoice" className="mt-4">
|
||||
<InvoiceBrandingPreview
|
||||
companyName={companyName || "Firmenname"}
|
||||
logoUrl={logoUrl}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<LogoCropDialog
|
||||
file={pendingCropFile}
|
||||
onCancel={() => setPendingCropFile(null)}
|
||||
onConfirm={handleCropConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessBrandingEditor;
|
||||
93
src/modules/settings/ui/components/ColorPickerField.tsx
Normal file
93
src/modules/settings/ui/components/ColorPickerField.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
|
||||
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
interface ColorPickerFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Natives <input type="color"> sieht je nach Browser/OS sehr unterschiedlich
|
||||
* aus (voller eckiger Farbklecks, kein eigenes Design, inkonsistentes
|
||||
* Hover/Focus) - deshalb bleibt es hier unsichtbar und dient nur als
|
||||
* Steuerung für den nativen Farbwähler-Dialog. Sichtbar ist ein eigener,
|
||||
* ins Design passender Swatch-Button plus editierbares Hex-Textfeld.
|
||||
*/
|
||||
const ColorPickerField = ({ label, description, value, onChange }: ColorPickerFieldProps) => {
|
||||
const id = useId();
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const [hexDraft, setHexDraft] = useState(value);
|
||||
const [hexError, setHexError] = useState(false);
|
||||
|
||||
const handleNativeColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
setHexDraft(e.target.value);
|
||||
setHexError(false);
|
||||
};
|
||||
|
||||
const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = e.target.value;
|
||||
setHexDraft(next);
|
||||
if (HEX_PATTERN.test(next)) {
|
||||
onChange(next);
|
||||
setHexError(false);
|
||||
} else {
|
||||
setHexError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHexBlur = () => {
|
||||
// Ungültige Eingabe beim Verlassen des Felds auf den zuletzt gültigen
|
||||
// Wert zurücksetzen, statt einen kaputten Hex-Code stehen zu lassen.
|
||||
if (!HEX_PATTERN.test(hexDraft)) {
|
||||
setHexDraft(value);
|
||||
setHexError(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={`${id}-hex`} className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="relative h-9 w-9 shrink-0 rounded-md border border-input shadow-xs transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
style={{ backgroundColor: HEX_PATTERN.test(value) ? value : undefined }}
|
||||
aria-label={`${label} auswählen`}
|
||||
>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={handleNativeColorChange}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</button>
|
||||
<Input
|
||||
id={`${id}-hex`}
|
||||
value={hexDraft}
|
||||
onChange={handleHexInputChange}
|
||||
onBlur={handleHexBlur}
|
||||
aria-invalid={hexError}
|
||||
maxLength={7}
|
||||
className="w-28 font-mono uppercase"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPickerField;
|
||||
170
src/modules/settings/ui/components/LogoCropDialog.tsx
Normal file
170
src/modules/settings/ui/components/LogoCropDialog.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
|
||||
const CANVAS_SIZE = 320;
|
||||
const OUTPUT_SIZE = 512;
|
||||
|
||||
interface LogoCropDialogProps {
|
||||
file: File | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (croppedFile: File) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfacher Zoom/Pan-Crop direkt per Canvas, ohne zusätzliche Dependency -
|
||||
* analog zum bestehenden Canvas-Ansatz in downsizeImage. Schneidet immer
|
||||
* quadratisch zu (Logos werden überall als Quadrat/Kreis dargestellt: Header,
|
||||
* Rechnung, öffentliche Seite), damit kein gestrecktes oder schief
|
||||
* zugeschnittenes Logo entstehen kann.
|
||||
*/
|
||||
const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origin: { x: number; y: number };
|
||||
} | null>(null);
|
||||
|
||||
const draw = useCallback(
|
||||
(currentZoom: number, currentOffset: { x: number; y: number }, img?: HTMLImageElement) => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = img ?? imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = CANVAS_SIZE;
|
||||
canvas.height = CANVAS_SIZE;
|
||||
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||
ctx.fillStyle = "#f5f5f5";
|
||||
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||
|
||||
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
|
||||
const scale = baseScale * currentZoom;
|
||||
const drawWidth = image.width * scale;
|
||||
const drawHeight = image.height * scale;
|
||||
const x = (CANVAS_SIZE - drawWidth) / 2 + currentOffset.x;
|
||||
const y = (CANVAS_SIZE - drawHeight) / 2 + currentOffset.y;
|
||||
|
||||
ctx.drawImage(image, x, y, drawWidth, drawHeight);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setZoom(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
draw(1, { x: 0, y: 0 }, img);
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, draw]);
|
||||
|
||||
const handleZoomChange = ([value]: number[]) => {
|
||||
setZoom(value);
|
||||
draw(value, offset);
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origin: offset };
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = e.clientX - dragState.current.startX;
|
||||
const dy = e.clientY - dragState.current.startY;
|
||||
const next = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy };
|
||||
setOffset(next);
|
||||
draw(zoom, next);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
dragState.current = null;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const image = imageRef.current;
|
||||
if (!image) return;
|
||||
|
||||
const outputCanvas = document.createElement("canvas");
|
||||
outputCanvas.width = OUTPUT_SIZE;
|
||||
outputCanvas.height = OUTPUT_SIZE;
|
||||
const ctx = outputCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const outputScale = OUTPUT_SIZE / CANVAS_SIZE;
|
||||
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
|
||||
const scale = baseScale * zoom * outputScale;
|
||||
const drawWidth = image.width * scale;
|
||||
const drawHeight = image.height * scale;
|
||||
const x = (OUTPUT_SIZE - drawWidth) / 2 + offset.x * outputScale;
|
||||
const y = (OUTPUT_SIZE - drawHeight) / 2 + offset.y * outputScale;
|
||||
|
||||
ctx.drawImage(image, x, y, drawWidth, drawHeight);
|
||||
|
||||
outputCanvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
onConfirm(new File([blob], "logo.png", { type: "image/png" }));
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!file} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Logo zuschneiden</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ziehe das Bild, um den Ausschnitt zu verschieben, und nutze den Regler zum Zoomen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_SIZE}
|
||||
height={CANVAS_SIZE}
|
||||
className="rounded-lg border border-input touch-none cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<span className="text-xs text-muted-foreground shrink-0">Zoom</span>
|
||||
<Slider min={1} max={3} step={0.05} value={[zoom]} onValueChange={handleZoomChange} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Übernehmen</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoCropDialog;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
interface EmailBrandingPreviewProps {
|
||||
companyName: string;
|
||||
logoUrl: string | null;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
accentTextColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet den Header/Footer-Aufbau von renderEmailLayout (src/lib/mail/templates/layout.ts)
|
||||
* nach - dieselbe Struktur (Header-Band in primaryColor, Logo oder
|
||||
* Text-Wordmark, Akzentfarbe am Button, Footer mit Firmenname), damit die
|
||||
* Vorschau nicht von der tatsächlich versendeten Mail abweicht.
|
||||
*/
|
||||
const EmailBrandingPreview = ({
|
||||
companyName,
|
||||
logoUrl,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
accentTextColor,
|
||||
}: EmailBrandingPreviewProps) => {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-white shadow-sm">
|
||||
<div
|
||||
className="flex flex-col items-center px-8 py-10 text-center"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- Vorschau spiegelt das <img>-Markup der echten Mail (kein next/image dort möglich)
|
||||
<img src={logoUrl} alt={companyName} className="mb-4 max-h-10 max-w-40 object-contain" />
|
||||
) : (
|
||||
<p className="mb-3 text-sm font-bold tracking-wide text-white uppercase">{companyName}</p>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-white">Buchung bestätigt</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-8">
|
||||
<p className="text-sm text-gray-700">
|
||||
Hallo, deine Buchung für <strong>Beispielkurs</strong> wurde bestätigt.
|
||||
</p>
|
||||
<span
|
||||
className="inline-block rounded-full px-5 py-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: accentColor, color: accentTextColor }}
|
||||
>
|
||||
Meine Buchungen ansehen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-8 py-6 text-center">
|
||||
<p className="text-sm text-gray-600">{companyName}</p>
|
||||
<p className="mt-1 text-xs text-gray-400">Diese E-Mail wurde automatisch generiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailBrandingPreview;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
interface InvoiceBrandingPreviewProps {
|
||||
companyName: string;
|
||||
logoUrl: string | null;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet den Kopf/Summen-Bereich von invoice-pdf-document.tsx nach (Logo
|
||||
* rechts oben, Akzentfarbe an "Rechnung"-Titel und Gesamtbetrag) - das PDF
|
||||
* selbst lässt sich hier nicht rendern, daher als HTML-Annäherung an dieselbe
|
||||
* Struktur.
|
||||
*/
|
||||
const InvoiceBrandingPreview = ({
|
||||
companyName,
|
||||
logoUrl,
|
||||
accentColor,
|
||||
}: InvoiceBrandingPreviewProps) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white p-6 font-serif text-sm text-gray-900 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{companyName || "Firmenname"}</p>
|
||||
<p className="text-xs text-gray-500">Musterstraße 1, 12345 Musterstadt</p>
|
||||
<p className="text-xs text-gray-500">kontakt@example.com</p>
|
||||
</div>
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- Vorschau der PDF-Struktur, kein DOM-Bild im echten Dokument
|
||||
<img src={logoUrl} alt={companyName} className="h-16 w-16 object-contain" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-lg font-bold" style={{ color: accentColor }}>
|
||||
Rechnung
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex justify-between border-b border-gray-200 pb-2 text-xs font-semibold text-gray-500">
|
||||
<span>Leistung</span>
|
||||
<span>Betrag</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-200 py-3 text-xs">
|
||||
<span>Beispielkurs · Termin: 01.01.2026</span>
|
||||
<span>49,00 €</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end gap-6 text-sm font-bold">
|
||||
<span>Gesamtbetrag</span>
|
||||
<span style={{ color: accentColor }}>49,00 €</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceBrandingPreview;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
interface PublicPageBrandingPreviewProps {
|
||||
companyName: string;
|
||||
logoUrl: string | null;
|
||||
accentColor: string;
|
||||
accentTextColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet den Header-Aufbau von business-public-view.tsx nach (Logo neben dem
|
||||
* Firmennamen, Akzentfarbe am aktiven Filter-Pill und am Mailto-Link), damit
|
||||
* die Vorschau exakt zeigt, wie die Werte auf der echten Buchungsseite
|
||||
* ankommen.
|
||||
*/
|
||||
const PublicPageBrandingPreview = ({
|
||||
companyName,
|
||||
logoUrl,
|
||||
accentColor,
|
||||
accentTextColor,
|
||||
}: PublicPageBrandingPreviewProps) => {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-gray-50">
|
||||
<div className="border-b border-gray-200 bg-white/90 px-6 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{logoUrl ? (
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- Vorschau, kein Next-Image-Optimizer nötig für kleinen Vorschau-Kontext */}
|
||||
<img src={logoUrl} alt={companyName} className="h-full w-full object-contain p-1" />
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-xl font-bold text-gray-900">{companyName || "Firmenname"}</h2>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 text-xs text-gray-600">
|
||||
<span>Musterstraße 1, 12345 Musterstadt</span>
|
||||
<span>·</span>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:underline"
|
||||
style={{ color: accentColor }}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
kontakt@example.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className="rounded-full px-4 py-1.5 text-sm font-medium"
|
||||
style={{ backgroundColor: accentColor, color: accentTextColor }}
|
||||
>
|
||||
Alle
|
||||
</span>
|
||||
<span className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-sm font-medium text-gray-600">
|
||||
Yoga
|
||||
</span>
|
||||
<span className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-sm font-medium text-gray-600">
|
||||
Pilates
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicPageBrandingPreview;
|
||||
|
|
@ -1,5 +1,131 @@
|
|||
const SettingsBrandingPageView = () => {
|
||||
return <div>SettingsBrandingPageView</div>;
|
||||
"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<string | null>(null);
|
||||
const [loadingBranding, startLoadingBranding] = useTransition();
|
||||
const [isSaving, startSaving] = useTransition();
|
||||
|
||||
const handleBusinessChange = (businessId: string) => {
|
||||
const previousBusinessId = selectedBusinessId;
|
||||
setSelectedBusinessId(businessId);
|
||||
setLoadError(null);
|
||||
startLoadingBranding(async () => {
|
||||
const result = await getBusinessBranding(businessId);
|
||||
if (result.success) {
|
||||
setBranding(result.branding);
|
||||
} else {
|
||||
// Auswahl zurücksetzen, damit die UI nicht unter dem neuen
|
||||
// Business-Namen die Werte des vorherigen Business anzeigt und ein
|
||||
// Speichern sie fälschlich der neuen Business-ID zuordnet.
|
||||
setSelectedBusinessId(previousBusinessId);
|
||||
setLoadError(result.error);
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = (newBranding: BusinessBranding): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
startSaving(async () => {
|
||||
try {
|
||||
const result = await saveBusinessBranding(selectedBusinessId, newBranding);
|
||||
if (result.success) {
|
||||
toast.success("Branding gespeichert.");
|
||||
resolve(true);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
resolve(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Branding konnte nicht gespeichert werden.");
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl flex items-center gap-2 font-bold">
|
||||
<Megaphone /> Branding
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Platziere dein Logo und nimm deine Farben — für E-Mails, die Buchungsseite und Rechnungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{businesses.length === 0 ? (
|
||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{businesses.length > 1 && (
|
||||
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
|
||||
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
|
||||
<SelectValue placeholder="Business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{loadError ? (
|
||||
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>{loadError} Bitte lade die Seite neu, um das Branding zu bearbeiten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={loadingBranding ? "opacity-60 transition-opacity" : "transition-opacity"}
|
||||
>
|
||||
<BusinessBrandingEditor
|
||||
key={selectedBusinessId}
|
||||
businessId={selectedBusinessId}
|
||||
initialBranding={branding}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsBrandingPageView;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.businessName}>{business.name}</Text>
|
||||
<Text style={styles.muted}>{business.address}</Text>
|
||||
<Text style={styles.muted}>{business.email}</Text>
|
||||
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.businessName}>{business.name}</Text>
|
||||
<Text style={styles.muted}>{business.address}</Text>
|
||||
<Text style={styles.muted}>{business.email}</Text>
|
||||
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
|
||||
</View>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer's Image hat kein alt-Prop, nicht das DOM-<img> */}
|
||||
{hasLogo ? <Image src={business.logoUrl!} style={styles.logo} /> : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Rechnung</Text>
|
||||
<Text style={[styles.title, { color: business.accentColor }]}>Rechnung</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaBlock}>
|
||||
|
|
@ -141,7 +172,9 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
|
|||
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Gesamtbetrag</Text>
|
||||
<Text style={styles.totalValue}>{formatPrice(amountCents)}</Text>
|
||||
<Text style={[styles.totalValue, { color: business.accentColor }]}>
|
||||
{formatPrice(amountCents)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue