feat: Onepage nach Filmcamp-Gliederung umgebaut

- Figtree als einzige Schrift, Logo mittig in fester Kopfzeile
- Headerbild ueber volle Breite und Hoehe, ohne Preis, mit Terminleiste
- "Was dich erwartet" stellt die Villa mit animierter Bilderfolge vor,
  darunter Tagesablauf als Karussell (Programm folgt als PDF)
- Preis: regulaerer Preis durchgestrichen, kein Verkaufsstartdatum,
  Ueberschrift "Jetzt Platz sichern mit Early Bird"
- Team als "Mit dabei", danach drei Reels im Hochformat
- Logo, Headerbild, Portraets und Reels ueber feste Dateinamen im
  Medienverzeichnis (docs/MEDIEN.md), ohne Neustart austauschbar
- Widerrufsbelehrung im Admin pflegbar, Seite /widerruf, Live-Verkauf
  ohne Freigabe gesperrt
- Anreise: Abholung am Bahnhof Firenze Santa Maria Novella
- Booking-Link entfernt, Gedankenstriche aus Kundentexten entfernt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 23:53:18 +02:00
1 parent 3cba3e2341
commit 7203cd1e6e
37 files changed
+962 -517

No files matched your search

+2
View File
@@ -220,6 +220,8 @@ export const campInputSchema = z.object({
termsVersion: text(60),
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
termsApproved: z.boolean(),
withdrawalText: text(10000),
withdrawalApproved: z.boolean(),
travelLawChecked: z.boolean(),
installmentsApproved: z.boolean(),
installmentRuleApproved: z.boolean(),
+4
View File
@@ -37,6 +37,8 @@ export function toGateFields(camp: Camp): CampGateFields {
taxConfirmed: camp.taxConfirmed,
termsVersion: camp.termsVersion,
termsApproved: camp.termsApproved,
withdrawalText: camp.withdrawalText,
withdrawalApproved: camp.withdrawalApproved,
travelLawChecked: camp.travelLawChecked,
installmentsApproved: camp.installmentsApproved,
installmentRuleApproved: camp.installmentRuleApproved,
@@ -63,6 +65,7 @@ export type PublicCampFacts = {
organizer: { name: string; address: string; email: string; phone: string | null } | null;
termsUrl: string | null;
termsVersion: string | null;
withdrawalText: string | null;
};
export function publicCampFacts(camp: Camp): PublicCampFacts {
@@ -84,5 +87,6 @@ export function publicCampFacts(camp: Camp): PublicCampFacts {
: null,
termsUrl: camp.termsApproved ? camp.termsUrl : null,
termsVersion: camp.termsApproved ? camp.termsVersion : null,
withdrawalText: camp.withdrawalApproved ? camp.withdrawalText : null,
};
}
+2 -2
View File
@@ -20,7 +20,7 @@ import type { PaymentGateway } from "./payment-gateway";
import { statusTokenFor } from "./tokens";
import { formatDate, formatDateTime } from "./time";
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027 – Teilnahme";
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027, Teilnahme";
export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung";
/** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */
const MIN_STRIPE_SESSION_MS = 30 * 60_000;
@@ -43,7 +43,7 @@ export function planSummary(installments: Pick<Installment, "seq" | "amountCents
return [
`Gesamtpreis ${formatEuro(totalCents)}.`,
today,
later ? `Weitere Raten: ${later} – automatisch über die hier gespeicherte Zahlungsmethode.` : "",
later ? `Weitere Raten: ${later}, automatisch über die hier gespeicherte Zahlungsmethode.` : "",
]
.filter(Boolean)
.join(" ");
+6 -6
View File
@@ -26,7 +26,7 @@ function planLines(booking: BookingWithInstallments): string[] {
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
const state =
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
return `Rate ${i.seq}: ${formatEuro(i.amountCents)} – ${due} – ${state}`;
return `Rate ${i.seq}: ${formatEuro(i.amountCents)}, ${due}, ${state}`;
});
}
@@ -43,12 +43,12 @@ ${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;back
}
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
return { dedupeKey: key, to: booking.email, subject: `${subject} – ${BRAND}`, bookingId: booking.id, ...body };
return { dedupeKey: key, to: booking.email, subject: `${subject} | ${BRAND}`, bookingId: booking.id, ...body };
}
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt – erste Rate erhalten";
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt, erste Rate erhalten";
const lines = [
`Hallo ${booking.firstName},`,
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
@@ -96,7 +96,7 @@ export function paymentFailedEmail(booking: Booking): EmailDraft {
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Vollständig bezahlt – danke!" : `Rate ${installment.seq} erhalten`;
const subject = fullyPaid ? "Vollständig bezahlt. Danke!" : `Rate ${installment.seq} erhalten`;
return draft(
`installment-paid:${installment.id}`,
booking,
@@ -129,7 +129,7 @@ export function installmentActionEmail(booking: Booking, installment: Installmen
requiresAction
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
"Du kannst die Rate über den folgenden Link selbst begleichen – auch mit einer anderen Zahlungsmethode.",
"Du kannst die Rate über den folgenden Link selbst begleichen, auch mit einer anderen Zahlungsmethode.",
],
{ href: statusLink(token), label: "Rate jetzt bezahlen" },
),
@@ -147,7 +147,7 @@ export function adminReviewEmail(to: string, booking: Booking, reason: string):
}
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung ist eingegangen – wir melden uns";
const subject = "Deine Zahlung ist eingegangen, wir melden uns";
return draft(
`review-customer:${booking.id}`,
booking,
+5
View File
@@ -34,6 +34,8 @@ export type CampGateFields = {
taxConfirmed: boolean;
termsVersion: string | null;
termsApproved: boolean;
withdrawalText: string | null;
withdrawalApproved: boolean;
travelLawChecked: boolean;
installmentsApproved: boolean;
installmentRuleApproved: boolean;
@@ -49,6 +51,7 @@ export type ReleaseBlocker =
| "ORGANIZER"
| "TAX"
| "TERMS"
| "WITHDRAWAL"
| "TRAVEL_LAW"
| "CAPACITY"
| "STAMMMODEL_QUOTA";
@@ -62,6 +65,7 @@ export const BLOCKER_TEXT: Record<ReleaseBlocker, string> = {
ORGANIZER: "Veranstalter- und Kontaktdaten fehlen oder sind nicht bestätigt.",
TAX: "Steuerlich korrekte Preisangabe ist nicht konfiguriert.",
TERMS: "Buchungs-, Storno- und Zahlungsbedingungen sind nicht freigegeben.",
WITHDRAWAL: "Widerrufsbelehrung ist nicht eingetragen oder nicht freigegeben.",
TRAVEL_LAW: "Reiserechtliche Einordnung (inkl. ggf. Absicherung) ist nicht geprüft.",
CAPACITY: "Verkaufbare Platzzahl ist nicht bestätigt.",
STAMMMODEL_QUOTA: "Kontingent für Stammmodel-Codes ist nicht bestätigt.",
@@ -93,6 +97,7 @@ export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
}
if (!camp.taxNote || !camp.taxConfirmed) blockers.push("TAX");
if (!camp.termsVersion || !camp.termsApproved) blockers.push("TERMS");
if (!camp.withdrawalText || !camp.withdrawalApproved) blockers.push("WITHDRAWAL");
if (!camp.travelLawChecked) blockers.push("TRAVEL_LAW");
if (!camp.capacityConfirmed) blockers.push("CAPACITY");
if (!camp.stammmodelQuotaConfirmed) blockers.push("STAMMMODEL_QUOTA");
+84
View File
@@ -0,0 +1,84 @@
/**
* Austauschbare Medien ueber feste Dateinamen im Medienverzeichnis
* (MEDIA_DIR, im Cluster ein PersistentVolume, ausgeliefert unter /media/…).
*
* Liegt eine Datei unter dem erwarteten Namen dort, erscheint sie ohne
* Neustart und ohne Codeaenderung auf der Seite. Fehlt sie, bleibt der
* Platzhalter bzw. das Standardbild. Die Liste aller Namen steht in
* docs/MEDIEN.md.
*
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
*/
import { stat } from "node:fs/promises";
import path from "node:path";
import { site, type Media } from "@/content/site";
import { env } from "./env";
const IMAGE_EXTS = [".jpg", ".jpeg", ".webp", ".png", ".avif"];
const LOGO_EXTS = [".svg", ".png", ".webp"];
const VIDEO_EXTS = [".mp4", ".webm"];
type Found = { src: string; ext: string };
async function find(base: string, exts: readonly string[]): Promise<Found | null> {
const dir = path.resolve(env().MEDIA_DIR);
for (const ext of exts) {
try {
const info = await stat(path.join(dir, `${base}${ext}`));
if (info.isFile()) return { src: `/media/${base}${ext}?v=${Math.floor(info.mtimeMs)}`, ext };
} catch {
// Datei fehlt: naechste Endung probieren.
}
}
return null;
}
async function image(base: string, alt: string): Promise<Media | null> {
const found = await find(base, IMAGE_EXTS);
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
}
export type Reel = { src: string | null; poster: Media | null };
export type SiteMedia = {
/** Logo fuer helle Flaechen (dunkles Logo). */
logoDark: string | null;
/** Logo auf Foto oder dunklem Grund (helles Logo). */
logoLight: string | null;
header: Media;
headerVideo: string | null;
portraits: Record<string, Media>;
reels: Reel[];
};
export async function loadSiteMedia(): Promise<SiteMedia> {
const people = [site.team.head, ...site.team.people];
const [logoDark, logoLight, header, headerVideo, portraitList, reels] = await Promise.all([
find("logo", LOGO_EXTS),
find("logo-hell", LOGO_EXTS),
image("header", "ARTISTA Modelcamp in der Toskana"),
find("header", VIDEO_EXTS),
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
Promise.all(
[1, 2, 3].map(async (n): Promise<Reel> => {
const [video, poster] = await Promise.all([find(`reels/reel-${n}`, VIDEO_EXTS), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
return { src: video?.src ?? null, poster };
}),
),
]);
const portraits: Record<string, Media> = {};
people.forEach((p, index) => {
portraits[p.slug] = portraitList[index] ?? p.portrait;
});
return {
logoDark: logoDark?.src ?? logoLight?.src ?? null,
logoLight: logoLight?.src ?? logoDark?.src ?? null,
header: header ?? site.hero.media,
headerVideo: headerVideo?.src ?? null,
portraits,
reels,
};
}
+12
View File
@@ -130,3 +130,15 @@ export function formatIsoDateLong(isoDate: string): string {
function pad(n: number): string {
return String(n).padStart(2, "0");
}
const weekdayDateFormatter = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
weekday: "long",
day: "numeric",
month: "long",
});
/** "Samstag, 8. Mai" fuer reine Datumswerte (YYYY-MM-DD). */
export function formatIsoWeekdayDate(isoDate: string): string {
return weekdayDateFormatter.format(isoToDbDate(isoDate));
}