feat: Team, Villa-Bilder und bestaetigte Camp-Eckdaten
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m49s
- Team-Abschnitt mit neun Personen, Eric Heitz als Head of Modelcamp, Portraets im Rundbogen mit Initialen-Platzhaltern bis Fotos vorliegen - Unterkunftsbilder der Villa La Chiesina lokal in public/images/unterkunft (vom Auftraggeber freigegeben), Hero-Leitbild, Ausstattung, Booking-Link - Migration: Termin 08.05.-15.05.2027, Ort Arcidosso (Toskana), eigenes Bett im geteilten Zimmer, 13 Plaetze; Admin-Werte bleiben erhalten - Reveal: kein Hydration-Fehler mehr bei reduzierter Bewegung - Doku zu offenen Punkten aktualisiert Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
No files matched your search
@@ -52,8 +52,9 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
|
|||||||
|
|
||||||
## Wo was gepflegt wird
|
## Wo was gepflegt wird
|
||||||
|
|
||||||
- **Texte, Bilder, Dozentinnen, Videos, FAQ-Grundlagen:** [src/content/site.ts](src/content/site.ts). Einträge mit `confirmed: false` gelten als Entwurf.
|
- **Texte, Bilder, Team, Videos, FAQ-Grundlagen:** [src/content/site.ts](src/content/site.ts). Einträge mit `confirmed: false` gelten als Entwurf.
|
||||||
- **Termin, Ort, Zimmerbeschreibung, Veranstalter, Steuerhinweis, Bedingungen, Kapazität, Freigaben:** Adminbereich → „Einstellungen und Freigaben“.
|
- **Termin, Ort, Zimmerbeschreibung, Veranstalter, Steuerhinweis, Bedingungen, Kapazität, Freigaben:** Adminbereich → „Einstellungen und Freigaben“.
|
||||||
- **Preise und Preisphasen:** fest in [src/lib/pricing.ts](src/lib/pricing.ts), weil sie verbindlich vorgegeben sind.
|
- **Preise und Preisphasen:** fest in [src/lib/pricing.ts](src/lib/pricing.ts), weil sie verbindlich vorgegeben sind.
|
||||||
- **Gutscheine (Stammmodel-Codes):** Adminbereich → „Gutscheine“.
|
- **Gutscheine (Stammmodel-Codes):** Adminbereich → „Gutscheine“.
|
||||||
|
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben) und `public/images/team/` (Porträts, folgen).
|
||||||
- **Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
- **Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
||||||
@@ -106,4 +106,4 @@ Die Software trifft keine Storno-Entscheidung, sie setzt sie nur nachvollziehbar
|
|||||||
|
|
||||||
Dateien (jpg, png, webp, avif, mp4, webm) direkt in den SMB-Ordner `modelcamp-media` legen, z. B. über den Windows-Explorer (`\\nas…\artista\modelcamp-media`). Die App sieht sie sofort unter `/media/<dateiname>`. Im Pod ist das Volume schreibgeschützt eingehängt.
|
Dateien (jpg, png, webp, avif, mp4, webm) direkt in den SMB-Ordner `modelcamp-media` legen, z. B. über den Windows-Explorer (`\\nas…\artista\modelcamp-media`). Die App sieht sie sofort unter `/media/<dateiname>`. Im Pod ist das Volume schreibgeschützt eingehängt.
|
||||||
|
|
||||||
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder, etwa von Booking.com, ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen.
|
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen. Die Unterkunftsbilder liegen als lokale Kopie in `public/images/unterkunft/` und sind Teil des Container-Images.
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
# Noch benötigte Inhalte und Freigaben
|
# Noch benötigte Inhalte und Freigaben
|
||||||
|
|
||||||
|
Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` eingetragen (im Admin änderbar):
|
||||||
|
|
||||||
|
- Termin 08.05.–15.05.2027, Villa La Chiesina, Arcidosso (Toskana)
|
||||||
|
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
|
||||||
|
- 13 verkaufbare Plätze
|
||||||
|
- Team (Namen und Rollen) sowie Booking-Link und Unterkunftsbilder
|
||||||
|
|
||||||
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“.
|
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“.
|
||||||
|
|
||||||
## Inhalte
|
## Inhalte
|
||||||
|
|
||||||
| Punkt | Wo eintragen |
|
| Punkt | Wo eintragen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Camp-Termin (Anreise/Abreise) **(Freigabe)** | Admin → Einstellungen |
|
| Porträts und freigegebene Kurzbeschreibungen der neun Teammitglieder | `public/images/team/` + `site.ts` → `team` |
|
||||||
| Genauer Ort und Region **(Freigabe)** | Admin → Einstellungen |
|
| Schriftliche Bestätigung des Vermieters, dass die Booking-Bilder genutzt werden dürfen (Urheber ist der Vermieter bzw. Fotograf) | Ablage beim Veranstalter |
|
||||||
| Verbindliche Zimmer- und Bettbeschreibung, insbesondere ob ein französisches Doppelbett geteilt wird **(Freigabe)** | Admin → Einstellungen |
|
|
||||||
| Booking-Link der Unterkunft | `src/content/site.ts` → `accommodation.bookingUrl` |
|
|
||||||
| Unterkunftsbilder mit geklärter Nutzungsberechtigung | Medienvolume + `site.ts` |
|
|
||||||
| Namen, Rollen, Kurzbeschreibungen und Porträts der beiden Dozentinnen | `site.ts` → `teachers` |
|
|
||||||
| Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` |
|
| Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` |
|
||||||
| Drei echte, freigegebene Feedback-Videos mit Namen und Begleittext | Medienvolume + `site.ts` → `videos` |
|
| Drei echte, freigegebene Feedback-Videos mit Namen und Begleittext | Medienvolume + `site.ts` → `videos` |
|
||||||
| Hero-Leitbild oder -Video | `site.ts` → `hero.media` |
|
| Hero-Leitbild oder -Video | `site.ts` → `hero.media` |
|
||||||
@@ -28,7 +31,6 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
|
|||||||
| Buchungs-, Storno- und Zahlungsbedingungen **(Freigabe)** | Als Dokument oder Seite hinterlegen (Link und Version im Admin). Es wurden keine AGB formuliert |
|
| Buchungs-, Storno- und Zahlungsbedingungen **(Freigabe)** | Als Dokument oder Seite hinterlegen (Link und Version im Admin). Es wurden keine AGB formuliert |
|
||||||
| Reiserechtliche Einordnung (Pauschalreise?) samt gegebenenfalls nötiger Insolvenzabsicherung und Formblätter **(Freigabe)** | Rechtliche Aussagen aus der Kalkulation wurden nicht übernommen |
|
| Reiserechtliche Einordnung (Pauschalreise?) samt gegebenenfalls nötiger Insolvenzabsicherung und Formblätter **(Freigabe)** | Rechtliche Aussagen aus der Kalkulation wurden nicht übernommen |
|
||||||
| Vollständiges Impressum und Datenschutzerklärung | Technische Grundlage siehe `/datenschutz` (nur in Entwicklung sichtbar) |
|
| Vollständiges Impressum und Datenschutzerklärung | Technische Grundlage siehe `/datenschutz` (nur in Entwicklung sichtbar) |
|
||||||
| Verkaufbare Platzzahl (vorläufig 10) **(Freigabe)** | Admin → Einstellungen |
|
|
||||||
| Kontingent Stammmodel-Buchungen (vorläufig 4) **(Freigabe)** | Admin → Einstellungen |
|
| Kontingent Stammmodel-Buchungen (vorläufig 4) **(Freigabe)** | Admin → Einstellungen |
|
||||||
| Ratenzahlung: rechtliche Prüfung, Terminregel, Freigabe für den Livemodus | Admin → Einstellungen (zwei Häkchen) |
|
| Ratenzahlung: rechtliche Prüfung, Terminregel, Freigabe für den Livemodus | Admin → Einstellungen (zwei Häkchen) |
|
||||||
| Teilnahme Minderjähriger | Die Buchung verlangt derzeit die Erklärung „Ich bin volljährig“. Bitte bestätigen oder die Regel vorgeben |
|
| Teilnahme Minderjähriger | Die Buchung verlangt derzeit die Erklärung „Ich bin volljährig“. Bitte bestätigen oder die Regel vorgeben |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ page }, testInfo) => {
|
test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ page }, testInfo) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { level: 1 })).toContainText("ARTISTA");
|
await expect(page.getByRole("heading", { level: 1 })).toContainText("ARTISTA");
|
||||||
for (const id of ["camp", "leistungen", "unterkunft", "dozentinnen", "artista", "preis", "fragen"]) {
|
for (const id of ["camp", "leistungen", "unterkunft", "team", "artista", "preis", "fragen"]) {
|
||||||
await expect(page.locator(`#${id}`)).toBeAttached();
|
await expect(page.locator(`#${id}`)).toBeAttached();
|
||||||
}
|
}
|
||||||
// Keine oeffentliche Platzzahl und kein Restplatz-Zaehler.
|
// Keine oeffentliche Platzzahl und kein Restplatz-Zaehler.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
qualities: [75, 85],
|
qualities: [75, 85],
|
||||||
// Freigegebene Bilder liegen im persistenten Medienverzeichnis und
|
// Feste Bilder (Unterkunft, Team) liegen in public/images/, austauschbare
|
||||||
// werden unter /media/… ausgeliefert (src/app/media/[...path]/route.ts).
|
// im persistenten Medienverzeichnis unter /media/… (src/app/media/[...path]/route.ts).
|
||||||
localPatterns: [{ pathname: "/media/**" }],
|
localPatterns: [{ pathname: "/media/**" }, { pathname: "/images/**" }],
|
||||||
dangerouslyAllowSVG: false,
|
dangerouslyAllowSVG: false,
|
||||||
},
|
},
|
||||||
// Turbopack-Dateicaches kollidieren im OneDrive-synchronisierten
|
// Turbopack-Dateicaches kollidieren im OneDrive-synchronisierten
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Vom Auftraggeber am 01.10.2026 bestaetigte Eckdaten:
|
||||||
|
-- Termin 08.05.–15.05.2027, Villa La Chiesina in Arcidosso (Toskana),
|
||||||
|
-- eigenes Bett im geteilten Zimmer, 13 verkaufbare Plaetze.
|
||||||
|
-- Bereits im Adminbereich gepflegte Werte werden nicht ueberschrieben
|
||||||
|
-- (nur leere bzw. unbestaetigte Felder werden gesetzt).
|
||||||
|
|
||||||
|
ALTER TABLE "Camp" ALTER COLUMN "capacity" SET DEFAULT 13;
|
||||||
|
|
||||||
|
INSERT INTO "Camp" ("id", "updatedAt") VALUES ('modelcamp-2027', CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"campStartDate" = DATE '2027-05-08',
|
||||||
|
"campEndDate" = DATE '2027-05-15',
|
||||||
|
"dateConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "dateConfirmed" = false;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"locationName" = 'Villa La Chiesina, Arcidosso',
|
||||||
|
"locationRegion" = 'Toskana, Italien',
|
||||||
|
"locationConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "locationConfirmed" = false;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"accommodationText" = 'Du hast dein eigenes Bett und teilst dir das Zimmer mit anderen Teilnehmerinnen. Einzelzimmer und Zimmer-Upgrades sind nicht buchbar.',
|
||||||
|
"accommodationConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "accommodationConfirmed" = false;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"capacity" = 13,
|
||||||
|
"capacityConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "capacityConfirmed" = false;
|
||||||
@@ -38,7 +38,7 @@ model Camp {
|
|||||||
/// vollstaendigen Freigabe-Checkliste (src/lib/sales-gate.ts).
|
/// vollstaendigen Freigabe-Checkliste (src/lib/sales-gate.ts).
|
||||||
salesEnabled Boolean @default(false)
|
salesEnabled Boolean @default(false)
|
||||||
|
|
||||||
capacity Int @default(10)
|
capacity Int @default(13)
|
||||||
capacityConfirmed Boolean @default(false)
|
capacityConfirmed Boolean @default(false)
|
||||||
stammmodelQuota Int @default(4)
|
stammmodelQuota Int @default(4)
|
||||||
stammmodelQuotaConfirmed Boolean @default(false)
|
stammmodelQuotaConfirmed Boolean @default(false)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -2,14 +2,11 @@
|
|||||||
* Testdaten NUR fuer Entwicklung und Staging, damit eine Stripe-Testbuchung
|
* Testdaten NUR fuer Entwicklung und Staging, damit eine Stripe-Testbuchung
|
||||||
* sofort moeglich ist:
|
* sofort moeglich ist:
|
||||||
* - Preisphase "Early Bird" simuliert (wirkt nur mit Stripe-Testschluessel)
|
* - Preisphase "Early Bird" simuliert (wirkt nur mit Stripe-Testschluessel)
|
||||||
* - ein TEST-Anreisetag, ausdruecklich NICHT bestaetigt (erscheint deshalb
|
|
||||||
* nicht oeffentlich, erlaubt aber Ratenplaene im Testmodus)
|
|
||||||
* - ein Test-Gutschein TEST-STAMMMODEL (nicht der Beispielcode aus dem Briefing)
|
* - ein Test-Gutschein TEST-STAMMMODEL (nicht der Beispielcode aus dem Briefing)
|
||||||
*
|
*
|
||||||
* Verweigert den Lauf mit APP_ENV=production.
|
* Verweigert den Lauf mit APP_ENV=production.
|
||||||
*/
|
*/
|
||||||
import { getDb, CAMP_ID } from "@/lib/db";
|
import { getDb, CAMP_ID } from "@/lib/db";
|
||||||
import { isoToDbDate } from "@/lib/time";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
process.loadEnvFile();
|
process.loadEnvFile();
|
||||||
@@ -26,15 +23,16 @@ const db = getDb();
|
|||||||
try {
|
try {
|
||||||
await db.camp.upsert({
|
await db.camp.upsert({
|
||||||
where: { id: CAMP_ID },
|
where: { id: CAMP_ID },
|
||||||
create: { id: CAMP_ID, testPhaseOverride: "EARLY_BIRD", campStartDate: isoToDbDate("2027-06-05"), dateConfirmed: false },
|
// Termin und Ort setzt die Migration 20261001120000_camp_2027_bestaetigt.
|
||||||
update: { testPhaseOverride: "EARLY_BIRD", campStartDate: isoToDbDate("2027-06-05"), dateConfirmed: false },
|
create: { id: CAMP_ID, testPhaseOverride: "EARLY_BIRD" },
|
||||||
|
update: { testPhaseOverride: "EARLY_BIRD" },
|
||||||
});
|
});
|
||||||
await db.voucher.upsert({
|
await db.voucher.upsert({
|
||||||
where: { code: "TEST-STAMMMODEL" },
|
where: { code: "TEST-STAMMMODEL" },
|
||||||
create: { code: "TEST-STAMMMODEL", maxRedemptions: 3, internalNote: "Entwicklung: Testgutschein" },
|
create: { code: "TEST-STAMMMODEL", maxRedemptions: 3, internalNote: "Entwicklung: Testgutschein" },
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
console.log("Entwicklungsdaten gesetzt: Early Bird simuliert, Test-Anreisetag 05.06.2027 (unbestätigt), Gutschein TEST-STAMMMODEL.");
|
console.log("Entwicklungsdaten gesetzt: Early Bird simuliert, Gutschein TEST-STAMMMODEL.");
|
||||||
} finally {
|
} finally {
|
||||||
await db.$disconnect();
|
await db.$disconnect();
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ const hanken = Hanken_Grotesk({
|
|||||||
|
|
||||||
const TITLE = "ARTISTA MODELCAMP 2027";
|
const TITLE = "ARTISTA MODELCAMP 2027";
|
||||||
const DESCRIPTION =
|
const DESCRIPTION =
|
||||||
"Das ARTISTA Modelcamp 2027 in Italien: lernen, arbeiten und wohnen unter einem Dach. Alle Infos zu Unterkunft, Dozentinnen, Preis und Buchung.";
|
"Das ARTISTA Modelcamp 2027 in Italien: lernen, arbeiten und wohnen unter einem Dach. Alle Infos zu Unterkunft, Team, Preis und Buchung.";
|
||||||
|
|
||||||
export function generateMetadata(): Metadata {
|
export function generateMetadata(): Metadata {
|
||||||
const { APP_URL, APP_ENV } = env();
|
const { APP_URL, APP_ENV } = env();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MobileBookingBar } from "@/components/booking/MobileBookingBar";
|
|||||||
import { Hero, priceLine } from "@/components/Hero";
|
import { Hero, priceLine } from "@/components/Hero";
|
||||||
import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections";
|
import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections";
|
||||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||||
import { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
import { AboutSection, TeamSection, VoicesSection } from "@/components/sections/PeopleSections";
|
||||||
import { PricingSection } from "@/components/sections/PricingSection";
|
import { PricingSection } from "@/components/sections/PricingSection";
|
||||||
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
@@ -31,7 +31,7 @@ export default async function HomePage() {
|
|||||||
<CampSection showDrafts={showDrafts} />
|
<CampSection showDrafts={showDrafts} />
|
||||||
<ServicesSection showDrafts={showDrafts} />
|
<ServicesSection showDrafts={showDrafts} />
|
||||||
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
||||||
<TeachersSection showDrafts={showDrafts} />
|
<TeamSection showDrafts={showDrafts} />
|
||||||
<AboutSection showDrafts={showDrafts} />
|
<AboutSection showDrafts={showDrafts} />
|
||||||
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
||||||
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
||||||
|
|||||||
@@ -31,17 +31,19 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
|
|||||||
};
|
};
|
||||||
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
|
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
|
||||||
|
|
||||||
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein Panorama.
|
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein
|
||||||
const layout = ["md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "md:col-span-12"];
|
// Panorama, danach Dreierreihen. Auf dem Smartphone zwei Spalten, damit
|
||||||
|
// die Galerie nicht endlos lang wird; Bild 1 und das Panorama volle Breite.
|
||||||
|
const layout = ["col-span-2 md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "col-span-2 md:col-span-12"];
|
||||||
const aspect = (index: number) =>
|
const aspect = (index: number) =>
|
||||||
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[3/2] md:aspect-[3/1]" : "aspect-[3/2]";
|
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[3/2] md:aspect-[3/1]" : "aspect-square md:aspect-[3/2]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ul className="mt-12 grid gap-3 md:grid-cols-12 md:gap-4">
|
<ul className="mt-12 grid grid-cols-2 gap-3 md:grid-cols-12 md:gap-4">
|
||||||
{images.map((media, index) => (
|
{images.map((media, index) => (
|
||||||
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
||||||
<Reveal delay={index * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
<Reveal delay={Math.min(index, 4) * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
||||||
<button
|
<button
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
triggers.current[index] = el;
|
triggers.current[index] = el;
|
||||||
@@ -54,7 +56,7 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
|
|||||||
<MediaFrame
|
<MediaFrame
|
||||||
media={media}
|
media={media}
|
||||||
aspect={aspect(index)}
|
aspect={aspect(index)}
|
||||||
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 100vw"}
|
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 50vw"}
|
||||||
showLabel={showLabels}
|
showLabel={showLabels}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ const TONES: Record<PlaceholderTone, Plate> = {
|
|||||||
studio: { background: "#d2c9bd", glyph: "A", italic: true, style: { fontSize: "96cqh", right: "-16cqw", bottom: "-16cqh" } },
|
studio: { background: "#d2c9bd", glyph: "A", italic: true, style: { fontSize: "96cqh", right: "-16cqw", bottom: "-16cqh" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialen auf Portraet-Platzhaltern: gross, angeschnitten am unteren Rand.
|
||||||
|
const INITIALS_STYLE: React.CSSProperties = { fontSize: "34cqh", left: "8cqw", bottom: "-4cqh", letterSpacing: "-0.03em" };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
media: Media;
|
media: Media;
|
||||||
/** Tailwind-Klasse fuer das Seitenverhaeltnis, z. B. "aspect-[4/5]". */
|
/** Tailwind-Klasse fuer das Seitenverhaeltnis, z. B. "aspect-[4/5]". */
|
||||||
@@ -29,17 +32,22 @@ type Props = {
|
|||||||
showLabel: boolean;
|
showLabel: boolean;
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** "arch": oben rund wie ein Rundbogenfenster (Team-Portraets). */
|
||||||
|
shape?: "rect" | "arch";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ARCH = "rounded-t-[50vw]";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
||||||
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
||||||
* uebersehen werden.
|
* uebersehen werden.
|
||||||
*/
|
*/
|
||||||
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "" }: Props) {
|
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "", shape = "rect" }: Props) {
|
||||||
|
const shapeClass = shape === "arch" ? ARCH : "";
|
||||||
if (media.kind === "image") {
|
if (media.kind === "image") {
|
||||||
return (
|
return (
|
||||||
<div className={`relative overflow-hidden bg-sand ${aspect} ${className}`}>
|
<div className={`relative overflow-hidden bg-sand ${aspect} ${shapeClass} ${className}`}>
|
||||||
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -49,17 +57,17 @@ export function MediaFrame({ media, aspect, sizes, showLabel, priority, classNam
|
|||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Platzhalter: ${media.label}`}
|
aria-label={`Platzhalter: ${media.label}`}
|
||||||
className={`placeholder-plate relative isolate overflow-hidden ${aspect} ${className}`}
|
className={`placeholder-plate relative isolate overflow-hidden ${aspect} ${shapeClass} ${className}`}
|
||||||
style={{ backgroundColor: tone.background, containerType: "size" }}
|
style={{ backgroundColor: tone.background, containerType: "size" }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={`display pointer-events-none absolute select-none leading-none text-ink/[0.07] ${tone.italic ? "italic" : ""}`}
|
className={`display pointer-events-none absolute select-none leading-none ${media.glyph ? "text-ink/[0.13]" : "text-ink/[0.07]"} ${tone.italic && !media.glyph ? "italic" : ""}`}
|
||||||
style={tone.style}
|
style={media.glyph ? INITIALS_STYLE : tone.style}
|
||||||
>
|
>
|
||||||
{tone.glyph}
|
{media.glyph ?? tone.glyph}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden className="pointer-events-none absolute inset-3 border border-ink/10 md:inset-4" />
|
{shape === "rect" && <span aria-hidden className="pointer-events-none absolute inset-3 border border-ink/10 md:inset-4" />}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="absolute bottom-6 left-6 bg-paper/90 px-2.5 py-1 text-xs font-semibold text-ink md:bottom-7 md:left-7">
|
<span className="absolute bottom-6 left-6 bg-paper/90 px-2.5 py-1 text-xs font-semibold text-ink md:bottom-7 md:left-7">
|
||||||
Platzhalter: {media.label}
|
Platzhalter: {media.label}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
|||||||
* Animation warten muss.
|
* Animation warten muss.
|
||||||
*/
|
*/
|
||||||
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
||||||
|
// Immer dasselbe Element rendern: der Server kennt die Bewegungs-
|
||||||
|
// einstellung nicht, ein anderer Elementbaum fuehrt zu Hydration-Fehlern.
|
||||||
|
// Bei reduzierter Bewegung wird ohne Dauer sofort aufgedeckt.
|
||||||
const reduce = useReducedMotion();
|
const reduce = useReducedMotion();
|
||||||
if (reduce) return <div className={className}>{children}</div>;
|
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<m.div
|
<m.div
|
||||||
@@ -17,7 +19,7 @@ export function Reveal({ children, className = "", delay = 0 }: { children: Reac
|
|||||||
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
||||||
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
||||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
transition={reduce ? { duration: 0 } : { duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</m.div>
|
</m.div>
|
||||||
|
|||||||
@@ -100,26 +100,31 @@ export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampF
|
|||||||
return (
|
return (
|
||||||
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-24 md:py-36">
|
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-24 md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<h2 id="unterkunft-title" className="section-title">{accommodation.title}</h2>
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{accommodation.name} · {accommodation.place}</p>
|
||||||
|
<h2 id="unterkunft-title" className="section-title mt-3">{accommodation.title}</h2>
|
||||||
<p className="display mt-6 max-w-2xl text-[1.5rem] leading-[1.25] text-ink/85 md:text-[1.875rem]">{accommodation.lead}</p>
|
<p className="display mt-6 max-w-2xl text-[1.5rem] leading-[1.25] text-ink/85 md:text-[1.875rem]">{accommodation.lead}</p>
|
||||||
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
||||||
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
||||||
<div className="md:col-span-7">
|
<div className="md:col-span-7">
|
||||||
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
|
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
|
||||||
<p className="mt-3 max-w-2xl">
|
|
||||||
Gebucht wird ein Platz pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.
|
|
||||||
</p>
|
|
||||||
{facts.accommodationText ? (
|
{facts.accommodationText ? (
|
||||||
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 max-w-2xl text-stone">
|
<p className="mt-3 max-w-2xl text-stone">
|
||||||
Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
|
Gebucht wird ein Platz pro Person mit geteilter Unterbringung; Einzelzimmer sind nicht buchbar. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-4 md:col-start-9">
|
<div className="md:col-span-4 md:col-start-9">
|
||||||
|
<h3 className="text-lg font-semibold">Ausstattung</h3>
|
||||||
|
<ul className="mt-3 divide-y divide-line border-y border-line">
|
||||||
|
{accommodation.features.map((feature) => (
|
||||||
|
<li key={feature} className="py-2.5">{feature}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-sm text-stone">Laut Beschreibung der Unterkunft.</p>
|
||||||
{accommodation.bookingUrl ? (
|
{accommodation.bookingUrl ? (
|
||||||
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet">
|
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet mt-6">
|
||||||
Unterkunft ansehen (externe Seite)
|
Unterkunft ansehen (externe Seite)
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,36 +1,69 @@
|
|||||||
import { site } from "@/content/site";
|
import { site, type Media } from "@/content/site";
|
||||||
import { MediaFrame } from "../MediaFrame";
|
import { MediaFrame } from "../MediaFrame";
|
||||||
import { Reveal } from "../Reveal";
|
import { Reveal } from "../Reveal";
|
||||||
import { VideoSlot } from "../VideoSlot";
|
import { VideoSlot } from "../VideoSlot";
|
||||||
|
|
||||||
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
type TeamMember = { name: string; role: string; bio: string | null; portrait: Media };
|
||||||
const { teachers } = site;
|
|
||||||
|
function Bio({ bio, showDrafts }: { bio: string | null; showDrafts: boolean }) {
|
||||||
|
if (bio) return <p className="mt-3 max-w-md">{bio}</p>;
|
||||||
|
return showDrafts ? <p className="mt-3"><span className="draft-flag">Kurzbeschreibung folgt</span></p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team: Eric Heitz als Head of Modelcamp gross vorangestellt, darunter alle
|
||||||
|
* weiteren gleichwertig im Raster. Portraets im Rundbogen – ein Motiv aus
|
||||||
|
* der Architektur der Villa. Die Spalten sind auf breiten Bildschirmen
|
||||||
|
* leicht versetzt, damit das Raster wie eine Magazinseite wirkt.
|
||||||
|
*/
|
||||||
|
export function TeamSection({ showDrafts }: { showDrafts: boolean }) {
|
||||||
|
const { team } = site;
|
||||||
|
const { head } = team;
|
||||||
return (
|
return (
|
||||||
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-24 md:py-36">
|
<section id="team" aria-labelledby="team-title" className="bg-paper py-24 md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
<div className="grid gap-6 md:grid-cols-12 md:gap-10">
|
||||||
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
<h2 id="team-title" className="section-title md:col-span-5">{team.title}</h2>
|
||||||
{teachers.people.map((person, index) => (
|
<p className="max-w-xl text-lg text-ink/85 md:col-span-6 md:col-start-7 md:pt-3">{team.lead}</p>
|
||||||
<article
|
|
||||||
key={index}
|
|
||||||
aria-label={person.name ?? `Dozentin ${index + 1}`}
|
|
||||||
className={index === 0 ? "md:col-span-5" : "md:col-span-5 md:col-start-8 md:mt-40"}
|
|
||||||
>
|
|
||||||
<Reveal>
|
|
||||||
<MediaFrame media={person.portrait} aspect="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
|
|
||||||
</Reveal>
|
|
||||||
<h3 className="display mt-6 text-3xl">
|
|
||||||
{person.name ?? (showDrafts ? `Dozentin ${index + 1}: Name folgt` : "Wird in Kürze vorgestellt")}
|
|
||||||
</h3>
|
|
||||||
{person.role && <p className="mt-1 text-stone">{person.role}</p>}
|
|
||||||
{person.bio ? (
|
|
||||||
<p className="mt-4 max-w-md">{person.bio}</p>
|
|
||||||
) : (
|
|
||||||
showDrafts && <p className="mt-4"><span className="draft-flag">Kurzbeschreibung folgt</span></p>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<article aria-label={head.name} className="mt-14 grid items-end gap-8 md:mt-20 md:grid-cols-12 md:gap-10">
|
||||||
|
<Reveal className="md:col-span-5">
|
||||||
|
<MediaFrame media={head.portrait} aspect="aspect-[4/5]" shape="arch" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
|
||||||
|
</Reveal>
|
||||||
|
<div className="md:col-span-6 md:col-start-7 md:pb-10">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{head.title}</p>
|
||||||
|
<h3 className="display mt-4 text-[3rem] leading-[0.95] md:text-[5.5rem]">{head.name}</h3>
|
||||||
|
<p className="mt-5 border-t border-ink pt-4 text-lg">{head.role}</p>
|
||||||
|
<Bio bio={head.bio} showDrafts={showDrafts} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16">
|
||||||
|
{team.people.map((person: TeamMember, index) => (
|
||||||
|
<li key={person.name} className={index % 2 === 1 ? "md:mt-16" : ""}>
|
||||||
|
<article aria-label={person.name}>
|
||||||
|
<Reveal delay={(index % 4) * 0.06}>
|
||||||
|
<MediaFrame
|
||||||
|
media={person.portrait}
|
||||||
|
aspect="aspect-[3/4]"
|
||||||
|
shape="arch"
|
||||||
|
sizes="(min-width: 768px) 22vw, 50vw"
|
||||||
|
showLabel={false}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
<h3 className="display mt-4 text-[1.375rem] leading-tight md:text-[1.75rem]">{person.name}</h3>
|
||||||
|
<p className="mt-1 text-sm text-stone md:text-[0.9375rem]">{person.role}</p>
|
||||||
|
<Bio bio={person.bio} showDrafts={showDrafts} />
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{showDrafts && (
|
||||||
|
<p className="mt-12">
|
||||||
|
<span className="draft-flag">Porträts folgen</span> Platzhalter mit Initialen, bis freigegebene Fotos vorliegen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis und
|
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis und
|
||||||
* Bedingungen pflegt der Adminbereich (Datenbank), weil sie den
|
* Bedingungen pflegt der Adminbereich (Datenbank), weil sie den
|
||||||
* Live-Verkauf freischalten. Hier steht alles Uebrige.
|
* Live-Verkauf freischalten. Hier steht alles Uebrige.
|
||||||
* - Bilder: nur mit bestaetigter Nutzungsberechtigung, als lokale Datei im
|
* - Bilder: nur mit bestaetigter Nutzungsberechtigung und immer als lokale
|
||||||
* Medienverzeichnis (MEDIA_DIR, ausgeliefert unter /media/…). Kein
|
* Datei, nie per Hotlinking. Feste Bilder liegen in public/images/,
|
||||||
* Hotlinking fremder Bilder (z. B. von Booking.com).
|
* austauschbare Medien im Medienverzeichnis (MEDIA_DIR, /media/…).
|
||||||
|
* Die Unterkunftsbilder stammen aus dem Booking.com-Eintrag der Villa
|
||||||
|
* und wurden vom Auftraggeber am 01.10.2026 zur Nutzung freigegeben.
|
||||||
*
|
*
|
||||||
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
||||||
*/
|
*/
|
||||||
@@ -21,12 +23,14 @@
|
|||||||
export type PlaceholderTone = "cover" | "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
export type PlaceholderTone = "cover" | "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
||||||
|
|
||||||
export type Media =
|
export type Media =
|
||||||
| { kind: "placeholder"; label: string; tone: PlaceholderTone }
|
| { kind: "placeholder"; label: string; tone: PlaceholderTone; glyph?: string }
|
||||||
| { kind: "image"; src: string; alt: string; width: number; height: number };
|
| { kind: "image"; src: string; alt: string; width: number; height: number };
|
||||||
|
|
||||||
export type Confirmable<T> = T & { confirmed: boolean };
|
export type Confirmable<T> = T & { confirmed: boolean };
|
||||||
|
|
||||||
const placeholder = (label: string, tone: PlaceholderTone): Media => ({ kind: "placeholder", label, tone });
|
const portrait = (name: string, initials: string): Media => ({ kind: "placeholder", label: `Porträt ${name}`, tone: "portrait", glyph: initials });
|
||||||
|
/** Lokales Bild aus public/images/unterkunft (1280 x 853 px). */
|
||||||
|
const villa = (file: string, alt: string): Media => ({ kind: "image", src: `/images/unterkunft/${file}.jpg`, alt, width: 1280, height: 853 });
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
hero: {
|
hero: {
|
||||||
@@ -35,7 +39,7 @@ export const site = {
|
|||||||
headline: "Gemeinsam wachsen. In Italien.",
|
headline: "Gemeinsam wachsen. In Italien.",
|
||||||
subline:
|
subline:
|
||||||
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
||||||
media: placeholder("Leitmotiv: Bild oder Video folgt", "cover"),
|
media: villa("villa-luftbild", "Luftaufnahme der Villa La Chiesina zwischen Zypressen und Wiesen in der Toskana"),
|
||||||
},
|
},
|
||||||
|
|
||||||
camp: {
|
camp: {
|
||||||
@@ -89,22 +93,57 @@ export const site = {
|
|||||||
|
|
||||||
accommodation: {
|
accommodation: {
|
||||||
title: "Die Unterkunft",
|
title: "Die Unterkunft",
|
||||||
lead: "Wohnen, arbeiten und abends zusammensitzen – alles an einem Ort.",
|
lead: "Eine Villa zwischen Zypressen, mit Blick über die Hügel am Monte Amiata. Wohnen, arbeiten und abends zusammensitzen – alles an einem Ort.",
|
||||||
// Link zur Unterkunft (z. B. Booking.com), nachzureichen.
|
name: "Villa La Chiesina",
|
||||||
bookingUrl: null as string | null,
|
place: "Arcidosso, Toskana",
|
||||||
|
// Ohne Tracking-Parameter; Termin und Personenzahl waehlt man dort selbst.
|
||||||
|
bookingUrl: "https://www.booking.com/hotel/it/villa-la-chiesina-arcidosso.de.html" as string | null,
|
||||||
|
// Ausstattung laut Unterkunftsbeschreibung (Booking.com, Stand 01.10.2026).
|
||||||
|
features: [
|
||||||
|
"Eigener Außenpool (saisonal)",
|
||||||
|
"Großer Garten mit Terrasse",
|
||||||
|
"Blick auf die Berge",
|
||||||
|
"Gemeinsames Wohnzimmer und gut ausgestattete Küche",
|
||||||
|
"Bettwäsche und Handtücher",
|
||||||
|
"WLAN",
|
||||||
|
],
|
||||||
images: [
|
images: [
|
||||||
placeholder("Unterkunft: Außenansicht", "villa"),
|
villa("villa-eingang", "Die Villa La Chiesina mit Steintreppe und Rasen im Frühling"),
|
||||||
placeholder("Unterkunft: Garten oder Terrasse", "terrace"),
|
villa("pool", "Pool mit Liegen im Garten der Villa"),
|
||||||
placeholder("Unterkunft: Zimmer", "room"),
|
villa("wohnzimmer-kamin", "Wohnzimmer mit Holzbalkendecke, Natursteinwand und Kamin"),
|
||||||
placeholder("Unterkunft: Gemeinschaftsbereich", "garden"),
|
villa("anwesen-luftbild", "Luftaufnahme des Anwesens mit Zypressenallee, Pool und Tennisplatz"),
|
||||||
|
villa("schlafzimmer-einzelbetten", "Schlafzimmer mit zwei Einzelbetten und Holzbalkendecke"),
|
||||||
|
villa("esszimmer", "Esszimmer mit großem Holztisch und Kamin"),
|
||||||
|
villa("terrasse", "Sitzplatz auf der Gartenterrasse"),
|
||||||
|
villa("pool-zypressen", "Pool zwischen hohen Zypressen, im Hintergrund die Hügel der Toskana"),
|
||||||
|
villa("steintreppe", "Alte Steintreppe im Garten unter Bäumen"),
|
||||||
|
villa("arcidosso", "Blick auf den mittelalterlichen Ort Arcidosso mit Burg"),
|
||||||
] as Media[],
|
] as Media[],
|
||||||
},
|
},
|
||||||
|
|
||||||
teachers: {
|
team: {
|
||||||
title: "Die Dozentinnen",
|
title: "Das Team",
|
||||||
|
lead: "Fotografie, Modelcoaching, Content und eine Küche, die alle zusammenbringt: Diese Menschen sind beim Modelcamp 2027 dabei.",
|
||||||
|
// Namen und Rollen vom Auftraggeber (01.10.2026). Porträts folgen:
|
||||||
|
// Datei in public/images/team/ legen und "portrait" ersetzen, z. B.
|
||||||
|
// { kind: "image", src: "/images/team/eric-heitz.jpg", alt: "Porträt Eric Heitz", width: 1200, height: 1500 }.
|
||||||
|
// Kurzbeschreibungen nur aus freigegebenen Angaben, keine erfundenen Referenzen.
|
||||||
|
head: {
|
||||||
|
name: "Eric Heitz",
|
||||||
|
title: "Head of Modelcamp",
|
||||||
|
role: "Fotograf · Content Creator · Modelcoach",
|
||||||
|
bio: null as string | null,
|
||||||
|
portrait: portrait("Eric Heitz", "EH"),
|
||||||
|
},
|
||||||
people: [
|
people: [
|
||||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "portrait") as Media },
|
{ name: "Michele Ietro", role: "Editorial- und Casual-Fotograf", bio: null as string | null, portrait: portrait("Michele Ietro", "MI") },
|
||||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "studio") as Media },
|
{ name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
|
||||||
|
{ name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
|
||||||
|
{ name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
|
||||||
|
{ name: "Stefan Höchtl", role: "Social Media Content Creator", bio: null as string | null, portrait: portrait("Stefan Höchtl", "SH") },
|
||||||
|
{ name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
|
||||||
|
{ name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
|
||||||
|
{ name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -135,7 +174,7 @@ export const NAV_ITEMS = [
|
|||||||
{ id: "camp", label: "Das Camp" },
|
{ id: "camp", label: "Das Camp" },
|
||||||
{ id: "leistungen", label: "Leistungen" },
|
{ id: "leistungen", label: "Leistungen" },
|
||||||
{ id: "unterkunft", label: "Unterkunft" },
|
{ id: "unterkunft", label: "Unterkunft" },
|
||||||
{ id: "dozentinnen", label: "Dozentinnen" },
|
{ id: "team", label: "Team" },
|
||||||
{ id: "artista", label: "Über ARTISTA" },
|
{ id: "artista", label: "Über ARTISTA" },
|
||||||
{ id: "preis", label: "Preis" },
|
{ id: "preis", label: "Preis" },
|
||||||
{ id: "fragen", label: "Fragen" },
|
{ id: "fragen", label: "Fragen" },
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { dbDateToIso } from "./time";
|
|||||||
/**
|
/**
|
||||||
* Laedt die Camp-Konfiguration. Fehlt die Zeile (frische Datenbank), wird
|
* Laedt die Camp-Konfiguration. Fehlt die Zeile (frische Datenbank), wird
|
||||||
* sie mit sicheren Standardwerten angelegt: Live-Verkauf aus, keine
|
* sie mit sicheren Standardwerten angelegt: Live-Verkauf aus, keine
|
||||||
* Freigaben, 10 Plaetze und 4 Stammmodel-Buchungen als vorlaeufige Werte.
|
* Freigaben, 13 Plaetze und 4 Stammmodel-Buchungen. Die Migration
|
||||||
|
* 20261001120000_camp_2027_bestaetigt legt Termin, Ort und Zimmer fest.
|
||||||
*/
|
*/
|
||||||
export async function getCamp(db: Db | Tx): Promise<Camp> {
|
export async function getCamp(db: Db | Tx): Promise<Camp> {
|
||||||
return db.camp.upsert({ where: { id: CAMP_ID }, create: { id: CAMP_ID }, update: {} });
|
return db.camp.upsert({ where: { id: CAMP_ID }, create: { id: CAMP_ID }, update: {} });
|
||||||
|
|||||||