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:
1 parent
3cba3e2341
commit
7203cd1e6e
37 files changed
+962
-517
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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(" ");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user