diff --git a/app/api/templates/[id]/export/route.ts b/app/api/templates/[id]/export/route.ts new file mode 100644 index 0000000..e7bacac --- /dev/null +++ b/app/api/templates/[id]/export/route.ts @@ -0,0 +1,31 @@ +import type { NextRequest } from "next/server" +import { getProfile } from "@/lib/supabase/dal" +import { buildProject } from "@/modules/templates/export/project" +import { resolveContent } from "@/modules/templates/lib/content" +import { getTemplate } from "@/modules/templates/lib/registry" +import { getVariant } from "@/modules/templates/queries/variants" + +// Gespeicherter Stand einer Variante als eigenständiges Next.js-Projekt (ZIP) – nur für Admins +export async function GET(_request: NextRequest, ctx: RouteContext<"/api/templates/[id]/export">) { + const profile = await getProfile() + if (profile?.role !== "admin") return Response.json({ error: "Keine Berechtigung." }, { status: 403 }) + + const { id } = await ctx.params + const variant = await getVariant(id) + const def = variant ? getTemplate(variant.template_key) : undefined + if (!variant || !def) return Response.json({ error: "Variante nicht gefunden." }, { status: 404 }) + + try { + const { fileName, zip } = await buildProject(def, resolveContent(def, variant.content), variant.name) + return new Response(zip, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${fileName}"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Template export failed:", error) + return Response.json({ error: "Export fehlgeschlagen." }, { status: 500 }) + } +} diff --git a/modules/templates/ai/demo-info.ts b/modules/templates/ai/demo-info.ts index d2e3a0e..b4043be 100644 --- a/modules/templates/ai/demo-info.ts +++ b/modules/templates/ai/demo-info.ts @@ -1,4 +1,4 @@ -import { accentColors } from "../lib/content" +import { accentColors } from "../lib/theme" import type { TemplateContent, TemplateDefinition } from "../lib/types" import { AI_DEMO_SLUGS, AI_DEMOS, type AiDemoSlug } from "./demos" import { businessName, collectImages } from "./knowledge" diff --git a/modules/templates/catalog/agentur/definition.ts b/modules/templates/catalog/agentur/definition.ts new file mode 100644 index 0000000..2c42d67 --- /dev/null +++ b/modules/templates/catalog/agentur/definition.ts @@ -0,0 +1,90 @@ +import { defineTemplate, describeSections, unsplash } from "../../lib/content" + +const defaults = { + brand: { name: "Studio Brut", tagline: "Branding · Web · Kampagnen", email: "hello@studiobrut.de", city: "Köln" }, + hero: { + title: "Wir machen\nMarken *laut.*", + text: "Ein unabhängiges Studio für Branding, digitale Produkte und Kampagnen, die man nicht wegscrollt.", + cta: "Projekt starten", + image: unsplash("1541701494587-cb58502866ab", 1200), + }, + clients: { items: [{ name: "Kraftbau" }, { name: "Lumio" }, { name: "Velora" }, { name: "Okuma" }, { name: "Brightfield" }, { name: "Nordwerk" }] }, + work: { + title: "Ausgewählte *Arbeiten*", + items: [ + { title: "Velora", category: "Branding & Website", year: "2026", text: "Neue Marke für ein Fintech, das Geld endlich verständlich macht.", image: unsplash("1618005182384-a83a8bd57fbe", 1400), color: "#7b61ff" }, + { title: "Kraftbau", category: "Kampagne", year: "2025", text: "Recruiting-Kampagne mit 4.000 Bewerbungen in acht Wochen.", image: unsplash("1522202176988-66273c2fd55f", 1400), color: "#ff5c39" }, + { title: "Lumio", category: "Digitales Produkt", year: "2025", text: "App-Design und Designsystem für 300.000 Nutzer:innen.", image: unsplash("1558655146-d09347e92766", 1400), color: "#1f3cff" }, + { title: "Okuma Coffee", category: "Packaging", year: "2024", text: "Verpackungen, die im Regal schreien – und im Laden flüstern.", image: unsplash("1561070791-2526d30994b5", 1400), color: "#e6b400" }, + ], + }, + manifest: { + text: "Gute Gestaltung ist keine Deko. Sie entscheidet, ob jemand stehen bleibt oder weiterscrollt. Wir bauen Marken, die man fühlt – mutig, klar und mit einem Augenzwinkern.", + }, + services: { + title: "Was wir *können*", + items: [ + { title: "Branding", text: "Strategie, Naming, Logo und Designsysteme, die mitwachsen." }, + { title: "Websites", text: "Schnell, barrierearm und so gestaltet, dass man bleibt." }, + { title: "Kampagnen", text: "Ideen für Plakat, Social und alles dazwischen." }, + { title: "Content", text: "Foto, Video und Texte, die nach euch klingen." }, + { title: "Motion", text: "Animationen, die Marken lebendig machen." }, + ], + }, + numbers: { + items: [ + { value: "120", suffix: "+", label: "Projekte" }, + { value: "14", suffix: "", label: "Awards" }, + { value: "9", suffix: "", label: "Köpfe im Studio" }, + { value: "98", suffix: " %", label: "kommen wieder" }, + ], + }, + team: { + title: "Die *Köpfe*", + members: [ + { name: "Mira Kaya", role: "Creative Direction", image: unsplash("1544005313-94ddf0286df2", 700) }, + { name: "Jonas Wendt", role: "Strategie", image: unsplash("1552058544-f2b08422138a", 700) }, + { name: "Lea Brandt", role: "Design", image: unsplash("1517841905240-472988babdf9", 700) }, + { name: "Tim Hartung", role: "Development", image: unsplash("1500648767791-00dcc994a43e", 700) }, + ], + }, + contact: { + title: "Lass uns was\n*Großes* bauen.", + text: "Erzähl uns von deinem Projekt – wir melden uns innerhalb von 24 Stunden mit einer ersten Idee.", + }, +} + +export const agentur = defineTemplate({ + key: "agentur", + name: "Studio", + industry: "Agentur & Kreativstudio", + description: "Experimentell und selbstbewusst: eigener Cursor, Scramble-Effekt beim Hover, gestapelte Case-Karten, Manifest zum Mitlesen und Leistungen als Hover-Liste.", + cover: unsplash("1541701494587-cb58502866ab", 1200), + samples: { + inquiry: "Hi, wir sind ein Münchner Start-up für nachhaltige Sportbekleidung und brauchen ein komplettes Rebranding inklusive neuer Website bis zum Launch im März. Wie geht ihr vor, und mit welchem Budget sollten wir rechnen? Grüße, Paul", + topic: "Case Study: Wie wir Velora zur Marke gemacht haben", + }, + palettes: [ + { key: "nacht", label: "Nacht", dark: true, bg: "#0b0b0b", fg: "#f4f2ec", muted: "#8c8a84", surface: "#171717", accent: "#ff5c39" }, + { key: "papier", label: "Papier", bg: "#efece4", fg: "#141414", muted: "#67645d", surface: "#e2ded3", accent: "#1f3cff" }, + { key: "pop", label: "Pop", bg: "#f4f0ff", fg: "#170d2e", muted: "#665b80", surface: "#e6dcff", accent: "#ff3d6e" }, + ], + fonts: [ + { key: "brutal", label: "Epilogue" }, + { key: "schlank", label: "Darker Grotesque & Inter" }, + ], + sections: describeSections(defaults, { + brand: "Studio", + hero: "Startbereich", + clients: { label: "Kundenlogos", optional: true, items: { items: "Kunde" } }, + work: { label: "Arbeiten", items: { items: "Projekt" } }, + manifest: { label: "Manifest", optional: true }, + services: { label: "Leistungen", optional: true, items: { items: "Leistung" } }, + numbers: { label: "Zahlen", optional: true, items: { items: "Kennzahl" } }, + team: { label: "Team", optional: true, items: { members: "Person" } }, + contact: "Kontakt", + }), + defaults, +}) + +export type AgenturData = typeof agentur.defaults diff --git a/modules/templates/catalog/agentur/fonts.ts b/modules/templates/catalog/agentur/fonts.ts new file mode 100644 index 0000000..bb42e42 --- /dev/null +++ b/modules/templates/catalog/agentur/fonts.ts @@ -0,0 +1,11 @@ +import { Darker_Grotesque, Epilogue } from "next/font/google" +import type { FontPair } from "../../lib/types" + +// preload: false – siehe restaurant/fonts.ts +const epilogue = Epilogue({ subsets: ["latin"], variable: "--font-agentur-epilogue", preload: false }) +const darker = Darker_Grotesque({ subsets: ["latin"], variable: "--font-agentur-darker", preload: false }) + +export const fonts: Record = { + brutal: { className: epilogue.variable, display: "var(--font-agentur-epilogue)", body: "var(--font-agentur-epilogue)" }, + schlank: { className: darker.variable, display: "var(--font-agentur-darker)", body: "var(--font-inter)" }, +} diff --git a/modules/templates/catalog/agentur/template.tsx b/modules/templates/catalog/agentur/template.tsx new file mode 100644 index 0000000..101485c --- /dev/null +++ b/modules/templates/catalog/agentur/template.tsx @@ -0,0 +1,255 @@ +"use client" + +import { AnimatePresence, motion, useMotionValue, useSpring } from "motion/react" +import { useEffect, useRef, useState } from "react" +import { ArrowUpRightIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import type { TemplateProps } from "../../lib/types" +import { TImage } from "../shared/media" +import { Counter, Marquee, Reveal, RevealWords, ScrollWords, ease } from "../shared/motion" +import { TemplateShell } from "../shared/shell" +import { agentur, type AgenturData } from "./definition" +import { fonts } from "./fonts" + +const em = "italic text-t-accent" +const container = "mx-auto w-full max-w-[1500px] px-5 md:px-10" +const display = "font-t-display font-extrabold tracking-[-0.05em]" +const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ#%&*+" + +// Buchstaben würfeln kurz durcheinander und rasten dann ein +function Scramble({ text, className }: { text: string; className?: string }) { + const [output, setOutput] = useState(text) + const timer = useRef | null>(null) + + useEffect(() => () => { + if (timer.current) clearInterval(timer.current) + }, []) + + function run() { + if (timer.current) clearInterval(timer.current) + let frame = 0 + timer.current = setInterval(() => { + frame += 1 + setOutput( + text + .split("") + .map((char, i) => (char === " " || i < frame / 2 ? char : GLYPHS[(i * 7 + frame * 3) % GLYPHS.length])) + .join("") + ) + if (frame / 2 >= text.length && timer.current) clearInterval(timer.current) + }, 30) + } + + return ( + + {output} + + ) +} + +export function AgenturTemplate({ content }: TemplateProps) { + const { brand, hero, clients, work, manifest, services, numbers, team, contact } = content.data + const show = (key: string) => !content.hidden.includes(key) + const [cursor, setCursor] = useState(null) + const x = useMotionValue(-100) + const y = useMotionValue(-100) + const springX = useSpring(x, { stiffness: 500, damping: 40 }) + const springY = useSpring(y, { stiffness: 500, damping: 40 }) + + useEffect(() => { + const move = (event: PointerEvent) => { + x.set(event.clientX) + y.set(event.clientY) + } + window.addEventListener("pointermove", move) + return () => window.removeEventListener("pointermove", move) + }, [x, y]) + + return ( + + + + {cursor} + + + +
+ +
+ +
+ + + + + + + +

{hero.text}

+

{brand.tagline} · {brand.city}

+
+
+ + {show("clients") && ( +
+ + {clients.items.map((client, i) => ( + + {client.name} + + ))} + +
+ )} + +
+ +
+ {work.items.map((project, i) => ( +
setCursor("Ansehen")} + onMouseLeave={() => setCursor(null)} + className="sticky overflow-hidden rounded-[2rem] p-6 text-white md:p-10" + style={{ top: `${6 + i * 1.5}rem`, background: project.color || "var(--t-surface)" }} + > +
+
+

{project.category} · {project.year}

+

{project.title}

+

{project.text}

+ + Case ansehen + +
+
+ +
+
+
+ ))} +
+
+ + {show("manifest") && ( +
+ +
+ )} + + {show("services") && } + + {show("numbers") && ( +
+
+ {numbers.items.map((item, i) => ( +
+
+ + {item.suffix} +
+
{item.label}
+
+ ))} +
+
+ )} + + {show("team") && ( +
+ +
+ {team.members.map((member, i) => ( + +
setCursor("Hallo!")} onMouseLeave={() => setCursor(null)}> +
+ +
+

{member.name}

+

{member.role}

+
+
+ ))} +
+
+ )} + +
+ + +

{contact.text}

+ setCursor("Schreiben")} + onMouseLeave={() => setCursor(null)} + className={cn(display, "group relative mt-10 inline-block text-[clamp(2rem,6vw,5.5rem)] text-t-accent")} + > + {brand.email} + + +
+
+

{brand.name} · {brand.city}

+ +
+
+
+ ) +} + +function Services({ services }: { services: AgenturData["services"] }) { + const [active, setActive] = useState(null) + + return ( +
+ +
    setActive(null)}> + {services.items.map((item, i) => ( +
  • setActive(i)} className="border-b border-t-fg/15"> +
    + 0{i + 1} + + {item.title} + + + {(active === i || active === null) && ( + + {item.text} + + )} + +
    +
  • + ))} +
+
+ ) +} diff --git a/modules/templates/catalog/beauty/template.tsx b/modules/templates/catalog/beauty/template.tsx index ec731cc..f30a270 100644 --- a/modules/templates/catalog/beauty/template.tsx +++ b/modules/templates/catalog/beauty/template.tsx @@ -32,7 +32,7 @@ export function BeautyTemplate({ content }: TemplateProps) { {show("intro") && ( -
+
) {
)} - {show("services") && } + {show("services") && } {show("gallery") && ( -
+
{gallery.images.map((image, i) => (
) { )} {show("team") && ( -
+

{team.eyebrow}

@@ -85,7 +85,7 @@ export function BeautyTemplate({ content }: TemplateProps) {
)} - {show("reviews") && } + {show("reviews") && }
diff --git a/modules/templates/catalog/cafe/definition.ts b/modules/templates/catalog/cafe/definition.ts new file mode 100644 index 0000000..7086a9a --- /dev/null +++ b/modules/templates/catalog/cafe/definition.ts @@ -0,0 +1,110 @@ +import { defineTemplate, describeSections, unsplash } from "../../lib/content" + +const defaults = { + brand: { name: "Krümel & Bohne", tagline: "Café & Backstube", address: "Bergmannstraße 21, 10961 Berlin", phone: "030 123 456 78" }, + hero: { + title: "Frisch gebacken.\n*Liebevoll* gebrüht.", + text: "Sauerteigbrot, Zimtschnecken und Kaffee aus der eigenen Röstung – jeden Tag ab 7 Uhr mitten in Kreuzberg.", + cta: "Zur Auslage", + image: unsplash("1555507036-ab1f4038808a", 1200), + imageSecondary: unsplash("1509042239860-f550ce710b93", 800), + imageThird: unsplash("1558961363-fa8fdf82db35", 800), + sticker: "Seit 5 Uhr gebacken", + stickerSecondary: "100 % Bio-Mehl", + }, + fresh: { + title: "Heute frisch", + note: "Stand heute 11 Uhr · Vorbestellen für morgen bis 18 Uhr", + items: [ + { name: "Zimtschnecke", price: "3,20", status: "" }, + { name: "Sauerteig-Landbrot", price: "6,50", status: "" }, + { name: "Franzbrötchen", price: "2,90", status: "fast weg" }, + { name: "Rhabarber-Streusel", price: "3,80", status: "ausverkauft" }, + { name: "Mohnschnecke", price: "3,20", status: "" }, + ], + }, + products: { + eyebrow: "Auslage", + title: "Unsere *Lieblinge*", + items: [ + { name: "Sauerteig-Landbrot", category: "Brot", price: "6,50", text: "48 Stunden Teigruhe, knusprige Kruste.", image: unsplash("1509440159596-0249088772ff", 800) }, + { name: "Brötchen-Mix", category: "Brot", price: "4,90", text: "Sechs Sorten für den perfekten Sonntag.", image: unsplash("1608198093002-ad4e005484ec", 800) }, + { name: "Buttercroissant", category: "Süßes", price: "2,40", text: "Französische Butter, 27 Schichten.", image: unsplash("1555507036-ab1f4038808a", 800) }, + { name: "Schoko-Cookie", category: "Süßes", price: "2,80", text: "Außen knusprig, innen weich.", image: unsplash("1558961363-fa8fdf82db35", 800) }, + { name: "Flat White", category: "Kaffee", price: "3,90", text: "Doppelter Espresso, samtige Milch.", image: unsplash("1509042239860-f550ce710b93", 800) }, + { name: "Filterkaffee", category: "Kaffee", price: "3,20", text: "Hausröstung, wechselnde Bohne.", image: unsplash("1442512595331-e89e73853f31", 800) }, + { name: "Schokotorte", category: "Torten", price: "4,90", text: "Das Stück – oder ganz auf Bestellung.", image: unsplash("1578985545062-69928b1d9587", 800) }, + { name: "Cupcakes", category: "Torten", price: "3,50", text: "Täglich wechselnde Toppings.", image: unsplash("1486427944299-d1955d23e34d", 800) }, + ], + }, + story: { + eyebrow: "Über uns", + title: "Backen wie *früher.*", + text: "Wir backen mit Zeit, Bio-Mehl aus Brandenburg und ohne Fertigmischungen. Jeden Morgen um fünf geht bei uns das Licht an – damit um sieben alles duftet.", + image: unsplash("1517433670267-08bbd4be890f", 1000), + imageSecondary: unsplash("1554118811-1e0d58224f24", 800), + }, + breakfast: { + title: "Frühstück bis *14 Uhr*", + items: [ + { name: "Kleines Glück", text: "Croissant, Butter, Marmelade und ein Kaffee nach Wahl", price: "7,90" }, + { name: "Großer Hunger", text: "Brötchenkorb, Rührei, Käse, Aufschnitt, Obst und O-Saft", price: "12,90" }, + { name: "Avocado-Sauerteig", text: "Geröstetes Landbrot, Avocado, Feta und Chili", price: "9,50" }, + { name: "Granola-Bowl", text: "Hausgemachtes Granola, Joghurt und saisonales Obst", price: "8,50" }, + ], + }, + social: { + handle: "@kruemelundbohne", + images: [ + { src: unsplash("1495474472287-4d71bcdd2085", 700) }, + { src: unsplash("1551024601-bec78aea704b", 700) }, + { src: unsplash("1559925393-8be0ec4767c8", 700) }, + { src: unsplash("1612203985729-70726954388c", 700) }, + { src: unsplash("1501339847302-ac426a4a7cbb", 700) }, + { src: unsplash("1578985545062-69928b1d9587", 700) }, + ], + }, + visit: { + title: "Komm *vorbei!*", + text: "Draußen sitzen, Hund mitbringen, Laptop aufklappen – alles erlaubt.", + hours: [ + { days: "Montag – Freitag", time: "7 – 18 Uhr" }, + { days: "Samstag", time: "8 – 18 Uhr" }, + { days: "Sonntag", time: "9 – 16 Uhr" }, + ], + }, +} + +export const cafe = defineTemplate({ + key: "cafe", + name: "Krümel", + industry: "Café & Bäckerei", + description: "Verspielt und herzlich: Sticker und Wellen, Kreidetafel „Heute frisch“, Auslage mit Filter, Vorbestellen per Tüte, Frühstückskarte und Insta-Raster.", + cover: unsplash("1517433670267-08bbd4be890f", 1200), + samples: { + inquiry: "Hallo, ich hätte gern für Samstag eine Schokotorte für 16 Personen mit der Aufschrift „Happy Birthday Lotta“ und dazu 20 Zimtschnecken. Geht das, und was kostet es? Danke, Miriam", + topic: "Neu ab Montag: Kürbis-Zimtschnecken", + }, + palettes: [ + { key: "karamell", label: "Karamell", bg: "#fff8ef", fg: "#3a2618", muted: "#8a6f5c", surface: "#f7e8d5", accent: "#e8743b" }, + { key: "himbeere", label: "Himbeere", bg: "#fff5f7", fg: "#3b1d2a", muted: "#8c6675", surface: "#fde3ea", accent: "#e0456b" }, + { key: "minze", label: "Minze", bg: "#f2faf6", fg: "#1d3a30", muted: "#5f7d72", surface: "#dff2e9", accent: "#2fa37a" }, + ], + fonts: [ + { key: "rund", label: "Fredoka & Nunito" }, + { key: "weich", label: "Baloo 2 & Mulish" }, + ], + sections: describeSections(defaults, { + brand: "Café & Kontakt", + hero: "Startbereich", + fresh: { label: "Heute frisch", optional: true, items: { items: "Produkt" } }, + products: { label: "Auslage", items: { items: "Produkt" } }, + story: { label: "Über uns", optional: true }, + breakfast: { label: "Frühstück", optional: true, items: { items: "Gericht" } }, + social: { label: "Social-Raster", optional: true, items: { images: "Bild" } }, + visit: { label: "Öffnungszeiten", items: { hours: "Zeile" } }, + }), + defaults, +}) + +export type CafeData = typeof cafe.defaults diff --git a/modules/templates/catalog/cafe/fonts.ts b/modules/templates/catalog/cafe/fonts.ts new file mode 100644 index 0000000..2671375 --- /dev/null +++ b/modules/templates/catalog/cafe/fonts.ts @@ -0,0 +1,16 @@ +import { Baloo_2, Fredoka, Mulish, Nunito, Pacifico } from "next/font/google" +import type { FontPair } from "../../lib/types" + +// preload: false – siehe restaurant/fonts.ts. Pacifico nur für Logo und Kreidetafel. +const fredoka = Fredoka({ subsets: ["latin"], variable: "--font-cafe-fredoka", preload: false }) +const nunito = Nunito({ subsets: ["latin"], variable: "--font-cafe-nunito", preload: false }) +const baloo = Baloo_2({ subsets: ["latin"], variable: "--font-cafe-baloo", preload: false }) +const mulish = Mulish({ subsets: ["latin"], variable: "--font-cafe-mulish", preload: false }) +const pacifico = Pacifico({ subsets: ["latin"], weight: "400", variable: "--font-cafe-pacifico", preload: false }) + +export const fonts: Record = { + rund: { className: `${fredoka.variable} ${nunito.variable} ${pacifico.variable}`, display: "var(--font-cafe-fredoka)", body: "var(--font-cafe-nunito)" }, + weich: { className: `${baloo.variable} ${mulish.variable} ${pacifico.variable}`, display: "var(--font-cafe-baloo)", body: "var(--font-cafe-mulish)" }, +} + +export const script = { fontFamily: "var(--font-cafe-pacifico), cursive" } diff --git a/modules/templates/catalog/cafe/template.tsx b/modules/templates/catalog/cafe/template.tsx new file mode 100644 index 0000000..2a842c0 --- /dev/null +++ b/modules/templates/catalog/cafe/template.tsx @@ -0,0 +1,381 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" +import { useState } from "react" +import { ArrowRightIcon, MapPinIcon, MinusIcon, PlusIcon, ShoppingBagIcon, XIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import type { TemplateProps } from "../../lib/types" +import { DemoNote } from "../shared/demo" +import { TField, TSelect } from "../shared/fields" +import { TImage } from "../shared/media" +import { Reveal, RevealWords, ease, useScrollNav } from "../shared/motion" +import { TemplateShell } from "../shared/shell" +import { cafe, type CafeData } from "./definition" +import { fonts, script } from "./fonts" + +type Basket = Record + +const em = "text-t-accent" +const container = "mx-auto w-full max-w-[1240px] px-5 md:px-8" +const heading = "font-t-display font-semibold tracking-[-0.02em]" +const ALL = "Alles" +const price = (value: string) => Number(value.replace(",", ".")) || 0 +const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }) + +function Wave({ className, flip }: { className?: string; flip?: boolean }) { + return ( + + + + ) +} + +function Sticker({ children, className, rotate = -8 }: { children: string; className?: string; rotate?: number }) { + return ( + + {children} + + ) +} + +export function CafeTemplate({ content }: TemplateProps) { + const { brand, hero, fresh, products, story, breakfast, social, visit } = content.data + const show = (key: string) => !content.hidden.includes(key) + const [basket, setBasket] = useState({}) + const [open, setOpen] = useState(false) + const count = Object.values(basket).reduce((sum, n) => sum + n, 0) + const change = (name: string, delta: number) => + setBasket((current) => { + const next = { ...current, [name]: Math.max(0, (current[name] ?? 0) + delta) } + if (next[name] === 0) delete next[name] + return next + }) + + return ( + +
@@ -141,7 +141,7 @@ export function CoachTemplate({ content }: TemplateProps) {
{show("press") && ( -
+

{press.title}

@@ -154,7 +154,7 @@ export function CoachTemplate({ content }: TemplateProps) { )} {show("pains") && ( -
+
{pains.eyebrow} @@ -181,10 +181,10 @@ export function CoachTemplate({ content }: TemplateProps) {
)} - {show("story") && } + {show("story") && } {show("offers") && ( -
+
{offers.eyebrow}
@@ -209,7 +209,7 @@ export function CoachTemplate({ content }: TemplateProps) { )} {show("results") && ( -
+
{results.eyebrow} @@ -235,10 +235,10 @@ export function CoachTemplate({ content }: TemplateProps) {
)} - {show("podcast") && } + {show("podcast") && } {show("leadmagnet") && ( -
+
) {
{show("marquee") && ( -
+
{marquee.items.map((item, i) => ( @@ -173,7 +173,7 @@ export function EventTemplate({ content }: TemplateProps) { )} {show("speakers") && ( -
+
{speakers.eyebrow}
@@ -194,7 +194,7 @@ export function EventTemplate({ content }: TemplateProps) {
)} - {show("agenda") && } + {show("agenda") && }
{tickets.eyebrow} @@ -241,7 +241,7 @@ export function EventTemplate({ content }: TemplateProps) {
{show("venue") && ( -
+
{venue.eyebrow} @@ -255,7 +255,7 @@ export function EventTemplate({ content }: TemplateProps) { )} {show("partners") && ( -
+

{partners.title}

@@ -268,7 +268,7 @@ export function EventTemplate({ content }: TemplateProps) { )} {show("faq") && ( -
+
)} diff --git a/modules/templates/catalog/fitness/template.tsx b/modules/templates/catalog/fitness/template.tsx index d78d65f..022110e 100644 --- a/modules/templates/catalog/fitness/template.tsx +++ b/modules/templates/catalog/fitness/template.tsx @@ -82,12 +82,12 @@ export function FitnessTemplate({ content }: TemplateProps) {

{hero.text}

{hero.cta} - {show("classes") && {hero.ctaSecondary}} + {show("classes") && {hero.ctaSecondary}}
{show("stats") && ( -
+
{stats.items.map((stat, i) => (
0 && "md:border-l md:border-t-fg/15 md:pl-8")}>
@@ -103,7 +103,7 @@ export function FitnessTemplate({ content }: TemplateProps) {
{show("marquee") && ( -
+
{marquee.items.map((item, i) => ( @@ -115,12 +115,12 @@ export function FitnessTemplate({ content }: TemplateProps) { )} - {show("programs") && } - {show("classes") && } - {show("transformation") && } + {show("programs") && } + {show("classes") && } + {show("transformation") && } {show("pricing") && ( -
+
{pricing.eyebrow}
@@ -158,7 +158,7 @@ export function FitnessTemplate({ content }: TemplateProps) { )} {show("coaches") && ( -
+
{coaches.eyebrow}
diff --git a/modules/templates/catalog/font-library.ts b/modules/templates/catalog/font-library.ts new file mode 100644 index 0000000..03f6df8 --- /dev/null +++ b/modules/templates/catalog/font-library.ts @@ -0,0 +1,34 @@ +import type { FontPair } from "../lib/types" +import { fonts as agentur } from "./agentur/fonts" +import { fonts as beauty } from "./beauty/fonts" +import { fonts as cafe } from "./cafe/fonts" +import { fonts as coach } from "./coach/fonts" +import { fonts as event } from "./event/fonts" +import { fonts as fitness } from "./fitness/fonts" +import { fonts as fotograf } from "./fotograf/fonts" +import { fonts as handwerk } from "./handwerk/fonts" +import { fonts as hotel } from "./hotel/fonts" +import { fonts as immobilien } from "./immobilien/fonts" +import { fonts as kampagne } from "./kampagne/fonts" +import { fonts as kanzlei } from "./kanzlei/fonts" +import { fonts as karriere } from "./karriere/fonts" +import { fonts as linkinbio } from "./linkinbio/fonts" +import { fonts as nutzfahrzeuge } from "./nutzfahrzeuge/fonts" +import { fonts as pflege } from "./pflege/fonts" +import { fonts as praxis } from "./praxis/fonts" +import { fonts as restaurant } from "./restaurant/fonts" +import { fonts as saas } from "./saas/fonts" +import { fonts as shop } from "./shop/fonts" +import { fonts as speisekarte } from "./speisekarte/fonts" +import { fonts as verein } from "./verein/fonts" + +const library: Record> = { + agentur, beauty, cafe, coach, event, fitness, fotograf, handwerk, hotel, immobilien, kampagne, + kanzlei, karriere, linkinbio, nutzfahrzeuge, pflege, praxis, restaurant, saas, shop, speisekarte, verein, +} + +// Schriftpaar einer anderen Vorlage ("vorlage:schlüssel"); alle Schriften laden erst bei Verwendung (preload: false) +export function libraryFont(key: string): FontPair | undefined { + const [template, pair] = key.split(":") + return library[template]?.[pair] +} diff --git a/modules/templates/catalog/fotograf/definition.ts b/modules/templates/catalog/fotograf/definition.ts new file mode 100644 index 0000000..c8d5838 --- /dev/null +++ b/modules/templates/catalog/fotograf/definition.ts @@ -0,0 +1,102 @@ +import { defineTemplate, describeSections, unsplash } from "../../lib/content" + +const defaults = { + brand: { name: "Lena Wolff", tagline: "Hochzeits- & Portraitfotografie", email: "hallo@lenawolff-fotografie.de", location: "Hamburg & weltweit" }, + hero: { + title: "Echte Momente,\n*zeitlos* erzählt.", + text: "Ich fotografiere Hochzeiten und Menschen so, wie sie sind – leise, nah und ganz ohne gestellte Posen.", + cta: "Datum anfragen", + strip: [ + { src: unsplash("1537633552985-df8429e8048b", 800) }, + { src: unsplash("1606216794074-735e91aa2c92", 800) }, + { src: unsplash("1465495976277-4387d4b0b4c6", 800) }, + { src: unsplash("1519741497674-611481863552", 800) }, + { src: unsplash("1469371670807-013ccf25f16a", 800) }, + { src: unsplash("1583939003579-730e3918a45a", 800) }, + ], + }, + portfolio: { + eyebrow: "Portfolio", + title: "Ausgewählte *Geschichten*", + items: [ + { src: unsplash("1511285560929-80b456fea0bc", 1200), caption: "Trauung am See", category: "Hochzeit" }, + { src: unsplash("1606216794074-735e91aa2c92", 1200), caption: "Anna & Ben", category: "Paare" }, + { src: unsplash("1515934751635-c81c6bc9a2d8", 1200), caption: "Die Ringe", category: "Details" }, + { src: unsplash("1534528741775-53994a69daeb", 1200), caption: "Studio-Portrait", category: "Portrait" }, + { src: unsplash("1522673607200-164d1b6ce486", 1200), caption: "Freie Trauung im Garten", category: "Hochzeit" }, + { src: unsplash("1537633552985-df8429e8048b", 1200), caption: "Am Meer", category: "Paare" }, + { src: unsplash("1465495976277-4387d4b0b4c6", 1200), caption: "Ja!", category: "Details" }, + { src: unsplash("1500648767791-00dcc994a43e", 1200), caption: "Business-Portrait", category: "Portrait" }, + { src: unsplash("1583939003579-730e3918a45a", 1200), caption: "Mit allen Liebsten", category: "Hochzeit" }, + { src: unsplash("1519741497674-611481863552", 1200), caption: "Der Brautstrauß", category: "Details" }, + ], + }, + packages: { + eyebrow: "Pakete", + title: "Für jeden *großen* Tag", + items: [ + { name: "Standesamt", price: "ab 690 €", features: "2 Stunden Begleitung\n150 bearbeitete Bilder\nOnline-Galerie zum Teilen", highlight: "" }, + { name: "Ganzer Tag", price: "ab 2.490 €", features: "Bis zu 10 Stunden Begleitung\nÜber 600 bearbeitete Bilder\nPaarshooting inklusive\nZweite Fotografin optional", highlight: "ja" }, + { name: "Portrait", price: "ab 290 €", features: "1 Stunde Shooting\n25 Bilder nach Wahl\nOutdoor oder im Studio", highlight: "" }, + ], + }, + about: { + title: "Hallo, ich bin *Lena.*", + text: "Seit zwölf Jahren begleite ich Paare an ihrem wichtigsten Tag. Ich liebe ungeplante Augenblicke: das Lachen beim Ringtausch, die Tränen der Trauzeugin, das Tanzen um Mitternacht. Meine Bilder sollen sich in zwanzig Jahren noch genauso anfühlen wie heute.", + image: unsplash("1524504388940-b1c1722653e1", 1000), + signature: "Lena", + }, + process: { + items: [ + { title: "Kennenlernen", text: "Ein Kaffee oder ein Videocall – wir schauen, ob es passt." }, + { title: "Planen", text: "Zeitplan, Lieblingsorte und alle kleinen Details." }, + { title: "Euer Tag", text: "Ich bin da, unauffällig und mittendrin." }, + { title: "Galerie", text: "Nach vier Wochen bekommt ihr eure Online-Galerie." }, + ], + }, + testimonials: { + items: [ + { quote: "Wir haben bei jedem Bild geweint – vor Glück. Lena hat Momente eingefangen, die wir selbst gar nicht bemerkt haben.", author: "Sophie & Malte" }, + { quote: "Sie war den ganzen Tag dabei und trotzdem nie im Weg. Unsere Gäste fragen heute noch nach ihr.", author: "Jana & Lukas" }, + { quote: "Die schönsten Portraits, die je von mir gemacht wurden – und ich hasse Fotos eigentlich.", author: "Martin K." }, + ], + }, + contact: { + title: "Euer Datum ist noch *frei?*", + text: "Ich fotografiere nur rund 25 Hochzeiten im Jahr. Schreibt mir früh – ich melde mich innerhalb von zwei Tagen.", + }, +} + +export const fotograf = defineTemplate({ + key: "fotograf", + name: "Lichtwerk", + industry: "Fotograf & Portfolio", + description: "Minimal und editorial: gegenläufige Bildspalten im Hero, filterbares Masonry-Portfolio mit Lightbox, Pakete, Ablauf und Datumsanfrage.", + cover: unsplash("1511285560929-80b456fea0bc", 1200), + samples: { + inquiry: "Hallo Lena, wir heiraten am 22. August 2027 in der Nähe von Lüneburg (Trauung 14 Uhr, Feier bis spät). Bist du an dem Tag noch frei, und was würde eine Ganztagsbegleitung kosten? Liebe Grüße, Hannah & Felix", + topic: "Noch drei Termine frei für Sommer 2027", + }, + palettes: [ + { key: "papier", label: "Papier", bg: "#faf9f7", fg: "#161616", muted: "#77736d", surface: "#efece7", accent: "#b08968" }, + { key: "noir", label: "Noir", dark: true, bg: "#0c0c0c", fg: "#f3f1ed", muted: "#8f8b85", surface: "#171717", accent: "#c9b8a0" }, + { key: "salbei", label: "Salbei", bg: "#f1f2ee", fg: "#232a25", muted: "#6c746d", surface: "#e2e6df", accent: "#7a8b6f" }, + ], + fonts: [ + { key: "zeitlos", label: "DM Serif Display & Albert Sans" }, + { key: "romantisch", label: "Libre Baskerville & Josefin Sans" }, + ], + sections: describeSections(defaults, { + brand: "Fotograf:in", + hero: { label: "Startbereich", items: { strip: "Bild" } }, + portfolio: { label: "Portfolio", items: { items: "Bild" } }, + packages: { label: "Pakete", optional: true, items: { items: "Paket" } }, + about: { label: "Über mich", optional: true }, + process: { label: "Ablauf", optional: true, items: { items: "Schritt" } }, + testimonials: { label: "Stimmen", optional: true, items: { items: "Stimme" } }, + contact: "Anfrage", + }), + defaults, +}) + +export type FotografData = typeof fotograf.defaults diff --git a/modules/templates/catalog/fotograf/fonts.ts b/modules/templates/catalog/fotograf/fonts.ts new file mode 100644 index 0000000..df938c8 --- /dev/null +++ b/modules/templates/catalog/fotograf/fonts.ts @@ -0,0 +1,13 @@ +import { Albert_Sans, DM_Serif_Display, Josefin_Sans, Libre_Baskerville } from "next/font/google" +import type { FontPair } from "../../lib/types" + +// preload: false – siehe restaurant/fonts.ts +const dmSerif = DM_Serif_Display({ subsets: ["latin"], weight: "400", style: ["normal", "italic"], variable: "--font-foto-dmserif", preload: false }) +const albert = Albert_Sans({ subsets: ["latin"], variable: "--font-foto-albert", preload: false }) +const baskerville = Libre_Baskerville({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-foto-baskerville", preload: false }) +const josefin = Josefin_Sans({ subsets: ["latin"], variable: "--font-foto-josefin", preload: false }) + +export const fonts: Record = { + zeitlos: { className: `${dmSerif.variable} ${albert.variable}`, display: "var(--font-foto-dmserif)", body: "var(--font-foto-albert)" }, + romantisch: { className: `${baskerville.variable} ${josefin.variable}`, display: "var(--font-foto-baskerville)", body: "var(--font-foto-josefin)" }, +} diff --git a/modules/templates/catalog/fotograf/template.tsx b/modules/templates/catalog/fotograf/template.tsx new file mode 100644 index 0000000..b3a9c6b --- /dev/null +++ b/modules/templates/catalog/fotograf/template.tsx @@ -0,0 +1,263 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" +import { useState } from "react" +import { ArrowRightIcon, CameraIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import type { TemplateProps } from "../../lib/types" +import { DemoForm } from "../shared/demo-form" +import { TArea, TField, TSelect } from "../shared/fields" +import { splitList } from "../shared/hooks" +import { Lightbox } from "../shared/lightbox" +import { TImage } from "../shared/media" +import { Reveal, RevealWords, ease, useScrollNav } from "../shared/motion" +import { TemplateShell } from "../shared/shell" +import { fotograf, type FotografData } from "./definition" +import { fonts } from "./fonts" + +const em = "italic text-t-accent" +const container = "mx-auto w-full max-w-[1400px] px-5 md:px-10" +const heading = "font-t-display font-normal tracking-[-0.01em]" +const label = "text-[11px] uppercase tracking-[0.3em]" +const ALL = "Alle" + +function Column({ images, reverse, duration }: { images: { src: string }[]; reverse?: boolean; duration: number }) { + return ( + + {[...images, ...images].map((image, i) => ( +
+ +
+ ))} +
+ ) +} + +export function FotografTemplate({ content }: TemplateProps) { + const { brand, hero, portfolio, packages, about, process, testimonials, contact } = content.data + const show = (key: string) => !content.hidden.includes(key) + const half = Math.ceil(hero.strip.length / 2) + const links = [ + { href: "#portfolio", label: "Portfolio", visible: true }, + { href: "#pakete", label: "Pakete", visible: show("packages") }, + { href: "#ueber-mich", label: "Über mich", visible: show("about") }, + ].filter((link) => link.visible) + + return ( + +
{show("marquee") && ( -
+
{marquee.items.map((item, i) => ( @@ -158,7 +158,7 @@ export function HandwerkTemplate({ content }: TemplateProps) { )} {show("services") && ( -
+
{services.eyebrow} @@ -190,7 +190,7 @@ export function HandwerkTemplate({ content }: TemplateProps) { )} {show("stats") && ( -
+
{stats.items.map((stat, i) => ( @@ -206,7 +206,7 @@ export function HandwerkTemplate({ content }: TemplateProps) { )} {show("projects") && ( -
+
{projects.eyebrow}
@@ -238,10 +238,10 @@ export function HandwerkTemplate({ content }: TemplateProps) {
)} - {show("process") && } + {show("process") && } {show("testimonial") && ( -
+
“ diff --git a/modules/templates/catalog/hotel/definition.ts b/modules/templates/catalog/hotel/definition.ts new file mode 100644 index 0000000..026dd72 --- /dev/null +++ b/modules/templates/catalog/hotel/definition.ts @@ -0,0 +1,104 @@ +import { defineTemplate, describeSections, unsplash } from "../../lib/content" + +const defaults = { + brand: { name: "Haus am See", suffix: "Boutique-Hotel · Tegernsee", phone: "08022 123 456", email: "hallo@hausamsee-hotel.de", address: "Seestraße 7, 83684 Tegernsee" }, + hero: { + eyebrow: "Boutique-Hotel am Tegernsee", + title: "Ankommen.\nDurchatmen. *Bleiben.*", + image: unsplash("1501785888041-af3ef285b470"), + cta: "Verfügbarkeit prüfen", + }, + intro: { + text: "Zwölf Zimmer, ein eigener Steg und der Duft von frischem Brot am Morgen. Bei uns verlangsamt sich die Zeit – damit Sie Platz haben für das, was wirklich zählt.", + }, + rooms: { + eyebrow: "Zimmer & Suiten", + title: "Ihr Zuhause *auf Zeit.*", + items: [ + { name: "Seezimmer", size: "28 m²", guests: "2", price: "189", text: "Französischer Balkon mit Blick über den See.", image: unsplash("1596394516093-501ba68a0ba6", 1200) }, + { name: "Juniorsuite", size: "42 m²", guests: "2–3", price: "259", text: "Mit Lesesessel, Kamin und freistehender Wanne.", image: unsplash("1611892440504-42a792e24d32", 1200) }, + { name: "Alpensuite", size: "56 m²", guests: "4", price: "349", text: "Zwei Schlafzimmer und Terrasse mit Bergblick.", image: unsplash("1582719478250-c89cae4dc85b", 1200) }, + { name: "Landhauszimmer", size: "24 m²", guests: "2", price: "149", text: "Gemütlich, hell und ruhig zum Garten gelegen.", image: unsplash("1590490360182-c33d57733427", 1200) }, + ], + }, + amenities: { + title: "Kleine Dinge, die *viel* ausmachen", + items: [ + { title: "Spa & Sauna", text: "Finnische Sauna, Dampfbad und Ruheraum mit Seeblick." }, + { title: "Frühstück vom Hof", text: "Regional, saisonal und bis 11 Uhr serviert." }, + { title: "Eigener Seezugang", text: "Steg, Liegewiese und Ruderboote für unsere Gäste." }, + { title: "E-Bikes inklusive", text: "Für Touren rund um den See und in die Berge." }, + { title: "Parken kostenlos", text: "Direkt am Haus, mit Ladestation für E-Autos." }, + { title: "Hunde willkommen", text: "Mit Körbchen, Napf und Lieblingswegen-Karte." }, + ], + }, + gallery: { + title: "Eindrücke", + images: [ + { src: unsplash("1566073771259-6a8506099945", 1400), caption: "Unser Außenpool" }, + { src: unsplash("1584132967334-10e028bd69f7", 1400), caption: "Sonnendeck am Wasser" }, + { src: unsplash("1506905925346-21bda4d32df4", 1400), caption: "Morgens am Wallberg" }, + { src: unsplash("1476514525535-07fb3b4ae5f1", 1400), caption: "Mit dem Boot über den See" }, + { src: unsplash("1509042239860-f550ce710b93", 1400), caption: "Kaffee auf der Terrasse" }, + { src: unsplash("1509440159596-0249088772ff", 1400), caption: "Brot aus der Hofbäckerei" }, + ], + }, + experiences: { + eyebrow: "Erleben", + title: "Rund um das *Haus*", + items: [ + { title: "Wandern am Wallberg", distance: "15 Minuten", text: "Panoramawege für Genießer und Gipfelstürmer.", image: unsplash("1506905925346-21bda4d32df4", 1000) }, + { title: "Stand-up-Paddling", distance: "direkt am Steg", text: "Boards stehen für Sie bereit – auch für Anfänger.", image: unsplash("1476514525535-07fb3b4ae5f1", 1000) }, + { title: "Spa-Abende", distance: "im Haus", text: "Sauna, Massage und ein Glas Wein am Kamin.", image: unsplash("1566073771259-6a8506099945", 1000) }, + ], + }, + reviews: { + score: "9,4", + label: "Hervorragend", + count: "412 Bewertungen", + items: [ + { quote: "Der schönste Frühstücksplatz, den wir je hatten. Wir kommen im Herbst wieder.", author: "Katharina & Jonas, München" }, + { quote: "Herzlich, ruhig und unglaublich liebevoll eingerichtet. Ein echtes Zuhause auf Zeit.", author: "Familie Berger, Stuttgart" }, + { quote: "Morgens Saunagang, dann direkt in den See – besser geht Urlaub nicht.", author: "Lisa M., Wien" }, + ], + }, + contact: { + title: "Wir freuen uns *auf Sie.*", + text: "Für Fragen, Gruppen oder besondere Wünsche erreichen Sie uns täglich von 7 bis 22 Uhr.", + }, +} + +export const hotel = defineTemplate({ + key: "hotel", + name: "Refugium", + industry: "Hotel & Ferienwohnung", + description: "Ruhig und einladend: Buchungsleiste mit Live-Preis, Zimmer-Slider, Ausstattung, Galerie mit Lightbox, Erlebnisse und Bewertungs-Score.", + cover: unsplash("1596394516093-501ba68a0ba6", 1200), + samples: { + inquiry: "Hallo, wir würden gern vom 14. bis 17. Mai mit zwei Erwachsenen und unserem Hund anreisen. Ist die Juniorsuite frei, und gibt es ein Paket mit Massage? Liebe Grüße, Anna Keller", + topic: "Herbst-Special: drei Nächte bleiben, zwei bezahlen", + }, + palettes: [ + { key: "morgen", label: "Morgen", bg: "#f7f3ec", fg: "#1f2a2a", muted: "#6b706b", surface: "#ece4d6", accent: "#a67c52" }, + { key: "see", label: "See", bg: "#eef3f1", fg: "#12302e", muted: "#5a716d", surface: "#dbe7e3", accent: "#2f7d73" }, + { key: "abend", label: "Abend", dark: true, bg: "#14161a", fg: "#efe9df", muted: "#9a958c", surface: "#1e2126", accent: "#d4a865" }, + ], + fonts: [ + { key: "klassisch", label: "Playfair Display & Mulish" }, + { key: "natur", label: "Lora & Urbanist" }, + ], + sections: describeSections(defaults, { + brand: "Hotel & Kontakt", + hero: "Startbereich", + intro: { label: "Leitsatz", optional: true }, + rooms: { label: "Zimmer", items: { items: "Zimmer" } }, + amenities: { label: "Ausstattung", optional: true, items: { items: "Merkmal" } }, + gallery: { label: "Galerie", optional: true, items: { images: "Bild" } }, + experiences: { label: "Erlebnisse", optional: true, items: { items: "Erlebnis" } }, + reviews: { label: "Bewertungen", optional: true, items: { items: "Bewertung" } }, + contact: "Buchung & Kontakt", + }), + defaults, +}) + +export type HotelData = typeof hotel.defaults diff --git a/modules/templates/catalog/hotel/fonts.ts b/modules/templates/catalog/hotel/fonts.ts new file mode 100644 index 0000000..a9c5abb --- /dev/null +++ b/modules/templates/catalog/hotel/fonts.ts @@ -0,0 +1,13 @@ +import { Lora, Mulish, Playfair_Display, Urbanist } from "next/font/google" +import type { FontPair } from "../../lib/types" + +// preload: false – siehe restaurant/fonts.ts +const playfair = Playfair_Display({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-hotel-playfair", preload: false }) +const mulish = Mulish({ subsets: ["latin"], variable: "--font-hotel-mulish", preload: false }) +const lora = Lora({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-hotel-lora", preload: false }) +const urbanist = Urbanist({ subsets: ["latin"], variable: "--font-hotel-urbanist", preload: false }) + +export const fonts: Record = { + klassisch: { className: `${playfair.variable} ${mulish.variable}`, display: "var(--font-hotel-playfair)", body: "var(--font-hotel-mulish)" }, + natur: { className: `${lora.variable} ${urbanist.variable}`, display: "var(--font-hotel-lora)", body: "var(--font-hotel-urbanist)" }, +} diff --git a/modules/templates/catalog/hotel/template.tsx b/modules/templates/catalog/hotel/template.tsx new file mode 100644 index 0000000..bfbb7b1 --- /dev/null +++ b/modules/templates/catalog/hotel/template.tsx @@ -0,0 +1,313 @@ +"use client" + +import { motion } from "motion/react" +import { useRef, useState } from "react" +import { BedDoubleIcon, BikeIcon, CarIcon, ChevronLeftIcon, ChevronRightIcon, CoffeeIcon, DogIcon, MapPinIcon, MaximizeIcon, PhoneIcon, SparklesIcon, StarIcon, UsersIcon, WavesIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import type { TemplateProps } from "../../lib/types" +import { DemoNote } from "../shared/demo" +import { TField, TSelect } from "../shared/fields" +import { Lightbox } from "../shared/lightbox" +import { TImage } from "../shared/media" +import { Reveal, RevealWords, ScrollWords, ease, useScrollNav } from "../shared/motion" +import { TemplateShell } from "../shared/shell" +import { hotel, type HotelData } from "./definition" +import { fonts } from "./fonts" + +const em = "italic text-t-accent" +const container = "mx-auto w-full max-w-[1320px] px-5 md:px-10" +const heading = "font-t-display font-normal tracking-[-0.01em]" +const label = "text-[11px] font-semibold uppercase tracking-[0.28em]" +const amenityIcons = [SparklesIcon, CoffeeIcon, WavesIcon, BikeIcon, CarIcon, DogIcon] +const DAY = 86_400_000 + +type Booking = { arrive: string; depart: string; guests: string; room: string } + +export function HotelTemplate({ content }: TemplateProps) { + const { brand, hero, intro, rooms, amenities, gallery, experiences, reviews, contact } = content.data + const show = (key: string) => !content.hidden.includes(key) + const [booking, setBooking] = useState({ arrive: "", depart: "", guests: "2", room: rooms.items[0]?.name ?? "" }) + const [lightbox, setLightbox] = useState(null) + const update = (patch: Partial) => setBooking((b) => ({ ...b, ...patch })) + + return ( + +