mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +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"
|
stripeAccountStatus String? // "pending" | "active" | "restricted"
|
||||||
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
|
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
|
||||||
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung.
|
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung.
|
||||||
|
branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING).
|
||||||
manager Manager @relation(fields: [managerId], references: [id])
|
manager Manager @relation(fields: [managerId], references: [id])
|
||||||
courses Course[]
|
courses Course[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,42 @@
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { requireManager } from "@/src/lib/auth/getUser";
|
import { requireManager } from "@/src/lib/auth/getUser";
|
||||||
|
import { getBusinessBranding } from "@/src/modules/settings/actions/business-branding-actions";
|
||||||
|
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||||
import SettingsBrandingPageView from "@/src/modules/settings/ui/views/settings-branding-page-view";
|
import SettingsBrandingPageView from "@/src/modules/settings/ui/views/settings-branding-page-view";
|
||||||
import SettingsLoading from "../loading";
|
import SettingsLoading from "../loading";
|
||||||
|
|
||||||
export default async function SettingsBrandingPage() {
|
export default async function SettingsBrandingPage() {
|
||||||
await requireManager();
|
const manager = await requireManager();
|
||||||
|
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
|
||||||
|
|
||||||
|
const firstBusinessId = businesses[0]?.id;
|
||||||
|
const brandingResult = firstBusinessId ? await getBusinessBranding(firstBusinessId) : null;
|
||||||
|
|
||||||
|
// Ein fehlgeschlagener initialer Load darf nicht still in ein editierbares
|
||||||
|
// Default-Branding umgewandelt werden - sonst könnte "Speichern" ein
|
||||||
|
// tatsächlich vorhandenes, nur gerade nicht ladbares Branding überschreiben.
|
||||||
|
if (firstBusinessId && !brandingResult?.success) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||||
|
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>{brandingResult?.error ?? "Branding-Einstellungen konnten nicht geladen werden."}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialBranding = brandingResult?.success
|
||||||
|
? brandingResult.branding
|
||||||
|
: {
|
||||||
|
companyName: KUMASY_BRANDING.companyName,
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: KUMASY_BRANDING.primaryColor,
|
||||||
|
accentColor: KUMASY_BRANDING.accentColor,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<SettingsLoading />}>
|
<Suspense fallback={<SettingsLoading />}>
|
||||||
<SettingsBrandingPageView />
|
<SettingsBrandingPageView businesses={businesses} initialBranding={initialBranding} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
generateBookingRefundEmail,
|
generateBookingRefundEmail,
|
||||||
type BookingRefundReason,
|
type BookingRefundReason,
|
||||||
} from "@/src/lib/mail/templates/booking-refund";
|
} from "@/src/lib/mail/templates/booking-refund";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||||
|
|
@ -621,7 +622,7 @@ async function sendBookingConfirmation(
|
||||||
timeFrom: true,
|
timeFrom: true,
|
||||||
timeTo: true,
|
timeTo: true,
|
||||||
coverImage: true,
|
coverImage: true,
|
||||||
business: { select: { name: true, address: true } },
|
business: { select: { name: true, address: true, branding: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!course || !course.business) return;
|
if (!course || !course.business) return;
|
||||||
|
|
@ -638,6 +639,7 @@ async function sendBookingConfirmation(
|
||||||
myCoursesUrl: `${baseUrl}/courses/myCourses`,
|
myCoursesUrl: `${baseUrl}/courses/myCourses`,
|
||||||
coverImageUrl: course.coverImage,
|
coverImageUrl: course.coverImage,
|
||||||
companyName: process.env.COMPANY_NAME,
|
companyName: process.env.COMPANY_NAME,
|
||||||
|
branding: toEmailBranding(course.business),
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendMail({ to: recipientEmail, subject, html, text });
|
await sendMail({ to: recipientEmail, subject, html, text });
|
||||||
|
|
@ -657,7 +659,7 @@ async function sendBookingRefund(
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
business: { select: { name: true } },
|
business: { select: { name: true, branding: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!course || !course.business) return;
|
if (!course || !course.business) return;
|
||||||
|
|
@ -669,6 +671,7 @@ async function sendBookingRefund(
|
||||||
refundPercent: 100,
|
refundPercent: 100,
|
||||||
reason,
|
reason,
|
||||||
companyName: process.env.COMPANY_NAME,
|
companyName: process.env.COMPANY_NAME,
|
||||||
|
branding: toEmailBranding(course.business),
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendMail({ to: recipientEmail, subject, html, text });
|
await sendMail({ to: recipientEmail, subject, html, text });
|
||||||
|
|
|
||||||
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), {
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,20 @@ export async function GET(request: Request) {
|
||||||
}
|
}
|
||||||
usedNames.add(fileName);
|
usedNames.add(fileName);
|
||||||
|
|
||||||
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
// Ein fehlgeschlagener Logo-Nachlade-Versuch für EINE Rechnung darf
|
||||||
|
// nicht das gesamte ZIP abbrechen - Retry ohne Logo für genau diese PDF.
|
||||||
|
let buffer: Buffer;
|
||||||
|
try {
|
||||||
|
buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||||
|
} catch (renderError) {
|
||||||
|
console.error(
|
||||||
|
`Rechnungs-Rendering mit Logo fehlgeschlagen (${invoiceData.invoiceNumber}), Retry ohne Logo:`,
|
||||||
|
renderError
|
||||||
|
);
|
||||||
|
buffer = await renderToBuffer(
|
||||||
|
<InvoicePdfDocument data={invoiceData} includeLogo={false} />
|
||||||
|
);
|
||||||
|
}
|
||||||
archive.append(buffer, { name: fileName });
|
archive.append(buffer, { name: fileName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
getPublishedCoursesForBusiness,
|
getPublishedCoursesForBusiness,
|
||||||
} from "@/src/modules/courses/actions/booking-actions";
|
} from "@/src/modules/courses/actions/booking-actions";
|
||||||
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
|
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
|
||||||
|
import { businessBrandingSchema } from "@/src/modules/settings/schemas/business-branding-schema";
|
||||||
|
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||||
|
|
||||||
interface BusinessPageProps {
|
interface BusinessPageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
|
@ -18,9 +20,24 @@ export default async function BusinessPage({ params }: BusinessPageProps) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immer fertige, nicht-null Branding-Werte an die Client-View reichen - sie
|
||||||
|
// soll nie ein rohes Json-Blob oder null-Farben handhaben müssen.
|
||||||
|
const parsedBranding = businessBrandingSchema.safeParse(businessResult.business.branding);
|
||||||
|
const branding = parsedBranding.success
|
||||||
|
? parsedBranding.data
|
||||||
|
: {
|
||||||
|
companyName: businessResult.business.name,
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: KUMASY_BRANDING.primaryColor,
|
||||||
|
accentColor: KUMASY_BRANDING.accentColor,
|
||||||
|
};
|
||||||
|
|
||||||
const business = {
|
const business = {
|
||||||
...businessResult.business,
|
...businessResult.business,
|
||||||
slug: businessResult.business.slug,
|
slug: businessResult.business.slug,
|
||||||
|
logoUrl: branding.logoUrl,
|
||||||
|
primaryColor: branding.primaryColor,
|
||||||
|
accentColor: branding.accentColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
const from = new Date();
|
const from = new Date();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/src/components/ui/button";
|
import { Button } from "@/src/components/ui/button";
|
||||||
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
import { XCircle } from "lucide-react";
|
import { XCircle } from "lucide-react";
|
||||||
|
|
||||||
interface CancelPageProps {
|
interface CancelPageProps {
|
||||||
|
|
@ -10,7 +13,9 @@ interface CancelPageProps {
|
||||||
export default async function CourseBookingCancelPage({ params }: CancelPageProps) {
|
export default async function CourseBookingCancelPage({ params }: CancelPageProps) {
|
||||||
const { courseId } = await params;
|
const { courseId } = await params;
|
||||||
const result = await getCourseSummaryForConfirmation(courseId);
|
const result = await getCourseSummaryForConfirmation(courseId);
|
||||||
const businessSlug = result.success ? (result.course.business?.slug ?? null) : null;
|
const business = result.success ? result.course.business : null;
|
||||||
|
const businessSlug = business?.slug ?? null;
|
||||||
|
const branding = business ? toEmailBranding(business) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-6">
|
<div className="max-w-2xl mx-auto p-6">
|
||||||
|
|
@ -26,10 +31,33 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
|
||||||
Die Zahlung wurde abgebrochen. Es wurde nichts berechnet.
|
Die Zahlung wurde abgebrochen. Es wurde nichts berechnet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{branding?.logoUrl && (
|
||||||
|
<div className="relative w-12 h-12 mx-auto mb-6 rounded-md overflow-hidden bg-white border border-gray-200">
|
||||||
|
<Image
|
||||||
|
src={branding.logoUrl}
|
||||||
|
alt={branding.companyName}
|
||||||
|
fill
|
||||||
|
className="object-contain p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
{businessSlug && (
|
{businessSlug && (
|
||||||
<Link href={`/business/${businessSlug}`}>
|
<Link href={`/business/${businessSlug}`}>
|
||||||
<Button className="w-full sm:w-auto">Erneut versuchen</Button>
|
<Button
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
style={
|
||||||
|
branding
|
||||||
|
? {
|
||||||
|
backgroundColor: branding.accentColor,
|
||||||
|
color: getContrastTextColor(branding.accentColor),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/src/components/ui/button";
|
import { Button } from "@/src/components/ui/button";
|
||||||
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react";
|
import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react";
|
||||||
|
|
||||||
interface SuccessPageProps {
|
interface SuccessPageProps {
|
||||||
|
|
@ -14,6 +16,7 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
||||||
|
|
||||||
const course = result.success ? result.course : null;
|
const course = result.success ? result.course : null;
|
||||||
const businessSlug = course?.business?.slug ?? null;
|
const businessSlug = course?.business?.slug ?? null;
|
||||||
|
const branding = course?.business ? toEmailBranding(course.business) : null;
|
||||||
|
|
||||||
const formattedDate = course
|
const formattedDate = course
|
||||||
? new Date(course.date).toLocaleDateString("de-DE", {
|
? new Date(course.date).toLocaleDateString("de-DE", {
|
||||||
|
|
@ -44,7 +47,19 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
||||||
|
|
||||||
{course && (
|
{course && (
|
||||||
<div className="bg-gray-50 rounded-lg p-5 text-left mb-6">
|
<div className="bg-gray-50 rounded-lg p-5 text-left mb-6">
|
||||||
<h2 className="text-lg font-bold text-gray-900 mb-1">{course.name}</h2>
|
<div className="flex items-start justify-between gap-3 mb-1">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">{course.name}</h2>
|
||||||
|
{branding?.logoUrl && (
|
||||||
|
<div className="relative w-10 h-10 shrink-0 rounded-md overflow-hidden bg-white border border-gray-200">
|
||||||
|
<Image
|
||||||
|
src={branding.logoUrl}
|
||||||
|
alt={branding.companyName}
|
||||||
|
fill
|
||||||
|
className="object-contain p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{course.business?.name && (
|
{course.business?.name && (
|
||||||
<p className="text-sm text-gray-500 mb-4">{course.business.name}</p>
|
<p className="text-sm text-gray-500 mb-4">{course.business.name}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -70,7 +85,12 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
||||||
|
|
||||||
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
|
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-500">Bezahlt</span>
|
<span className="text-sm text-gray-500">Bezahlt</span>
|
||||||
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
|
<span
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ color: branding?.accentColor ?? "#111827" }}
|
||||||
|
>
|
||||||
|
{formattedPrice}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
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 { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
|
||||||
import { executeBookingCancellation } from "./cancel-booking-shared";
|
import { executeBookingCancellation } from "./cancel-booking-shared";
|
||||||
import { combineDateAndTime } from "@/src/lib/course-time";
|
import { combineDateAndTime } from "@/src/lib/course-time";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||||
|
|
||||||
export type UserBooking = {
|
export type UserBooking = {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|
@ -37,6 +39,8 @@ export type UserBooking = {
|
||||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
|
businessName: string;
|
||||||
|
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getUserBookings(): Promise<
|
export async function getUserBookings(): Promise<
|
||||||
|
|
@ -71,6 +75,7 @@ export async function getUserBookings(): Promise<
|
||||||
coverImage: true,
|
coverImage: true,
|
||||||
description: true,
|
description: true,
|
||||||
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
|
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
|
||||||
|
business: { select: { name: true, branding: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -124,6 +129,7 @@ export async function getUserBookings(): Promise<
|
||||||
|
|
||||||
return bookingsRaw.map((b) => {
|
return bookingsRaw.map((b) => {
|
||||||
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
||||||
|
const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
|
||||||
return {
|
return {
|
||||||
bookingId: b.id,
|
bookingId: b.id,
|
||||||
courseId: b.course.id,
|
courseId: b.course.id,
|
||||||
|
|
@ -143,6 +149,12 @@ export async function getUserBookings(): Promise<
|
||||||
trainersMap,
|
trainersMap,
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
isPast: courseStart < now,
|
isPast: courseStart < now,
|
||||||
|
businessName: b.course.business?.name ?? branding.companyName,
|
||||||
|
businessBranding: {
|
||||||
|
logoUrl: branding.logoUrl,
|
||||||
|
accentColor: branding.accentColor,
|
||||||
|
accentTextColor: branding.accentTextColor,
|
||||||
|
},
|
||||||
} satisfies UserBooking;
|
} satisfies UserBooking;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -261,6 +273,7 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
address: true,
|
address: true,
|
||||||
|
branding: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -291,6 +304,7 @@ export async function getBusinessBySlug(slug: string) {
|
||||||
title: true,
|
title: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
branding: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { refundStripePayment } from "@/src/lib/stripe-refund";
|
||||||
import { sendMail } from "@/src/lib/mail/resend";
|
import { sendMail } from "@/src/lib/mail/resend";
|
||||||
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
||||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
|
||||||
export type CancelBookingResult =
|
export type CancelBookingResult =
|
||||||
| { success: true; refundedAmountCents: number; refundPercent: number }
|
| { success: true; refundedAmountCents: number; refundPercent: number }
|
||||||
|
|
@ -23,7 +24,7 @@ async function sendCancellationEmail(
|
||||||
booking: {
|
booking: {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
guestLeadId: string | null;
|
guestLeadId: string | null;
|
||||||
course: { name: string; business: { name: string } | null };
|
course: { name: string; business: { name: string; branding: unknown } | null };
|
||||||
},
|
},
|
||||||
amountRefundedCents: number,
|
amountRefundedCents: number,
|
||||||
originalAmountCents: number,
|
originalAmountCents: number,
|
||||||
|
|
@ -56,6 +57,7 @@ async function sendCancellationEmail(
|
||||||
reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"),
|
reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"),
|
||||||
originalAmountCents,
|
originalAmountCents,
|
||||||
companyName: process.env.COMPANY_NAME,
|
companyName: process.env.COMPANY_NAME,
|
||||||
|
branding: toEmailBranding(booking.course.business),
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendMail({ to: recipientEmail, subject, html, text });
|
await sendMail({ to: recipientEmail, subject, html, text });
|
||||||
|
|
@ -94,7 +96,7 @@ export async function executeBookingCancellation(
|
||||||
date: true,
|
date: true,
|
||||||
timeFrom: true,
|
timeFrom: true,
|
||||||
businessId: true,
|
businessId: true,
|
||||||
business: { select: { name: true } },
|
business: { select: { name: true, branding: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
||||||
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
||||||
|
|
@ -75,6 +76,9 @@ interface PublicCourseCardProps {
|
||||||
course: PublicCourse;
|
course: PublicCourse;
|
||||||
business?: BusinessInfo;
|
business?: BusinessInfo;
|
||||||
defaultOpen?: boolean; // öffnet das Detail-Popup beim Mount (für Deep-Links)
|
defaultOpen?: boolean; // öffnet das Detail-Popup beim Mount (für Deep-Links)
|
||||||
|
/** Business-Akzentfarbe für Buchen-Button/Fortschrittsbalken. Ohne Wert
|
||||||
|
* bleibt das App-Standarddesign (bg-blue) unverändert. */
|
||||||
|
accentColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
|
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
|
||||||
|
|
@ -124,13 +128,18 @@ export const BookingDialog = ({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
|
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
|
||||||
paymentStatus,
|
paymentStatus,
|
||||||
|
accentColor,
|
||||||
}: {
|
}: {
|
||||||
course: PublicCourse;
|
course: PublicCourse;
|
||||||
business?: BusinessInfo;
|
business?: BusinessInfo;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
|
accentColor?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const accentButtonStyle = accentColor
|
||||||
|
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
||||||
|
: undefined;
|
||||||
const sanitizedDescription = useSanitizedHtml(course.description);
|
const sanitizedDescription = useSanitizedHtml(course.description);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isBooked, setIsBooked] = useState(false);
|
const [isBooked, setIsBooked] = useState(false);
|
||||||
|
|
@ -419,6 +428,7 @@ export const BookingDialog = ({
|
||||||
onClick={() => handleBooking()}
|
onClick={() => handleBooking()}
|
||||||
disabled={isSubmitting || isFull}
|
disabled={isSubmitting || isFull}
|
||||||
className="flex-1 sm:flex-initial"
|
className="flex-1 sm:flex-initial"
|
||||||
|
style={!isFull ? accentButtonStyle : undefined}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -457,6 +467,7 @@ export const BookingDialog = ({
|
||||||
onClick={() => handleBooking({ asGuest: true })}
|
onClick={() => handleBooking({ asGuest: true })}
|
||||||
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
style={accentButtonStyle}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -468,7 +479,12 @@ export const BookingDialog = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCourseCardProps) => {
|
const PublicCourseCard = ({
|
||||||
|
course,
|
||||||
|
business,
|
||||||
|
defaultOpen = false,
|
||||||
|
accentColor,
|
||||||
|
}: PublicCourseCardProps) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(defaultOpen);
|
const [dialogOpen, setDialogOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
||||||
|
|
@ -551,8 +567,11 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
||||||
|
|
||||||
<div className="w-full h-2 bg-white/40 rounded-full overflow-hidden">
|
<div className="w-full h-2 bg-white/40 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
|
className={`h-full ${isFull ? "bg-red-500" : accentColor ? "" : "bg-blue-500"}`}
|
||||||
style={{ width: `${fillPercent}%` }}
|
style={{
|
||||||
|
width: `${fillPercent}%`,
|
||||||
|
backgroundColor: !isFull && accentColor ? accentColor : undefined,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -561,7 +580,15 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
||||||
Ausgebucht
|
Ausgebucht
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="w-full" onClick={() => setDialogOpen(true)}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
style={
|
||||||
|
accentColor
|
||||||
|
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
Jetzt anmelden
|
Jetzt anmelden
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -573,6 +600,7 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
||||||
business={business}
|
business={business}
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
|
accentColor={accentColor}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kursname */}
|
{/* Business + Kursname */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{booking.businessBranding.logoUrl && (
|
||||||
|
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
|
||||||
|
<Image
|
||||||
|
src={booking.businessBranding.logoUrl}
|
||||||
|
alt={booking.businessName}
|
||||||
|
fill
|
||||||
|
className="object-contain p-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
|
||||||
|
</div>
|
||||||
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
||||||
{booking.courseName}
|
{booking.courseName}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -170,6 +183,7 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
paymentStatus={effectiveStatus}
|
paymentStatus={effectiveStatus}
|
||||||
|
accentColor={booking.businessBranding.accentColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CancelBookingConfirmDialog
|
<CancelBookingConfirmDialog
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
import { useState, useTransition, useMemo } from "react";
|
import { useState, useTransition, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
import PublicCourseCard from "../components/PublicCourseCard";
|
import PublicCourseCard from "../components/PublicCourseCard";
|
||||||
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
|
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
|
||||||
import { Calendar } from "@/src/components/ui/calendar";
|
import { Calendar } from "@/src/components/ui/calendar";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
|
|
||||||
interface Business {
|
interface Business {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -14,6 +16,9 @@ interface Business {
|
||||||
email: string;
|
email: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrainerProfile {
|
interface TrainerProfile {
|
||||||
|
|
@ -119,6 +124,21 @@ const BusinessPublicView = ({
|
||||||
// Reset active sport if it no longer exists after a window refetch
|
// Reset active sport if it no longer exists after a window refetch
|
||||||
const resolvedSport = allSports.includes(activeSport) ? activeSport : "Alle";
|
const resolvedSport = allSports.includes(activeSport) ? activeSport : "Alle";
|
||||||
|
|
||||||
|
const accentTextColor = useMemo(
|
||||||
|
() => getContrastTextColor(business.accentColor),
|
||||||
|
[business.accentColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryTextColor = useMemo(
|
||||||
|
() => getContrastTextColor(business.primaryColor),
|
||||||
|
[business.primaryColor]
|
||||||
|
);
|
||||||
|
// Sekundärtext (Adresse/Titel) etwas dezenter als die Überschrift, aber im
|
||||||
|
// selben Hell/Dunkel-Lager - sonst könnte z.B. bei dunklem primaryColor eine
|
||||||
|
// "abgeschwächte" graue Sekundärfarbe erneut unlesbar werden.
|
||||||
|
const primaryMutedTextColor =
|
||||||
|
primaryTextColor === "#FFFFFF" ? "rgba(255,255,255,0.75)" : "#4B5563";
|
||||||
|
|
||||||
const displayedCourses = useMemo(() => {
|
const displayedCourses = useMemo(() => {
|
||||||
const dayStart = new Date(selectedDate);
|
const dayStart = new Date(selectedDate);
|
||||||
dayStart.setHours(0, 0, 0, 0);
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -145,16 +165,44 @@ const BusinessPublicView = ({
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-gray-200 bg-white/90 backdrop-blur-sm">
|
<div
|
||||||
|
className="border-b border-gray-200 backdrop-blur-sm"
|
||||||
|
style={{ backgroundColor: business.primaryColor }}
|
||||||
|
>
|
||||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||||
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1>
|
<div className="flex items-center gap-4">
|
||||||
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>}
|
{business.logoUrl && (
|
||||||
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-600">
|
<div className="relative w-14 h-14 shrink-0 rounded-lg overflow-hidden bg-white border border-gray-200">
|
||||||
|
<Image
|
||||||
|
src={business.logoUrl}
|
||||||
|
alt={business.name}
|
||||||
|
fill
|
||||||
|
className="object-contain p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="text-4xl font-bold" style={{ color: primaryTextColor }}>
|
||||||
|
{business.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{business.title && (
|
||||||
|
<p className="text-xl mt-1" style={{ color: primaryMutedTextColor }}>
|
||||||
|
{business.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="mt-4 flex flex-col sm:flex-row gap-2 text-sm"
|
||||||
|
style={{ color: primaryMutedTextColor }}
|
||||||
|
>
|
||||||
<span>{business.address}</span>
|
<span>{business.address}</span>
|
||||||
{business.email && (
|
{business.email && (
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline">·</span>
|
<span className="hidden sm:inline">·</span>
|
||||||
<a href={`mailto:${business.email}`} className="hover:underline text-blue-600">
|
<a
|
||||||
|
href={`mailto:${business.email}`}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: primaryTextColor }}
|
||||||
|
>
|
||||||
{business.email}
|
{business.email}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
|
|
@ -193,9 +241,14 @@ const BusinessPublicView = ({
|
||||||
onClick={() => setActiveSport(sport)}
|
onClick={() => setActiveSport(sport)}
|
||||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
resolvedSport === sport
|
resolvedSport === sport
|
||||||
? "bg-gray-900 text-white"
|
? ""
|
||||||
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
|
style={
|
||||||
|
resolvedSport === sport
|
||||||
|
? { backgroundColor: business.accentColor, color: accentTextColor }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{sport === "Alle" ? "Alle" : capitalize(sport)}
|
{sport === "Alle" ? "Alle" : capitalize(sport)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -221,6 +274,7 @@ const BusinessPublicView = ({
|
||||||
course={course}
|
course={course}
|
||||||
business={{ address: business.address, email: business.email }}
|
business={{ address: business.address, email: business.email }}
|
||||||
defaultOpen={course.id === deepLinkCourseId}
|
defaultOpen={course.id === deepLinkCourseId}
|
||||||
|
accentColor={business.accentColor}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { prisma } from "@/src/lib/prisma";
|
||||||
import { requireManager } from "@/src/lib/auth/getUser";
|
import { requireManager } from "@/src/lib/auth/getUser";
|
||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
import { combineDateAndTime } from "@/src/lib/course-time";
|
import { combineDateAndTime } from "@/src/lib/course-time";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||||
|
|
||||||
export type KundeBooking = {
|
export type KundeBooking = {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|
@ -31,6 +33,8 @@ export type KundeBooking = {
|
||||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
|
businessName: string;
|
||||||
|
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KundeDetail = {
|
export type KundeDetail = {
|
||||||
|
|
@ -98,6 +102,7 @@ export async function getKundeDetail(
|
||||||
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
|
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
|
||||||
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
|
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
|
||||||
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
|
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
|
||||||
|
business: { select: { name: true, branding: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -154,6 +159,7 @@ export async function getKundeDetail(
|
||||||
|
|
||||||
const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => {
|
const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => {
|
||||||
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
||||||
|
const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
|
||||||
return {
|
return {
|
||||||
bookingId: b.id,
|
bookingId: b.id,
|
||||||
courseId: b.course.id,
|
courseId: b.course.id,
|
||||||
|
|
@ -173,6 +179,12 @@ export async function getKundeDetail(
|
||||||
trainersMap,
|
trainersMap,
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
isPast: courseStart < now,
|
isPast: courseStart < now,
|
||||||
|
businessName: b.course.business?.name ?? branding.companyName,
|
||||||
|
businessBranding: {
|
||||||
|
logoUrl: branding.logoUrl,
|
||||||
|
accentColor: branding.accentColor,
|
||||||
|
accentTextColor: branding.accentTextColor,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,20 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kursname */}
|
{/* Business + Kursname */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{booking.businessBranding.logoUrl && (
|
||||||
|
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
|
||||||
|
<Image
|
||||||
|
src={booking.businessBranding.logoUrl}
|
||||||
|
alt={booking.businessName}
|
||||||
|
fill
|
||||||
|
className="object-contain p-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
|
||||||
|
</div>
|
||||||
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
||||||
{booking.courseName}
|
{booking.courseName}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -160,6 +173,7 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
paymentStatus={booking.paymentStatus}
|
paymentStatus={booking.paymentStatus}
|
||||||
|
accentColor={booking.businessBranding.accentColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CancelBookingConfirmDialog
|
<CancelBookingConfirmDialog
|
||||||
|
|
|
||||||
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 = () => {
|
"use client";
|
||||||
return <div>SettingsBrandingPageView</div>;
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Megaphone, AlertTriangle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/src/components/ui/select";
|
||||||
|
import BusinessBrandingEditor from "../components/BusinessBrandingEditor";
|
||||||
|
import { getBusinessBranding, saveBusinessBranding } from "../../actions/business-branding-actions";
|
||||||
|
import type { BusinessBranding } from "../../schemas/business-branding-schema";
|
||||||
|
|
||||||
|
interface SettingsBrandingPageViewProps {
|
||||||
|
businesses: { id: string; name: string }[];
|
||||||
|
initialBranding: BusinessBranding;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsBrandingPageView = ({
|
||||||
|
businesses,
|
||||||
|
initialBranding,
|
||||||
|
}: SettingsBrandingPageViewProps) => {
|
||||||
|
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
|
||||||
|
const [branding, setBranding] = useState(initialBranding);
|
||||||
|
// Getrennt von "kein Branding gespeichert" - eine fehlgeschlagene Anfrage
|
||||||
|
// darf niemals als leeres/Default-Branding dargestellt werden, sonst würde
|
||||||
|
// ein "Speichern" versehentlich das tatsächlich gespeicherte Branding mit
|
||||||
|
// Defaults überschreiben, ohne dass der Manager einen Fehler sieht.
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [loadingBranding, startLoadingBranding] = useTransition();
|
||||||
|
const [isSaving, startSaving] = useTransition();
|
||||||
|
|
||||||
|
const handleBusinessChange = (businessId: string) => {
|
||||||
|
const previousBusinessId = selectedBusinessId;
|
||||||
|
setSelectedBusinessId(businessId);
|
||||||
|
setLoadError(null);
|
||||||
|
startLoadingBranding(async () => {
|
||||||
|
const result = await getBusinessBranding(businessId);
|
||||||
|
if (result.success) {
|
||||||
|
setBranding(result.branding);
|
||||||
|
} else {
|
||||||
|
// Auswahl zurücksetzen, damit die UI nicht unter dem neuen
|
||||||
|
// Business-Namen die Werte des vorherigen Business anzeigt und ein
|
||||||
|
// Speichern sie fälschlich der neuen Business-ID zuordnet.
|
||||||
|
setSelectedBusinessId(previousBusinessId);
|
||||||
|
setLoadError(result.error);
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (newBranding: BusinessBranding): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
startSaving(async () => {
|
||||||
|
try {
|
||||||
|
const result = await saveBusinessBranding(selectedBusinessId, newBranding);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Branding gespeichert.");
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Branding konnte nicht gespeichert werden.");
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl flex items-center gap-2 font-bold">
|
||||||
|
<Megaphone /> Branding
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Platziere dein Logo und nimm deine Farben — für E-Mails, die Buchungsseite und Rechnungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{businesses.length === 0 ? (
|
||||||
|
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{businesses.length > 1 && (
|
||||||
|
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
|
||||||
|
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
|
||||||
|
<SelectValue placeholder="Business" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||||
|
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>{loadError} Bitte lade die Seite neu, um das Branding zu bearbeiten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={loadingBranding ? "opacity-60 transition-opacity" : "transition-opacity"}
|
||||||
|
>
|
||||||
|
<BusinessBrandingEditor
|
||||||
|
key={selectedBusinessId}
|
||||||
|
businessId={selectedBusinessId}
|
||||||
|
initialBranding={branding}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsBrandingPageView;
|
export default SettingsBrandingPageView;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { prisma } from "@/src/lib/prisma";
|
||||||
import type { ManagerData } from "@/src/lib/auth/getUser";
|
import type { ManagerData } from "@/src/lib/auth/getUser";
|
||||||
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
|
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
|
||||||
import type { ZahlungenFilters } from "./zahlungen-actions";
|
import type { ZahlungenFilters } from "./zahlungen-actions";
|
||||||
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
|
||||||
// Obergrenze für den ZIP-Sammel-Export, damit ein sehr weiter Filter nicht
|
// Obergrenze für den ZIP-Sammel-Export, damit ein sehr weiter Filter nicht
|
||||||
// eine unbegrenzte Zahl an PDFs auf einmal rendert.
|
// eine unbegrenzte Zahl an PDFs auf einmal rendert.
|
||||||
|
|
@ -32,6 +33,8 @@ export type InvoiceData = {
|
||||||
email: string;
|
email: string;
|
||||||
ustId: string | null;
|
ustId: string | null;
|
||||||
banking: string | null;
|
banking: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
accentColor: string;
|
||||||
};
|
};
|
||||||
course: {
|
course: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -117,6 +120,7 @@ const invoiceBookingSelect = {
|
||||||
email: true,
|
email: true,
|
||||||
ustId: true,
|
ustId: true,
|
||||||
banking: true,
|
banking: true,
|
||||||
|
branding: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -159,6 +163,8 @@ function toInvoiceData(
|
||||||
city: null,
|
city: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const branding = toEmailBranding(booking.course.business);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
issueDate: issuedAt.toISOString(),
|
issueDate: issuedAt.toISOString(),
|
||||||
|
|
@ -168,6 +174,8 @@ function toInvoiceData(
|
||||||
email: booking.course.business.email,
|
email: booking.course.business.email,
|
||||||
ustId: booking.course.business.ustId,
|
ustId: booking.course.business.ustId,
|
||||||
banking: booking.course.business.banking,
|
banking: booking.course.business.banking,
|
||||||
|
logoUrl: branding.logoUrl,
|
||||||
|
accentColor: branding.accentColor,
|
||||||
},
|
},
|
||||||
course: {
|
course: {
|
||||||
name: booking.course.name,
|
name: booking.course.name,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
|
import { Document, Page, Text, View, Image, StyleSheet } from "@react-pdf/renderer";
|
||||||
import type { InvoiceData } from "../../actions/invoice-actions";
|
import type { InvoiceData } from "../../actions/invoice-actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @react-pdf/renderer lädt Bilder zur Renderzeit serverseitig über die
|
||||||
|
* angegebene URL nach - eine unerreichbare/kaputte Logo-URL darf den
|
||||||
|
* gesamten Rechnungs-Download nicht zum Absturz bringen. Nur http(s)
|
||||||
|
* zulassen (kein data:/file:/beliebiges Protokoll).
|
||||||
|
*/
|
||||||
|
function isSafeImageUrl(url: string | null): url is string {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { padding: 48, fontSize: 10, color: "#1a1a1a", fontFamily: "Helvetica" },
|
page: { padding: 48, fontSize: 10, color: "#1a1a1a", fontFamily: "Helvetica" },
|
||||||
header: { marginBottom: 32 },
|
header: { marginBottom: 32, flexDirection: "row", justifyContent: "space-between" },
|
||||||
|
headerText: { flexDirection: "column" },
|
||||||
|
logo: { width: 64, height: 64, objectFit: "contain" },
|
||||||
businessName: { fontSize: 14, fontWeight: 700, marginBottom: 4 },
|
businessName: { fontSize: 14, fontWeight: 700, marginBottom: 4 },
|
||||||
muted: { color: "#666666" },
|
muted: { color: "#666666" },
|
||||||
title: { fontSize: 18, fontWeight: 700, marginTop: 32, marginBottom: 4 },
|
title: { fontSize: 18, fontWeight: 700, marginTop: 32, marginBottom: 4 },
|
||||||
|
|
@ -65,9 +83,16 @@ function formatDate(iso: string): string {
|
||||||
|
|
||||||
interface InvoicePdfDocumentProps {
|
interface InvoicePdfDocumentProps {
|
||||||
data: InvoiceData;
|
data: InvoiceData;
|
||||||
|
/**
|
||||||
|
* Erlaubt dem Route-Handler, nach einem fehlgeschlagenen Render-Versuch
|
||||||
|
* (z.B. unerreichbare Logo-URL - @react-pdf/renderer lädt Bilder zur
|
||||||
|
* Renderzeit serverseitig nach) gezielt ohne Logo neu zu rendern, statt den
|
||||||
|
* gesamten Rechnungs-Download scheitern zu lassen.
|
||||||
|
*/
|
||||||
|
includeLogo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
|
export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocumentProps) {
|
||||||
const {
|
const {
|
||||||
business,
|
business,
|
||||||
course,
|
course,
|
||||||
|
|
@ -85,17 +110,23 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
|
||||||
? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}`
|
? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const hasLogo = includeLogo && isSafeImageUrl(business.logoUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.businessName}>{business.name}</Text>
|
<View style={styles.headerText}>
|
||||||
<Text style={styles.muted}>{business.address}</Text>
|
<Text style={styles.businessName}>{business.name}</Text>
|
||||||
<Text style={styles.muted}>{business.email}</Text>
|
<Text style={styles.muted}>{business.address}</Text>
|
||||||
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
|
<Text style={styles.muted}>{business.email}</Text>
|
||||||
|
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
|
||||||
|
</View>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer's Image hat kein alt-Prop, nicht das DOM-<img> */}
|
||||||
|
{hasLogo ? <Image src={business.logoUrl!} style={styles.logo} /> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.title}>Rechnung</Text>
|
<Text style={[styles.title, { color: business.accentColor }]}>Rechnung</Text>
|
||||||
|
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<View style={styles.metaBlock}>
|
<View style={styles.metaBlock}>
|
||||||
|
|
@ -141,7 +172,9 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
|
||||||
|
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Gesamtbetrag</Text>
|
<Text style={styles.totalLabel}>Gesamtbetrag</Text>
|
||||||
<Text style={styles.totalValue}>{formatPrice(amountCents)}</Text>
|
<Text style={[styles.totalValue, { color: business.accentColor }]}>
|
||||||
|
{formatPrice(amountCents)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}>
|
<Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue