mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge branch 'dev' of https://github.com/Since-3/Kuma into dev
This commit is contained in:
commit
fad1dd2106
22 changed files with 670 additions and 325 deletions
|
|
@ -152,7 +152,7 @@ model Course {
|
||||||
isStandingOrder Boolean @default(false)
|
isStandingOrder Boolean @default(false)
|
||||||
frequency String? // daily, weekly, biweekly, monthly, custom
|
frequency String? // daily, weekly, biweekly, monthly, custom
|
||||||
weekdays String[] // For custom frequency
|
weekdays String[] // For custom frequency
|
||||||
weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... }
|
weekdayTimings Json? // { "monday": [{ "timeFrom": "08:00", "timeTo": "09:00" }, ...], ... } — mehrere Zeitfenster pro Tag. Legacy: { "monday": { "timeFrom", "timeTo" } }
|
||||||
endDate DateTime? // Required when isStandingOrder = true
|
endDate DateTime? // Required when isStandingOrder = true
|
||||||
parentCourseId String? // Null on template; set on every child instance
|
parentCourseId String? // Null on template; set on every child instance
|
||||||
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
||||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||||
|
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
|
|
||||||
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
// Best-effort only — in multi-instance/serverless deployments each instance has
|
||||||
|
|
@ -92,6 +93,20 @@ export async function POST(request: NextRequest) {
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manager/Mitarbeiter dürfen nicht als Gast mit ihrer geschäftlichen
|
||||||
|
// E-Mail buchen: sie haben keinen User-Eintrag, die Buchung liefe als
|
||||||
|
// GuestLead durch und würde den Account-Kontext umgehen. Vor der
|
||||||
|
// Stripe-Session abfangen, damit kein Geld fließt.
|
||||||
|
if (await isManagerOrEmployeeEmail(guestEmail.trim().toLowerCase())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Diese E-Mail-Adresse gehört zu einem Team-Account und kann nicht für eine Gast-Buchung verwendet werden. Bitte mit einem Kunden-Konto anmelden oder eine andere E-Mail-Adresse verwenden.",
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const course = await prisma.course.findUnique({
|
const course = await prisma.course.findUnique({
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "@/src/lib/mail/templates/booking-refund";
|
} from "@/src/lib/mail/templates/booking-refund";
|
||||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||||
|
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||||
|
|
@ -126,6 +127,29 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sicherheitsnetz: Manager/Mitarbeiter dürfen nicht als Gast mit ihrer
|
||||||
|
// geschäftlichen E-Mail buchen. Die Checkout-Route fängt das bereits vor
|
||||||
|
// der Stripe-Session ab; falls eine Session diesen Pfad dennoch erreicht
|
||||||
|
// (direkter API-Aufruf, Race-Condition), wird hier keine Buchung
|
||||||
|
// angelegt und die Zahlung sofort erstattet.
|
||||||
|
if (await isManagerOrEmployeeEmail(guestEmail)) {
|
||||||
|
console.warn(
|
||||||
|
`checkout.session.completed: Gast-E-Mail ${guestEmail} gehört zu einem Team-Account. ` +
|
||||||
|
`Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).`
|
||||||
|
);
|
||||||
|
if (paymentIntentId) {
|
||||||
|
const refundResult = await refundStripePayment(paymentIntentId);
|
||||||
|
if (!refundResult.success) {
|
||||||
|
console.error(
|
||||||
|
`Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` +
|
||||||
|
`(PaymentIntent ${paymentIntentId}): ${refundResult.error}. ` +
|
||||||
|
`Manuelle Erstattung im Stripe Dashboard nötig.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der
|
// Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der
|
||||||
// Gast bereits mit derselben E-Mail registriert haben. In dem Fall die
|
// Gast bereits mit derselben E-Mail registriert haben. In dem Fall die
|
||||||
// Buchung direkt dem neuen Account zuordnen statt einen verwaisten
|
// Buchung direkt dem neuen Account zuordnen statt einen verwaisten
|
||||||
|
|
@ -161,6 +185,26 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
session.customer_details?.email ||
|
session.customer_details?.email ||
|
||||||
session.customer_email;
|
session.customer_email;
|
||||||
if (fallbackEmail) {
|
if (fallbackEmail) {
|
||||||
|
// Wie im Guest-Zweig: Team-Account-E-Mails abfangen und erstatten,
|
||||||
|
// statt eine GuestLead-Buchung anzulegen.
|
||||||
|
if (await isManagerOrEmployeeEmail(fallbackEmail)) {
|
||||||
|
console.warn(
|
||||||
|
`checkout.session.completed: Fallback-E-Mail ${fallbackEmail} gehört zu einem ` +
|
||||||
|
`Team-Account. Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).`
|
||||||
|
);
|
||||||
|
if (paymentIntentId) {
|
||||||
|
const refundResult = await refundStripePayment(paymentIntentId);
|
||||||
|
if (!refundResult.success) {
|
||||||
|
console.error(
|
||||||
|
`Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` +
|
||||||
|
`(PaymentIntent ${paymentIntentId}): ${refundResult.error}. ` +
|
||||||
|
`Manuelle Erstattung im Stripe Dashboard nötig.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const registeredUser = await findUserByEmailInsensitive(fallbackEmail);
|
const registeredUser = await findUserByEmailInsensitive(fallbackEmail);
|
||||||
outcome = registeredUser
|
outcome = registeredUser
|
||||||
? await runCapacityTransactionUser({
|
? await runCapacityTransactionUser({
|
||||||
|
|
|
||||||
28
src/lib/auth/account-role-by-email.ts
Normal file
28
src/lib/auth/account-role-by-email.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { prisma } from "@/src/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob eine E-Mail bereits zu einem Manager- oder Employee-Account gehört.
|
||||||
|
*
|
||||||
|
* Wird im Gast-Buchungsflow gebraucht: Manager/Mitarbeiter haben keinen Eintrag
|
||||||
|
* in der User-Tabelle und dürfen Kurse nicht als Gast mit ihrer geschäftlichen
|
||||||
|
* E-Mail buchen (siehe Checkout-Route und Stripe-Webhook). Der Vergleich ist
|
||||||
|
* case-insensitive, da Gast-E-Mails immer lowercase normalisiert werden, ältere
|
||||||
|
* Manager/Employee-Datensätze aber abweichende Schreibweisen haben können.
|
||||||
|
*/
|
||||||
|
export async function isManagerOrEmployeeEmail(email: string): Promise<boolean> {
|
||||||
|
const normalized = email.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
const [manager, employee] = await Promise.all([
|
||||||
|
prisma.manager.findFirst({
|
||||||
|
where: { email: { equals: normalized, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.employee.findFirst({
|
||||||
|
where: { email: { equals: normalized, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return !!manager || !!employee;
|
||||||
|
}
|
||||||
48
src/lib/image/square-logo.ts
Normal file
48
src/lib/image/square-logo.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
const OUTPUT_SIZE = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passt ein Logo automatisch in ein quadratisches, transparentes PNG ein
|
||||||
|
* ("contain"): das Seitenverhältnis bleibt erhalten, nichts wird abgeschnitten,
|
||||||
|
* der Rand bleibt transparent. Ersetzt das manuelle Zoom/Pan-Zuschneiden – da
|
||||||
|
* Logos überall via `object-contain` in einem Quadrat dargestellt werden
|
||||||
|
* (Header, Rechnung, öffentliche Seite), reicht ein zentriertes Einpassen.
|
||||||
|
*
|
||||||
|
* SVGs werden unverändert zurückgegeben (Vektor bleibt scharf, das Rastern
|
||||||
|
* würde nur Qualität kosten). Nicht-Bild-Dateien ebenso.
|
||||||
|
*/
|
||||||
|
export async function squareLogo(file: File, size: number = OUTPUT_SIZE): Promise<File> {
|
||||||
|
if (!file.type.startsWith("image/") || file.type === "image/svg+xml") {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(file).catch(() => null);
|
||||||
|
if (!bitmap) return file;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contain-Scale: längere Kante füllt das Quadrat, die kürzere wird zentriert.
|
||||||
|
const scale = Math.min(size / bitmap.width, size / bitmap.height);
|
||||||
|
const drawWidth = bitmap.width * scale;
|
||||||
|
const drawHeight = bitmap.height * scale;
|
||||||
|
const x = (size - drawWidth) / 2;
|
||||||
|
const y = (size - drawHeight) / 2;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.drawImage(bitmap, x, y, drawWidth, drawHeight);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
|
if (!blob) return file;
|
||||||
|
|
||||||
|
const name = file.name.replace(/\.[^.]+$/, "") + ".png";
|
||||||
|
return new File([blob], name, { type: "image/png", lastModified: Date.now() });
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import MultiStepProzess from "./MultiStepProzess";
|
import MultiStepProzess from "./MultiStepProzess";
|
||||||
import Logo from "@/src/public/logo.png";
|
import Logo from "@/src/public/logo-gelb.png";
|
||||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/src/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/src/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ export type UserBooking = {
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
|
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
|
||||||
|
businessAddress: string | null;
|
||||||
|
businessEmail: string | null;
|
||||||
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,7 +78,9 @@ 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 } },
|
business: {
|
||||||
|
select: { name: true, address: true, email: true, branding: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -150,6 +155,8 @@ export async function getUserBookings(): Promise<
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
isPast: courseStart < now,
|
isPast: courseStart < now,
|
||||||
businessName: b.course.business?.name ?? branding.companyName,
|
businessName: b.course.business?.name ?? branding.companyName,
|
||||||
|
businessAddress: b.course.business?.address ?? null,
|
||||||
|
businessEmail: b.course.business?.email ?? null,
|
||||||
businessBranding: {
|
businessBranding: {
|
||||||
logoUrl: branding.logoUrl,
|
logoUrl: branding.logoUrl,
|
||||||
accentColor: branding.accentColor,
|
accentColor: branding.accentColor,
|
||||||
|
|
|
||||||
|
|
@ -125,11 +125,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
||||||
defaultTimeFrom: validatedData.timeFrom || "",
|
defaultTimeFrom: validatedData.timeFrom || "",
|
||||||
defaultTimeTo: validatedData.timeTo || "",
|
defaultTimeTo: validatedData.timeTo || "",
|
||||||
weekdays: validatedData.weekdays || [],
|
weekdays: validatedData.weekdays || [],
|
||||||
weekdayTimings:
|
weekdayTimings: validatedData.weekdayTimings ?? {},
|
||||||
(validatedData.weekdayTimings as Record<
|
|
||||||
string,
|
|
||||||
{ timeFrom: string; timeTo: string }
|
|
||||||
>) ?? {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (occurrences.length > 0) {
|
if (occurrences.length > 0) {
|
||||||
|
|
@ -551,11 +547,7 @@ export async function updateCourse(
|
||||||
defaultTimeFrom: validatedData.timeFrom || "",
|
defaultTimeFrom: validatedData.timeFrom || "",
|
||||||
defaultTimeTo: validatedData.timeTo || "",
|
defaultTimeTo: validatedData.timeTo || "",
|
||||||
weekdays: validatedData.weekdays || [],
|
weekdays: validatedData.weekdays || [],
|
||||||
weekdayTimings:
|
weekdayTimings: validatedData.weekdayTimings ?? {},
|
||||||
(validatedData.weekdayTimings as Record<
|
|
||||||
string,
|
|
||||||
{ timeFrom: string; timeTo: string }
|
|
||||||
>) ?? {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Das Template selbst wird (wie bei createCourse) nicht als sichtbarer
|
// Das Template selbst wird (wie bei createCourse) nicht als sichtbarer
|
||||||
|
|
|
||||||
143
src/modules/courses/hooks/useWeekdayTimings.ts
Normal file
143
src/modules/courses/hooks/useWeekdayTimings.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type WeekdaySlot = { timeFrom: string; timeTo: string };
|
||||||
|
export type WeekdayTimings = Record<string, WeekdaySlot[]>;
|
||||||
|
|
||||||
|
/** Rohform, wie sie aus der DB kommen kann (neues Array- oder altes Einzelobjekt-Format). */
|
||||||
|
type RawWeekdayTimings =
|
||||||
|
| Record<string, WeekdaySlot | WeekdaySlot[] | null | undefined>
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export const emptySlot = (): WeekdaySlot => ({ timeFrom: "", timeTo: "" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt gespeicherte weekdayTimings in das Array-Format. Akzeptiert sowohl das
|
||||||
|
* neue Format ({ monday: [{...}] }) als auch das alte Einzelobjekt-Format
|
||||||
|
* ({ monday: { timeFrom, timeTo } }) aus bestehenden DB-Datensätzen.
|
||||||
|
*/
|
||||||
|
export const normalizeWeekdayTimings = (raw: RawWeekdayTimings): WeekdayTimings => {
|
||||||
|
if (!raw) return {};
|
||||||
|
const result: WeekdayTimings = {};
|
||||||
|
for (const [day, value] of Object.entries(raw)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const slots = value.filter((s): s is WeekdaySlot => !!s && typeof s === "object");
|
||||||
|
if (slots.length > 0) result[day] = slots;
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
result[day] = [{ timeFrom: value.timeFrom ?? "", timeTo: value.timeTo ?? "" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseWeekdayTimingsOptions {
|
||||||
|
/** Vorbelegung (Edit-Modus) — beliebiges gespeichertes Format. */
|
||||||
|
initialWeekdays?: string[];
|
||||||
|
initialTimings?: RawWeekdayTimings;
|
||||||
|
/**
|
||||||
|
* Uhrzeit, mit der neu ausgewählte Tage vorbefüllt werden — hier die
|
||||||
|
* allgemeinen Kurs-Zeitfelder ("Uhrzeit von/bis" oben im Formular). Wird bei
|
||||||
|
* jedem Render aktuell übergeben; leere Werte belegen einen leeren Slot.
|
||||||
|
*/
|
||||||
|
defaultSlot?: WeekdaySlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kapselt die Wochentag-Auswahl und deren Zeitfenster für die "custom"-Häufigkeit
|
||||||
|
* eines Dauerauftrags. Jeder Tag kann mehrere Zeitfenster haben. Neu ausgewählte
|
||||||
|
* Tage (und neue Zeitfenster) werden mit der allgemeinen Kurs-Uhrzeit
|
||||||
|
* vorbelegt, falls diese gesetzt ist.
|
||||||
|
*/
|
||||||
|
export function useWeekdayTimings({
|
||||||
|
initialWeekdays = [],
|
||||||
|
initialTimings,
|
||||||
|
defaultSlot,
|
||||||
|
}: UseWeekdayTimingsOptions = {}) {
|
||||||
|
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(initialWeekdays);
|
||||||
|
const [weekdayTimings, setWeekdayTimings] = useState<WeekdayTimings>(() =>
|
||||||
|
normalizeWeekdayTimings(initialTimings)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vorbelegung neuer Slots mit der aktuellen Kurs-Uhrzeit aus den oberen
|
||||||
|
// Zeitfeldern. Die Callbacks unten sind nicht memoisiert, lesen also bei
|
||||||
|
// jedem Render den aktuellen Wert.
|
||||||
|
const newSlot = (): WeekdaySlot => ({
|
||||||
|
timeFrom: defaultSlot?.timeFrom ?? "",
|
||||||
|
timeTo: defaultSlot?.timeTo ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setSelectedWeekdays([]);
|
||||||
|
setWeekdayTimings({});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hält weekdayTimings mit der Tagesauswahl synchron: jeder neu ausgewählte Tag
|
||||||
|
// bekommt einen mit der Kurs-Uhrzeit vorbefüllten Slot, abgewählte Tage fallen raus.
|
||||||
|
const syncWeekdays = (days: string[]) => {
|
||||||
|
setSelectedWeekdays(days);
|
||||||
|
setWeekdayTimings((prev) => {
|
||||||
|
const next: WeekdayTimings = {};
|
||||||
|
days.forEach((d) => {
|
||||||
|
next[d] = prev[d]?.length ? prev[d] : [newSlot()];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSlot = (day: string, index: number, patch: Partial<WeekdaySlot>) => {
|
||||||
|
setWeekdayTimings((prev) => {
|
||||||
|
const slots = (prev[day] ?? [emptySlot()]).map((s, i) =>
|
||||||
|
i === index ? { ...s, ...patch } : s
|
||||||
|
);
|
||||||
|
return { ...prev, [day]: slots };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSlot = (day: string) => {
|
||||||
|
setWeekdayTimings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[day]: [...(prev[day] ?? []), newSlot()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlot = (day: string, index: number) => {
|
||||||
|
setWeekdayTimings((prev) => {
|
||||||
|
const slots = (prev[day] ?? []).filter((_, i) => i !== index);
|
||||||
|
return { ...prev, [day]: slots.length ? slots : [newSlot()] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nur befüllte Slots; Tage ohne gültige Uhrzeit fallen weg. `undefined`, wenn leer. */
|
||||||
|
const cleanedTimings = useMemo((): WeekdayTimings | undefined => {
|
||||||
|
const cleaned: WeekdayTimings = {};
|
||||||
|
for (const [day, slots] of Object.entries(weekdayTimings)) {
|
||||||
|
const filled = slots.filter((s) => s.timeFrom || s.timeTo);
|
||||||
|
if (filled.length > 0) cleaned[day] = filled;
|
||||||
|
}
|
||||||
|
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||||
|
}, [weekdayTimings]);
|
||||||
|
|
||||||
|
/** True, wenn ein ausgewählter Tag kein oder ein ungültiges Zeitfenster hat. */
|
||||||
|
const hasInvalidTiming = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedWeekdays.some((day) => {
|
||||||
|
const slots = weekdayTimings[day] ?? [];
|
||||||
|
if (slots.length === 0) return true;
|
||||||
|
return slots.some((s) => !s.timeFrom || !s.timeTo || s.timeFrom >= s.timeTo);
|
||||||
|
}),
|
||||||
|
[selectedWeekdays, weekdayTimings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedWeekdays,
|
||||||
|
weekdayTimings,
|
||||||
|
cleanedTimings,
|
||||||
|
hasInvalidTiming,
|
||||||
|
reset,
|
||||||
|
syncWeekdays,
|
||||||
|
updateSlot,
|
||||||
|
addSlot,
|
||||||
|
removeSlot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,26 @@ const frequencyEnum = z.enum(VALID_FREQUENCIES as [string, ...string[]], {
|
||||||
message: "Ungültige Häufigkeit",
|
message: "Ungültige Häufigkeit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const weekdaySlotSchema = z.object({ timeFrom: z.string(), timeTo: z.string() });
|
||||||
|
|
||||||
|
// Neues Format: mehrere Zeitfenster pro Tag. Das alte Einzelobjekt-Format
|
||||||
|
// ({ monday: { timeFrom, timeTo } }) wird für Abwärtskompatibilität weiterhin
|
||||||
|
// akzeptiert und in ein Array normalisiert.
|
||||||
const weekdayTimingsSchema = z
|
const weekdayTimingsSchema = z
|
||||||
.record(z.string(), z.object({ timeFrom: z.string(), timeTo: z.string() }))
|
.record(z.string(), z.union([z.array(weekdaySlotSchema), weekdaySlotSchema]))
|
||||||
|
.transform((timings) => {
|
||||||
|
const normalized: Record<string, { timeFrom: string; timeTo: string }[]> = {};
|
||||||
|
for (const [day, value] of Object.entries(timings)) {
|
||||||
|
const slots = Array.isArray(value) ? value : [value];
|
||||||
|
const filled = slots.filter((s) => s.timeFrom || s.timeTo);
|
||||||
|
if (filled.length > 0) normalized[day] = filled;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(timings) =>
|
(timings) =>
|
||||||
Object.values(timings).every(
|
Object.values(timings).every((slots) =>
|
||||||
({ timeFrom, timeTo }) => !timeFrom || !timeTo || timeFrom < timeTo
|
slots.every(({ timeFrom, timeTo }) => !timeFrom || !timeTo || timeFrom < timeTo)
|
||||||
),
|
),
|
||||||
{ message: "Endzeit muss nach der Anfangszeit liegen (Wochentag-Uhrzeiten)" }
|
{ message: "Endzeit muss nach der Anfangszeit liegen (Wochentag-Uhrzeiten)" }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ export type Course = {
|
||||||
isStandingOrder: boolean;
|
isStandingOrder: boolean;
|
||||||
frequency: string | null;
|
frequency: string | null;
|
||||||
weekdays: string[];
|
weekdays: string[];
|
||||||
weekdayTimings: Record<string, { timeFrom: string; timeTo: string }> | null;
|
weekdayTimings:
|
||||||
|
| Record<string, { timeFrom: string; timeTo: string }[]>
|
||||||
|
// Altes Format, kann noch in bestehenden DB-Datensätzen liegen
|
||||||
|
| Record<string, { timeFrom: string; timeTo: string }>
|
||||||
|
| null;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
parentCourseId: string | null;
|
parentCourseId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,11 @@ export const BookingDialog = ({
|
||||||
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
|
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
|
||||||
paymentStatus,
|
paymentStatus,
|
||||||
accentColor,
|
accentColor,
|
||||||
|
// Wenn gesetzt → im Footer erscheint ein "Stornieren"-Button (z.B. "Meine
|
||||||
|
// Kurse": bezahlte, kommende Buchung). Das Öffnen des Bestätigungsdialogs
|
||||||
|
// liegt beim Aufrufer.
|
||||||
|
canCancel = false,
|
||||||
|
onRequestCancel,
|
||||||
}: {
|
}: {
|
||||||
course: PublicCourse;
|
course: PublicCourse;
|
||||||
business?: BusinessInfo;
|
business?: BusinessInfo;
|
||||||
|
|
@ -148,6 +153,8 @@ export const BookingDialog = ({
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
|
canCancel?: boolean;
|
||||||
|
onRequestCancel?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const accentButtonStyle = accentColor
|
const accentButtonStyle = accentColor
|
||||||
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
||||||
|
|
@ -434,6 +441,16 @@ export const BookingDialog = ({
|
||||||
>
|
>
|
||||||
{adminStatus!.label}
|
{adminStatus!.label}
|
||||||
</span>
|
</span>
|
||||||
|
{canCancel && onRequestCancel && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRequestCancel}
|
||||||
|
className="flex-1 sm:flex-initial text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
|
||||||
|
>
|
||||||
|
<X size={14} className="mr-1.5 shrink-0" />
|
||||||
|
Stornieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,25 @@
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { MapPin, Clock, X } from "lucide-react";
|
import { MapPin, Clock, Users } from "lucide-react";
|
||||||
import type { UserBooking } from "../../actions/booking-actions";
|
import type { UserBooking } from "../../actions/booking-actions";
|
||||||
import { cancelUserBooking } from "../../actions/booking-actions";
|
import { cancelUserBooking } from "../../actions/booking-actions";
|
||||||
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
|
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
|
||||||
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
|
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
|
||||||
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
|
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
|
||||||
import type { PublicCourse } from "@/src/modules/courses/ui/components/PublicCourseCard";
|
import type {
|
||||||
import { Button } from "@/src/components/ui/button";
|
PublicCourse,
|
||||||
|
BusinessInfo,
|
||||||
|
} from "@/src/modules/courses/ui/components/PublicCourseCard";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, { label: string; bg: string; text: string }> = {
|
||||||
|
any: { label: "Jedes Niveau", bg: "bg-gray-100", text: "text-gray-600" },
|
||||||
|
beginner: { label: "Anfänger", bg: "bg-blue-100", text: "text-blue-700" },
|
||||||
|
advanced: { label: "Fortgeschrittene", bg: "bg-orange-100", text: "text-orange-700" },
|
||||||
|
pro: { label: "Profi", bg: "bg-red-100", text: "text-red-700" },
|
||||||
|
};
|
||||||
|
|
||||||
const getDiceBearUrl = (seed: string) =>
|
const getDiceBearUrl = (seed: string) =>
|
||||||
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`t-${seed.slice(0, 6)}`)}`;
|
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`t-${seed.slice(0, 6)}`)}`;
|
||||||
|
|
||||||
|
|
@ -49,6 +58,15 @@ function toPublicCourse(b: UserBooking): PublicCourse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Standort-Kontaktdaten für den Detail-Dialog (Email + Ort). */
|
||||||
|
function toBusinessInfo(b: UserBooking): BusinessInfo | undefined {
|
||||||
|
if (!b.businessAddress && !b.businessEmail) return undefined;
|
||||||
|
return {
|
||||||
|
address: b.businessAddress ?? "",
|
||||||
|
email: b.businessEmail ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface UserBookingCardProps {
|
interface UserBookingCardProps {
|
||||||
booking: UserBooking;
|
booking: UserBooking;
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +89,9 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const canCancel = !booking.isPast && !cancelled && booking.paymentStatus === "paid";
|
const canCancel = !booking.isPast && !cancelled && booking.paymentStatus === "paid";
|
||||||
|
const level = LEVEL_LABELS[booking.level] ?? LEVEL_LABELS.any;
|
||||||
|
const safeCurrent = Math.max(0, booking.currentParticipants);
|
||||||
|
const safeMax = Math.max(0, booking.maxParticipants);
|
||||||
|
|
||||||
const handleConfirmCancel = () =>
|
const handleConfirmCancel = () =>
|
||||||
new Promise<{ success: true } | { success: false; error: string }>((resolve) => {
|
new Promise<{ success: true } | { success: false; error: string }>((resolve) => {
|
||||||
|
|
@ -83,6 +104,27 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl shadow-sm hover:shadow-md hover:bg-white/85 transition-all flex flex-col gap-0 overflow-hidden">
|
<div className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl shadow-sm hover:shadow-md hover:bg-white/85 transition-all flex flex-col gap-0 overflow-hidden">
|
||||||
|
{/* Kursbild */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
className="relative w-full h-32 shrink-0 bg-gray-100 cursor-pointer select-none"
|
||||||
|
aria-label={`Kurs ${booking.courseName} öffnen`}
|
||||||
|
>
|
||||||
|
{booking.coverImage ? (
|
||||||
|
<Image
|
||||||
|
src={booking.coverImage}
|
||||||
|
alt={booking.courseName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-linear-to-br from-gray-200 to-gray-300 flex items-center justify-center">
|
||||||
|
<span className="text-xs text-gray-400">Kein Bild</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clickable card area */}
|
{/* Clickable card area */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -149,6 +191,19 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Schwierigkeitsgrad + Teilnehmerzahl */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${level.bg} ${level.text}`}
|
||||||
|
>
|
||||||
|
{level.label}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-500 shrink-0">
|
||||||
|
<Users size={13} className="shrink-0" />
|
||||||
|
{safeCurrent}/{safeMax}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
|
|
@ -158,32 +213,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Stornierung Button – nur für zukünftige, nicht stornierte Buchungen */}
|
|
||||||
{canCancel && (
|
|
||||||
<div className="px-4 pb-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setConfirmOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={14} className="mr-1.5 shrink-0" />
|
|
||||||
Stornieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BookingDialog
|
<BookingDialog
|
||||||
course={toPublicCourse(booking)}
|
course={toPublicCourse(booking)}
|
||||||
|
business={toBusinessInfo(booking)}
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
paymentStatus={effectiveStatus}
|
paymentStatus={effectiveStatus}
|
||||||
accentColor={booking.businessBranding.accentColor}
|
accentColor={booking.businessBranding.accentColor}
|
||||||
|
canCancel={canCancel}
|
||||||
|
onRequestCancel={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CancelBookingConfirmDialog
|
<CancelBookingConfirmDialog
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Plus,
|
||||||
Info,
|
Info,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
|
@ -38,6 +39,7 @@ import {
|
||||||
import { Progress } from "@/src/components/ui/progress";
|
import { Progress } from "@/src/components/ui/progress";
|
||||||
import { downsizeImage } from "@/src/lib/image/downsize";
|
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||||
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
||||||
|
import { useWeekdayTimings, type WeekdaySlot } from "../../hooks/useWeekdayTimings";
|
||||||
import {
|
import {
|
||||||
generateOccurrences,
|
generateOccurrences,
|
||||||
TooManyInstancesError,
|
TooManyInstancesError,
|
||||||
|
|
@ -48,8 +50,6 @@ import {
|
||||||
type StandingOrderScope,
|
type StandingOrderScope,
|
||||||
} from "../components/StandingOrderScopeDialog";
|
} from "../components/StandingOrderScopeDialog";
|
||||||
|
|
||||||
type WeekdayTiming = { timeFrom: string; timeTo: string };
|
|
||||||
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
{ value: "any", label: "Jedes Niveau" },
|
{ value: "any", label: "Jedes Niveau" },
|
||||||
{ value: "beginner", label: "Anfänger" },
|
{ value: "beginner", label: "Anfänger" },
|
||||||
|
|
@ -134,15 +134,16 @@ const CourseCreateView = ({
|
||||||
const [selectedFrequency, setSelectedFrequency] = useState(
|
const [selectedFrequency, setSelectedFrequency] = useState(
|
||||||
isEdit ? initialData!.frequency || "" : ""
|
isEdit ? initialData!.frequency || "" : ""
|
||||||
);
|
);
|
||||||
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(
|
const weekdays = useWeekdayTimings({
|
||||||
isEdit ? initialData!.weekdays || [] : []
|
initialWeekdays: isEdit ? initialData!.weekdays || [] : [],
|
||||||
);
|
initialTimings:
|
||||||
const [weekdayTimings, setWeekdayTimings] = useState<Record<string, WeekdayTiming>>(() => {
|
isEdit && initialData!.weekdayTimings
|
||||||
if (isEdit && initialData!.weekdayTimings) {
|
? (initialData!.weekdayTimings as Record<string, WeekdaySlot | WeekdaySlot[]>)
|
||||||
return initialData!.weekdayTimings as Record<string, WeekdayTiming>;
|
: undefined,
|
||||||
}
|
// Neu ausgewählte Tage mit der allgemeinen Kurs-Uhrzeit oben vorbefüllen.
|
||||||
return {};
|
defaultSlot: { timeFrom, timeTo },
|
||||||
});
|
});
|
||||||
|
const { selectedWeekdays, weekdayTimings } = weekdays;
|
||||||
const [endDate, setEndDate] = useState(() => {
|
const [endDate, setEndDate] = useState(() => {
|
||||||
if (isEdit && initialData!.endDate) {
|
if (isEdit && initialData!.endDate) {
|
||||||
const d = new Date(initialData!.endDate);
|
const d = new Date(initialData!.endDate);
|
||||||
|
|
@ -310,13 +311,13 @@ const CourseCreateView = ({
|
||||||
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
|
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
|
||||||
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
|
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
|
||||||
}
|
}
|
||||||
if (selectedFrequency === "custom" && selectedWeekdays.length > 0) {
|
if (
|
||||||
const hasInvalidTiming = selectedWeekdays.some((day) => {
|
selectedFrequency === "custom" &&
|
||||||
const t = weekdayTimings[day];
|
selectedWeekdays.length > 0 &&
|
||||||
return !t?.timeFrom || !t?.timeTo || t.timeFrom >= t.timeTo;
|
weekdays.hasInvalidTiming
|
||||||
});
|
) {
|
||||||
if (hasInvalidTiming)
|
newErrors.weekdayTimings =
|
||||||
newErrors.weekdayTimings = "Bitte gültige Uhrzeiten für alle Wochentage eingeben";
|
"Bitte gültige Uhrzeiten für alle Zeitfenster eingeben (Ende nach Beginn)";
|
||||||
}
|
}
|
||||||
if (!newErrors.endDate && selectedFrequency && courseDate && endDate) {
|
if (!newErrors.endDate && selectedFrequency && courseDate && endDate) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -365,7 +366,7 @@ const CourseCreateView = ({
|
||||||
isStandingOrder,
|
isStandingOrder,
|
||||||
frequency: selectedFrequency || undefined,
|
frequency: selectedFrequency || undefined,
|
||||||
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
|
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
|
||||||
weekdayTimings: Object.keys(weekdayTimings).length > 0 ? weekdayTimings : undefined,
|
weekdayTimings: weekdays.cleanedTimings,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
businessId: selectedBusinessId || undefined,
|
businessId: selectedBusinessId || undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -599,9 +600,8 @@ const CourseCreateView = ({
|
||||||
setIsStandingOrder(next);
|
setIsStandingOrder(next);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setSelectedFrequency("");
|
setSelectedFrequency("");
|
||||||
setSelectedWeekdays([]);
|
|
||||||
setWeekdayTimings({});
|
|
||||||
setEndDate("");
|
setEndDate("");
|
||||||
|
weekdays.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
|
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
|
||||||
|
|
@ -638,8 +638,7 @@ const CourseCreateView = ({
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
setSelectedFrequency(value);
|
setSelectedFrequency(value);
|
||||||
if (value !== "custom") {
|
if (value !== "custom") {
|
||||||
setSelectedWeekdays([]);
|
weekdays.reset();
|
||||||
setWeekdayTimings({});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
options={FREQUENCIES}
|
options={FREQUENCIES}
|
||||||
|
|
@ -663,16 +662,7 @@ const CourseCreateView = ({
|
||||||
<MultiSelectDropdown
|
<MultiSelectDropdown
|
||||||
label="An welchen Tagen soll der Kurs stattfinden?"
|
label="An welchen Tagen soll der Kurs stattfinden?"
|
||||||
selected={selectedWeekdays}
|
selected={selectedWeekdays}
|
||||||
onSelect={(days) => {
|
onSelect={weekdays.syncWeekdays}
|
||||||
setSelectedWeekdays(days);
|
|
||||||
setWeekdayTimings((prev) => {
|
|
||||||
const next: Record<string, WeekdayTiming> = {};
|
|
||||||
days.forEach((d) => {
|
|
||||||
next[d] = prev[d] ?? { timeFrom: "", timeTo: "" };
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
options={WEEKDAYS}
|
options={WEEKDAYS}
|
||||||
error={errors.weekdays}
|
error={errors.weekdays}
|
||||||
/>
|
/>
|
||||||
|
|
@ -682,35 +672,59 @@ const CourseCreateView = ({
|
||||||
<p className="text-sm font-medium text-gray-700">Uhrzeiten pro Tag</p>
|
<p className="text-sm font-medium text-gray-700">Uhrzeiten pro Tag</p>
|
||||||
{selectedWeekdays.map((day) => {
|
{selectedWeekdays.map((day) => {
|
||||||
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
|
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
|
||||||
|
const slots: WeekdaySlot[] = weekdayTimings[day]?.length
|
||||||
|
? weekdayTimings[day]
|
||||||
|
: [{ timeFrom: "", timeTo: "" }];
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={day} className="flex gap-3">
|
||||||
key={day}
|
<span className="w-24 text-sm text-gray-600 shrink-0 pt-2.5">
|
||||||
className="grid grid-cols-[7rem_1fr_auto_1fr] items-center gap-3"
|
{dayLabel}
|
||||||
>
|
</span>
|
||||||
<span className="text-sm text-gray-600">{dayLabel}</span>
|
<div className="flex flex-col gap-2">
|
||||||
<InputComponent
|
{slots.map((slot, index) => (
|
||||||
type="time"
|
<div key={index} className="flex items-center gap-3">
|
||||||
id={`weekday-time-from-${day}`}
|
<InputComponent
|
||||||
value={weekdayTimings[day]?.timeFrom ?? ""}
|
type="time"
|
||||||
onChange={(e) =>
|
id={`weekday-time-from-${day}-${index}`}
|
||||||
setWeekdayTimings((prev) => ({
|
value={slot.timeFrom}
|
||||||
...prev,
|
onChange={(e) =>
|
||||||
[day]: { ...prev[day], timeFrom: e.target.value },
|
weekdays.updateSlot(day, index, {
|
||||||
}))
|
timeFrom: e.target.value,
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
<span className="text-sm text-gray-400">–</span>
|
/>
|
||||||
<InputComponent
|
<span className="text-sm text-gray-400">–</span>
|
||||||
type="time"
|
<InputComponent
|
||||||
id={`weekday-time-to-${day}`}
|
type="time"
|
||||||
value={weekdayTimings[day]?.timeTo ?? ""}
|
id={`weekday-time-to-${day}-${index}`}
|
||||||
onChange={(e) =>
|
value={slot.timeTo}
|
||||||
setWeekdayTimings((prev) => ({
|
onChange={(e) =>
|
||||||
...prev,
|
weekdays.updateSlot(day, index, {
|
||||||
[day]: { ...prev[day], timeTo: e.target.value },
|
timeTo: e.target.value,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{slots.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => weekdays.removeSlot(day, index)}
|
||||||
|
aria-label="Zeitfenster entfernen"
|
||||||
|
className="text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => weekdays.addSlot(day)}
|
||||||
|
className="inline-flex w-fit items-center gap-1 text-sm text-blue hover:underline"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Weiteres Zeitfenster
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
export type FrequencyType = "daily" | "weekly" | "biweekly" | "monthly" | "custom";
|
export type FrequencyType = "daily" | "weekly" | "biweekly" | "monthly" | "custom";
|
||||||
|
|
||||||
export type WeekdayTiming = { timeFrom: string; timeTo: string };
|
export type WeekdaySlot = { timeFrom: string; timeTo: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uhrzeiten pro Wochentag. Jeder Tag kann mehrere Zeitfenster haben
|
||||||
|
* (z.B. Montag 10:00–11:00 UND 18:00–19:00).
|
||||||
|
*/
|
||||||
|
export type WeekdayTimings = Record<string, WeekdaySlot[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Altes Format, das noch in der DB liegen kann: genau ein Zeitfenster pro Tag.
|
||||||
|
* Wird von {@link normalizeWeekdayTimings} in das neue Array-Format überführt.
|
||||||
|
*/
|
||||||
|
export type LegacyWeekdayTiming = { timeFrom: string; timeTo: string };
|
||||||
|
|
||||||
export type CourseOccurrence = { date: Date; timeFrom: string; timeTo: string };
|
export type CourseOccurrence = { date: Date; timeFrom: string; timeTo: string };
|
||||||
|
|
||||||
|
|
@ -32,6 +44,27 @@ const WEEKDAY_MAP: Record<string, number> = {
|
||||||
const toWeekdayNumber = (weekday: string): number | undefined =>
|
const toWeekdayNumber = (weekday: string): number | undefined =>
|
||||||
WEEKDAY_MAP[weekday.trim().toLowerCase()];
|
WEEKDAY_MAP[weekday.trim().toLowerCase()];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt beliebige gespeicherte weekdayTimings in das Array-Format.
|
||||||
|
* Akzeptiert das neue Format (`{ monday: [{...}, {...}] }`) und das alte
|
||||||
|
* Einzelobjekt-Format (`{ monday: { timeFrom, timeTo } }`).
|
||||||
|
*/
|
||||||
|
export function normalizeWeekdayTimings(
|
||||||
|
raw: Record<string, WeekdaySlot[] | LegacyWeekdayTiming | null | undefined> | null | undefined
|
||||||
|
): WeekdayTimings {
|
||||||
|
if (!raw) return {};
|
||||||
|
const result: WeekdayTimings = {};
|
||||||
|
for (const [day, value] of Object.entries(raw)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const slots = value.filter((s): s is WeekdaySlot => !!s && typeof s === "object");
|
||||||
|
if (slots.length > 0) result[day] = slots;
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
result[day] = [{ timeFrom: value.timeFrom ?? "", timeTo: value.timeTo ?? "" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const MAX_INSTANCES = 365;
|
export const MAX_INSTANCES = 365;
|
||||||
|
|
||||||
export class TooManyInstancesError extends Error {
|
export class TooManyInstancesError extends Error {
|
||||||
|
|
@ -50,7 +83,7 @@ export function generateOccurrences(params: {
|
||||||
defaultTimeFrom: string;
|
defaultTimeFrom: string;
|
||||||
defaultTimeTo: string;
|
defaultTimeTo: string;
|
||||||
weekdays?: string[];
|
weekdays?: string[];
|
||||||
weekdayTimings?: Record<string, WeekdayTiming>;
|
weekdayTimings?: Record<string, WeekdaySlot[] | LegacyWeekdayTiming | null | undefined>;
|
||||||
}): CourseOccurrence[] {
|
}): CourseOccurrence[] {
|
||||||
const {
|
const {
|
||||||
startDate,
|
startDate,
|
||||||
|
|
@ -102,10 +135,11 @@ export function generateOccurrences(params: {
|
||||||
const targetDays = new Set(
|
const targetDays = new Set(
|
||||||
weekdays.map(toWeekdayNumber).filter((d): d is number => d !== undefined)
|
weekdays.map(toWeekdayNumber).filter((d): d is number => d !== undefined)
|
||||||
);
|
);
|
||||||
const timingsByDay = Object.entries(weekdayTimings).reduce<Record<number, WeekdayTiming>>(
|
const normalized = normalizeWeekdayTimings(weekdayTimings);
|
||||||
(acc, [weekday, timing]) => {
|
const slotsByDay = Object.entries(normalized).reduce<Record<number, WeekdaySlot[]>>(
|
||||||
|
(acc, [weekday, slots]) => {
|
||||||
const day = toWeekdayNumber(weekday);
|
const day = toWeekdayNumber(weekday);
|
||||||
if (day !== undefined) acc[day] = timing;
|
if (day !== undefined) acc[day] = slots;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
|
|
@ -113,8 +147,14 @@ export function generateOccurrences(params: {
|
||||||
const cursor = new Date(startDate);
|
const cursor = new Date(startDate);
|
||||||
while (cursor <= endDate) {
|
while (cursor <= endDate) {
|
||||||
if (targetDays.has(cursor.getDay())) {
|
if (targetDays.has(cursor.getDay())) {
|
||||||
const timing = timingsByDay[cursor.getDay()];
|
const slots = slotsByDay[cursor.getDay()];
|
||||||
addOccurrence(cursor, timing?.timeFrom ?? defaultTimeFrom, timing?.timeTo ?? defaultTimeTo);
|
if (slots && slots.length > 0) {
|
||||||
|
for (const slot of slots) {
|
||||||
|
addOccurrence(cursor, slot.timeFrom || defaultTimeFrom, slot.timeTo || defaultTimeTo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addOccurrence(cursor, defaultTimeFrom, defaultTimeTo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ const DashboardSidebar = ({ userData, displayName }: DashboardSidebarProps) => {
|
||||||
return (
|
return (
|
||||||
<Sidebar className="border-r-0 bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 shadow-[1px_0_0_0_oklch(0.85_0_0/30%)]">
|
<Sidebar className="border-r-0 bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 shadow-[1px_0_0_0_oklch(0.85_0_0/30%)]">
|
||||||
<SidebarHeader className="text-sidebar-accent-foreground">
|
<SidebarHeader className="text-sidebar-accent-foreground">
|
||||||
<Link href="/" className="flex justify-center items-center gap-2 px-2 pt-2">
|
<Link href="/" className="flex items-center gap-2 px-2 pt-2">
|
||||||
<Image src={Logo} height={45} alt="Kumasy Logo" />
|
<Image src={Logo} height={45} alt="Kumasy Logo" />
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import { Input } from "@/src/components/ui/input";
|
||||||
import { Progress } from "@/src/components/ui/progress";
|
import { Progress } from "@/src/components/ui/progress";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||||
import { downsizeImage } from "@/src/lib/image/downsize";
|
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||||
|
import { squareLogo } from "@/src/lib/image/square-logo";
|
||||||
import { getContrastTextColor } from "../../lib/contrast";
|
import { getContrastTextColor } from "../../lib/contrast";
|
||||||
import type { BusinessBranding } from "../../schemas/business-branding-schema";
|
import type { BusinessBranding } from "../../schemas/business-branding-schema";
|
||||||
import ColorPickerField from "./ColorPickerField";
|
import ColorPickerField from "./ColorPickerField";
|
||||||
import LogoCropDialog from "./LogoCropDialog";
|
|
||||||
import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview";
|
import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview";
|
||||||
import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview";
|
import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview";
|
||||||
import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview";
|
import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview";
|
||||||
|
|
@ -37,7 +37,6 @@ const BusinessBrandingEditor = ({
|
||||||
const [accentColor, setAccentColor] = useState(initialBranding.accentColor);
|
const [accentColor, setAccentColor] = useState(initialBranding.accentColor);
|
||||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||||
const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
|
const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
|
||||||
const [pendingCropFile, setPendingCropFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const accentTextColor = getContrastTextColor(accentColor);
|
const accentTextColor = getContrastTextColor(accentColor);
|
||||||
|
|
||||||
|
|
@ -45,7 +44,10 @@ const BusinessBrandingEditor = ({
|
||||||
setIsUploadingLogo(true);
|
setIsUploadingLogo(true);
|
||||||
setUploadPhase("optimizing");
|
setUploadPhase("optimizing");
|
||||||
try {
|
try {
|
||||||
const resizedFile = await downsizeImage(file);
|
// Automatisch quadratisch einpassen (contain), dann komprimieren –
|
||||||
|
// kein manuelles Zoom/Pan mehr.
|
||||||
|
const squared = await squareLogo(file);
|
||||||
|
const resizedFile = await downsizeImage(squared);
|
||||||
setUploadPhase("uploading");
|
setUploadPhase("uploading");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", resizedFile);
|
formData.append("file", resizedFile);
|
||||||
|
|
@ -68,12 +70,7 @@ const BusinessBrandingEditor = ({
|
||||||
// gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht).
|
// gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht).
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setPendingCropFile(file);
|
void uploadLogo(file);
|
||||||
};
|
|
||||||
|
|
||||||
const handleCropConfirm = (croppedFile: File) => {
|
|
||||||
setPendingCropFile(null);
|
|
||||||
uploadLogo(croppedFile);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -160,7 +157,9 @@ const BusinessBrandingEditor = ({
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Max. 5 MB, danach zuschneidbar.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Max. 5 MB. Wird automatisch quadratisch eingepasst.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Farben */}
|
{/* Farben */}
|
||||||
|
|
@ -230,12 +229,6 @@ const BusinessBrandingEditor = ({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LogoCropDialog
|
|
||||||
file={pendingCropFile}
|
|
||||||
onCancel={() => setPendingCropFile(null)}
|
|
||||||
onConfirm={handleCropConfirm}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
"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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Größe, in der das Bild bei gegebenem Zoom gezeichnet wird (füllt CANVAS_SIZE mindestens aus). */
|
|
||||||
function getScaledSize(image: HTMLImageElement, zoom: number): { width: number; height: number } {
|
|
||||||
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
|
|
||||||
const scale = baseScale * zoom;
|
|
||||||
return { width: image.width * scale, height: image.height * scale };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begrenzt den Offset auf den Bereich, in dem das skalierte Bild den
|
|
||||||
* quadratischen Canvas noch vollständig ausfüllt - ohne diese Klemmung könnte
|
|
||||||
* ein Drag oder ein Zoom-Wechsel das Bild aus dem sichtbaren Bereich schieben
|
|
||||||
* (grauer Freiraum in der Vorschau, transparente Fläche statt Logo im Export).
|
|
||||||
*/
|
|
||||||
function clampOffset(
|
|
||||||
offset: { x: number; y: number },
|
|
||||||
drawWidth: number,
|
|
||||||
drawHeight: number
|
|
||||||
): { x: number; y: number } {
|
|
||||||
const maxOffsetX = Math.max(0, (drawWidth - CANVAS_SIZE) / 2);
|
|
||||||
const maxOffsetY = Math.max(0, (drawHeight - CANVAS_SIZE) / 2);
|
|
||||||
return {
|
|
||||||
x: Math.min(maxOffsetX, Math.max(-maxOffsetX, offset.x)),
|
|
||||||
y: Math.min(maxOffsetY, Math.max(-maxOffsetY, offset.y)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 { width: drawWidth, height: drawHeight } = getScaledSize(image, currentZoom);
|
|
||||||
const clampedOffset = clampOffset(currentOffset, drawWidth, drawHeight);
|
|
||||||
const x = (CANVAS_SIZE - drawWidth) / 2 + clampedOffset.x;
|
|
||||||
const y = (CANVAS_SIZE - drawHeight) / 2 + clampedOffset.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[]) => {
|
|
||||||
const image = imageRef.current;
|
|
||||||
if (!image) return;
|
|
||||||
const { width, height } = getScaledSize(image, value);
|
|
||||||
const clamped = clampOffset(offset, width, height);
|
|
||||||
setZoom(value);
|
|
||||||
setOffset(clamped);
|
|
||||||
draw(value, clamped);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 image = imageRef.current;
|
|
||||||
if (!image) return;
|
|
||||||
const dx = e.clientX - dragState.current.startX;
|
|
||||||
const dy = e.clientY - dragState.current.startY;
|
|
||||||
const raw = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy };
|
|
||||||
const { width, height } = getScaledSize(image, zoom);
|
|
||||||
const next = clampOffset(raw, width, height);
|
|
||||||
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 { width: drawWidth, height: drawHeight } = getScaledSize(image, zoom);
|
|
||||||
const clampedOffset = clampOffset(offset, drawWidth, drawHeight);
|
|
||||||
const x = (OUTPUT_SIZE - drawWidth * outputScale) / 2 + clampedOffset.x * outputScale;
|
|
||||||
const y = (OUTPUT_SIZE - drawHeight * outputScale) / 2 + clampedOffset.y * outputScale;
|
|
||||||
|
|
||||||
ctx.drawImage(image, x, y, drawWidth * outputScale, drawHeight * outputScale);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
@ -29,7 +29,7 @@ const settingsMenuTitles: SettingsMenuItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
name: "Business Einsetllungen",
|
name: "Business Einstellungen",
|
||||||
href: "/settings/business",
|
href: "/settings/business",
|
||||||
description: "Verwalte deine Business Daten",
|
description: "Verwalte deine Business Daten",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ export type ZahlungRow = {
|
||||||
courseName: string;
|
courseName: string;
|
||||||
businessId: string;
|
businessId: string;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
|
/**
|
||||||
|
* Betrag, den das Business tatsächlich behält, in Cents. Bei
|
||||||
|
* `partially_refunded` ist das der Nettobetrag (gezahlt − erstattet),
|
||||||
|
* sonst der voll gezahlte Betrag. Die Rechnung selbst bleibt davon
|
||||||
|
* unberührt (siehe invoice-actions.ts).
|
||||||
|
*/
|
||||||
amountPaid: number | null;
|
amountPaid: number | null;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
invoiceNumber: string | null;
|
invoiceNumber: string | null;
|
||||||
|
|
@ -68,6 +74,7 @@ export async function getZahlungen(
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
paymentStatus: true,
|
paymentStatus: true,
|
||||||
amountPaid: true,
|
amountPaid: true,
|
||||||
|
refundedAmount: true,
|
||||||
invoiceNumber: true,
|
invoiceNumber: true,
|
||||||
course: {
|
course: {
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -89,6 +96,14 @@ export async function getZahlungen(
|
||||||
b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–";
|
b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–";
|
||||||
const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–";
|
const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–";
|
||||||
|
|
||||||
|
// Bei Teilerstattung zeigt die Übersicht den Betrag, den das Business
|
||||||
|
// behält (netto). Voll erstattete / stornierte Zeilen behalten ihren
|
||||||
|
// Rohbetrag – dort trägt das Status-Badge die Bedeutung.
|
||||||
|
const netAmount =
|
||||||
|
b.paymentStatus === "partially_refunded" && b.amountPaid !== null
|
||||||
|
? b.amountPaid - (b.refundedAmount ?? 0)
|
||||||
|
: b.amountPaid;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: b.id,
|
id: b.id,
|
||||||
createdAt: b.createdAt.toISOString(),
|
createdAt: b.createdAt.toISOString(),
|
||||||
|
|
@ -98,7 +113,7 @@ export async function getZahlungen(
|
||||||
courseName: b.course.name,
|
courseName: b.course.name,
|
||||||
businessId: b.course.business?.id ?? "",
|
businessId: b.course.business?.id ?? "",
|
||||||
businessName: b.course.business?.name ?? "–",
|
businessName: b.course.business?.name ?? "–",
|
||||||
amountPaid: b.amountPaid,
|
amountPaid: netAmount,
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
invoiceNumber: b.invoiceNumber,
|
invoiceNumber: b.invoiceNumber,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
BIN
src/public/logo-gelb.png
Normal file
BIN
src/public/logo-gelb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
129
tests/api/stripe/checkout-course.test.ts
Normal file
129
tests/api/stripe/checkout-course.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { POST } from "@/src/app/api/stripe/checkout/course/route";
|
||||||
|
|
||||||
|
vi.mock("@/src/lib/auth/getUser", () => ({
|
||||||
|
getUserData: vi.fn(),
|
||||||
|
isUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
|
||||||
|
isManagerOrEmployeeEmail: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/src/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
course: { findUnique: vi.fn() },
|
||||||
|
courseBooking: { findUnique: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/src/lib/stripe", () => ({
|
||||||
|
stripe: { checkout: { sessions: { create: vi.fn() } } },
|
||||||
|
calculatePlatformFeeCents: vi.fn().mockReturnValue(100),
|
||||||
|
eurosToCents: vi.fn().mockReturnValue(2900),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||||
|
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
|
import { prisma } from "@/src/lib/prisma";
|
||||||
|
import { stripe } from "@/src/lib/stripe";
|
||||||
|
|
||||||
|
let ipCounter = 0;
|
||||||
|
|
||||||
|
function makeRequest(body: Record<string, unknown>) {
|
||||||
|
// Jede Anfrage aus einer eigenen IP, damit der In-Memory-Rate-Limiter
|
||||||
|
// (10/Minute/IP) die Tests nicht gegenseitig beeinflusst.
|
||||||
|
ipCounter += 1;
|
||||||
|
return new Request("http://localhost/api/stripe/checkout/course", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-forwarded-for": `10.0.0.${ipCounter}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}) as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedCourse = {
|
||||||
|
id: "course-1",
|
||||||
|
name: "Yoga",
|
||||||
|
status: "published",
|
||||||
|
price: 29,
|
||||||
|
date: new Date("2099-01-01"),
|
||||||
|
timeFrom: "10:00",
|
||||||
|
maxParticipants: 20,
|
||||||
|
_count: { bookings: 0 },
|
||||||
|
business: {
|
||||||
|
id: "biz-1",
|
||||||
|
name: "Studio",
|
||||||
|
stripeAccountId: "acct_123",
|
||||||
|
stripeAccountStatus: "active",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getUserData).mockReset();
|
||||||
|
vi.mocked(isUser).mockReset();
|
||||||
|
vi.mocked(isManagerOrEmployeeEmail).mockReset();
|
||||||
|
vi.mocked(prisma.course.findUnique).mockReset();
|
||||||
|
vi.mocked(prisma.courseBooking.findUnique).mockReset();
|
||||||
|
vi.mocked(stripe.checkout.sessions.create).mockReset();
|
||||||
|
|
||||||
|
vi.mocked(stripe.checkout.sessions.create).mockResolvedValue({
|
||||||
|
url: "https://checkout.stripe.com/session",
|
||||||
|
} as never);
|
||||||
|
vi.mocked(prisma.course.findUnique).mockResolvedValue(publishedCourse as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Ticket 6)", () => {
|
||||||
|
it("lehnt eine Gast-Buchung mit Manager/Employee-E-Mail mit 403 ab, ohne Stripe-Session", async () => {
|
||||||
|
vi.mocked(getUserData).mockResolvedValue(null as never);
|
||||||
|
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "Manager@Studio.de" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.error).toMatch(/Team-Account/i);
|
||||||
|
expect(isManagerOrEmployeeEmail).toHaveBeenCalledWith("manager@studio.de");
|
||||||
|
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt eine normale Gast-Buchung durch, wenn die E-Mail zu keinem Team-Account gehört", async () => {
|
||||||
|
vi.mocked(getUserData).mockResolvedValue(null as never);
|
||||||
|
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
|
||||||
|
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "gast@example.com" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.url).toBe("https://checkout.stripe.com/session");
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prüft die Team-Account-E-Mail nicht für eingeloggte Kunden-Accounts", async () => {
|
||||||
|
vi.mocked(getUserData).mockResolvedValue({
|
||||||
|
id: "user-1",
|
||||||
|
email: "kunde@example.com",
|
||||||
|
} as never);
|
||||||
|
vi.mocked(isUser).mockReturnValue(true);
|
||||||
|
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine ungültige Gast-E-Mail weiterhin vor der Team-Account-Prüfung mit 400 ab", async () => {
|
||||||
|
vi.mocked(getUserData).mockResolvedValue(null as never);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "keine-email" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.isGuestRequired).toBe(true);
|
||||||
|
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue