mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
Merge pull request #17 from Since-3/feature/added-more-content
added bunch of stuff
This commit is contained in:
commit
6268dbac76
65 changed files with 5516 additions and 250 deletions
31
app/api/templates/[id]/export/route.ts
Normal file
31
app/api/templates/[id]/export/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
90
modules/templates/catalog/agentur/definition.ts
Normal file
90
modules/templates/catalog/agentur/definition.ts
Normal file
|
|
@ -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
|
||||
11
modules/templates/catalog/agentur/fonts.ts
Normal file
11
modules/templates/catalog/agentur/fonts.ts
Normal file
|
|
@ -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<string, FontPair> = {
|
||||
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)" },
|
||||
}
|
||||
255
modules/templates/catalog/agentur/template.tsx
Normal file
255
modules/templates/catalog/agentur/template.tsx
Normal file
|
|
@ -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<ReturnType<typeof setInterval> | 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 (
|
||||
<span onMouseEnter={run} className={className}>
|
||||
{output}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgenturTemplate({ content }: TemplateProps<AgenturData>) {
|
||||
const { brand, hero, clients, work, manifest, services, numbers, team, contact } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const [cursor, setCursor] = useState<string | null>(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 (
|
||||
<TemplateShell def={agentur} content={content} fonts={fonts[content.fonts] ?? fonts.brutal} className="[@media(hover:hover)]:cursor-none [@media(hover:hover)]:[&_a]:cursor-none [@media(hover:hover)]:[&_button]:cursor-none">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ x: springX, y: springY }}
|
||||
className="pointer-events-none fixed top-0 left-0 z-[100] hidden [@media(hover:hover)]:block"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ width: cursor ? 110 : 16, height: cursor ? 110 : 16 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-t-accent text-xs font-bold tracking-wider text-t-accent-fg uppercase"
|
||||
>
|
||||
{cursor}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<header className="fixed inset-x-0 top-0 z-50 mix-blend-difference">
|
||||
<nav className={cn(container, "flex h-20 items-center justify-between text-white")}>
|
||||
<a href="#top" className={cn(display, "text-2xl")}>{brand.name}</a>
|
||||
<div className="hidden gap-8 text-sm font-semibold uppercase md:flex">
|
||||
<a href="#arbeiten"><Scramble text="Arbeiten" /></a>
|
||||
{show("services") && <a href="#leistungen"><Scramble text="Leistungen" /></a>}
|
||||
{show("team") && <a href="#team"><Scramble text="Team" /></a>}
|
||||
</div>
|
||||
<a href="#kontakt" className="text-sm font-semibold uppercase underline underline-offset-4"><Scramble text={hero.cta} /></a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="top" className={cn(container, "relative flex min-h-svh flex-col justify-end pt-32 pb-12")}>
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -40 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ duration: 1.4, ease, delay: 0.4 }}
|
||||
className="absolute top-28 right-5 size-40 overflow-hidden rounded-full md:right-10 md:size-72"
|
||||
>
|
||||
<motion.div className="absolute inset-[-20%]" animate={{ rotate: 360 }} transition={{ duration: 30, repeat: Infinity, ease: "linear" }}>
|
||||
<TImage src={hero.image} eager sizes="300px" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(display, "relative text-[clamp(4rem,14vw,15rem)] leading-[0.82]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 1 }} className="mt-12 grid gap-8 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<p className="max-w-md text-xl leading-snug">{hero.text}</p>
|
||||
<p className="text-sm font-semibold uppercase text-t-muted">{brand.tagline} · {brand.city}</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{show("clients") && (
|
||||
<section data-order="clients" className="border-y border-t-fg/15 py-8">
|
||||
<Marquee duration={22}>
|
||||
{clients.items.map((client, i) => (
|
||||
<span key={i} className={cn(display, "px-10 text-5xl md:text-7xl", i % 2 ? "text-t-fg" : "text-transparent [-webkit-text-stroke:1.5px_var(--t-fg)]")}>
|
||||
{client.name}
|
||||
</span>
|
||||
))}
|
||||
</Marquee>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="arbeiten" className={cn(container, "py-24 md:py-32")}>
|
||||
<RevealWords text={work.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,8rem)] leading-[0.9]")} />
|
||||
<div className="mt-16 space-y-6">
|
||||
{work.items.map((project, i) => (
|
||||
<article
|
||||
key={i}
|
||||
onMouseEnter={() => 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)" }}
|
||||
>
|
||||
<div className="grid gap-8 md:grid-cols-[1fr_1.3fr] md:items-end">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase opacity-80">{project.category} · {project.year}</p>
|
||||
<h3 className={cn(display, "mt-3 text-[clamp(3rem,7vw,7rem)] leading-[0.9]")}>{project.title}</h3>
|
||||
<p className="mt-5 max-w-sm text-lg opacity-90">{project.text}</p>
|
||||
<span className="mt-6 inline-flex items-center gap-2 rounded-full bg-white px-5 py-2.5 text-sm font-bold text-zinc-900">
|
||||
Case ansehen <ArrowUpRightIcon className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl">
|
||||
<TImage src={project.image} alt={project.title} sizes="(min-width: 768px) 55vw, 100vw" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show("manifest") && (
|
||||
<section data-order="manifest" className={cn(container, "py-24 md:py-40")}>
|
||||
<ScrollWords text={manifest.text} className={cn(display, "text-[clamp(2.25rem,5.5vw,5.5rem)] leading-[1] tracking-[-0.04em]")} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("services") && <Services data-order="services" services={services} />}
|
||||
|
||||
{show("numbers") && (
|
||||
<section data-order="numbers" className="bg-t-accent py-16 text-t-accent-fg">
|
||||
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
|
||||
{numbers.items.map((item, i) => (
|
||||
<div key={i}>
|
||||
<dd className={cn(display, "text-6xl md:text-8xl")}>
|
||||
<Counter value={item.value} />
|
||||
{item.suffix}
|
||||
</dd>
|
||||
<dt className="mt-1 font-semibold uppercase">{item.label}</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("team") && (
|
||||
<section data-order="team" id="team" className={cn(container, "py-24 md:py-32")}>
|
||||
<RevealWords text={team.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,8rem)] leading-[0.9]")} />
|
||||
<div className="mt-14 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{team.members.map((member, i) => (
|
||||
<Reveal key={i} delay={i * 0.08}>
|
||||
<div className="group" onMouseEnter={() => setCursor("Hallo!")} onMouseLeave={() => setCursor(null)}>
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl">
|
||||
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="grayscale transition duration-500 group-hover:scale-105 group-hover:grayscale-0" />
|
||||
</div>
|
||||
<p className={cn(display, "mt-4 text-2xl")}>{member.name}</p>
|
||||
<p className="text-sm uppercase text-t-muted">{member.role}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="kontakt" className={cn(container, "pt-24 pb-16 md:pt-32")}>
|
||||
<RevealWords text={contact.title} emClassName={em} className={cn(display, "text-[clamp(3.5rem,10vw,11rem)] leading-[0.85]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mt-10 max-w-md text-xl">{contact.text}</p>
|
||||
<a
|
||||
href={`mailto:${brand.email}`}
|
||||
onMouseEnter={() => 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}
|
||||
<span className="absolute -bottom-1 left-0 h-1.5 w-full origin-left scale-x-0 bg-t-accent transition-transform duration-500 group-hover:scale-x-100" />
|
||||
</a>
|
||||
</Reveal>
|
||||
<div className="mt-24 flex flex-col gap-4 border-t border-t-fg/15 pt-8 text-sm uppercase text-t-muted md:flex-row md:justify-between">
|
||||
<p>{brand.name} · {brand.city}</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Services({ services }: { services: AgenturData["services"] }) {
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<section id="leistungen" className={cn(container, "py-24 md:py-32")}>
|
||||
<RevealWords text={services.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,8rem)] leading-[0.9]")} />
|
||||
<ul className="mt-14 border-t border-t-fg/15" onMouseLeave={() => setActive(null)}>
|
||||
{services.items.map((item, i) => (
|
||||
<li key={i} onMouseEnter={() => setActive(i)} className="border-b border-t-fg/15">
|
||||
<div className="flex flex-col gap-2 py-6 md:flex-row md:items-center md:gap-10 md:py-8">
|
||||
<span className="text-sm font-semibold text-t-muted">0{i + 1}</span>
|
||||
<motion.span
|
||||
animate={{ x: active === i ? 24 : 0, opacity: active === null || active === i ? 1 : 0.3 }}
|
||||
transition={{ duration: 0.4, ease }}
|
||||
className={cn(display, "flex-1 text-[clamp(2.5rem,6vw,6rem)] leading-none")}
|
||||
>
|
||||
{item.title}
|
||||
</motion.span>
|
||||
<AnimatePresence>
|
||||
{(active === i || active === null) && (
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: active === i ? 1 : 0.6 }} exit={{ opacity: 0 }} className="max-w-xs text-t-muted md:text-right">
|
||||
{item.text}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
|
|||
<Hero hero={hero} />
|
||||
|
||||
{show("intro") && (
|
||||
<section className="mx-auto max-w-[1200px] px-5 py-24 md:px-10 md:py-40">
|
||||
<section data-order="intro" className="mx-auto max-w-[1200px] px-5 py-24 md:px-10 md:py-40">
|
||||
<ScrollWords
|
||||
text={intro.text}
|
||||
className="text-center font-t-display text-[clamp(1.9rem,4.3vw,4rem)] leading-[1.14] tracking-[-0.015em]"
|
||||
|
|
@ -40,10 +40,10 @@ export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("services") && <Services services={services} />}
|
||||
{show("services") && <Services data-order="services" services={services} />}
|
||||
|
||||
{show("gallery") && (
|
||||
<section aria-label="Bildband" className="py-10 md:py-16">
|
||||
<section data-order="gallery" aria-label="Bildband" className="py-10 md:py-16">
|
||||
<Marquee duration={55}>
|
||||
{gallery.images.map((image, i) => (
|
||||
<div
|
||||
|
|
@ -61,7 +61,7 @@ export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
|
|||
)}
|
||||
|
||||
{show("team") && (
|
||||
<section id="team" className={cn(container, "py-24 md:py-40")}>
|
||||
<section data-order="team" id="team" className={cn(container, "py-24 md:py-40")}>
|
||||
<Reveal><p className={eyebrow}>{team.eyebrow}</p></Reveal>
|
||||
<RevealWords text={team.title} emClassName={em} className="font-t-display text-[clamp(2.75rem,6vw,5.75rem)] leading-[0.95] tracking-[-0.025em]" />
|
||||
<div className="mt-16 grid grid-cols-2 gap-x-4 gap-y-12 md:grid-cols-4 md:gap-x-6">
|
||||
|
|
@ -85,7 +85,7 @@ export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("reviews") && <Reviews reviews={reviews} />}
|
||||
{show("reviews") && <Reviews data-order="reviews" reviews={reviews} />}
|
||||
|
||||
<section id="termin" className="p-3 md:p-6">
|
||||
<div className="relative overflow-hidden rounded-[32px] bg-t-accent px-5 py-20 text-t-accent-fg md:rounded-[56px] md:px-16 md:py-28">
|
||||
|
|
|
|||
110
modules/templates/catalog/cafe/definition.ts
Normal file
110
modules/templates/catalog/cafe/definition.ts
Normal file
|
|
@ -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
|
||||
16
modules/templates/catalog/cafe/fonts.ts
Normal file
16
modules/templates/catalog/cafe/fonts.ts
Normal file
|
|
@ -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<string, FontPair> = {
|
||||
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" }
|
||||
381
modules/templates/catalog/cafe/template.tsx
Normal file
381
modules/templates/catalog/cafe/template.tsx
Normal file
|
|
@ -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<string, number>
|
||||
|
||||
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 (
|
||||
<svg viewBox="0 0 1440 60" preserveAspectRatio="none" aria-hidden className={cn("block h-10 w-full md:h-14", flip && "rotate-180", className)}>
|
||||
<path d="M0 30 C 120 0, 240 60, 360 30 S 600 0, 720 30 S 960 60, 1080 30 S 1320 0, 1440 30 V60 H0 Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Sticker({ children, className, rotate = -8 }: { children: string; className?: string; rotate?: number }) {
|
||||
return (
|
||||
<motion.span
|
||||
initial={{ scale: 0, rotate: rotate - 20 }}
|
||||
animate={{ scale: 1, rotate: [rotate, rotate + 4, rotate] }}
|
||||
transition={{ scale: { type: "spring", bounce: 0.5, delay: 0.9 }, rotate: { duration: 4, repeat: Infinity, ease: "easeInOut" } }}
|
||||
className={cn("absolute z-10 rounded-full border-2 border-dashed border-t-fg/30 bg-t-bg px-4 py-2 text-sm font-bold shadow-lg", className)}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CafeTemplate({ content }: TemplateProps<CafeData>) {
|
||||
const { brand, hero, fresh, products, story, breakfast, social, visit } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const [basket, setBasket] = useState<Basket>({})
|
||||
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 (
|
||||
<TemplateShell def={cafe} content={content} fonts={fonts[content.fonts] ?? fonts.rund}>
|
||||
<Nav name={brand.name} count={count} onBasket={() => setOpen(true)} />
|
||||
|
||||
<section id="top" className="relative overflow-hidden bg-t-surface pt-32 md:pt-40">
|
||||
<div className={cn(container, "grid items-center gap-14 pb-20 lg:grid-cols-[1.1fr_1fr]")}>
|
||||
<div>
|
||||
<RevealWords as="h1" onMount delay={0.15} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(3rem,7vw,6rem)] leading-[0.98]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
|
||||
<p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||
<a href="#auslage" className="mt-8 inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 font-bold text-t-accent-fg shadow-[0_8px_0_0] shadow-t-fg/20 transition-transform hover:-translate-y-1">
|
||||
{hero.cta} <ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="relative mx-auto aspect-square w-full max-w-[520px]">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 1, ease }}
|
||||
className="absolute inset-[8%] overflow-hidden rounded-[45%_55%_52%_48%/48%_45%_55%_52%] border-8 border-t-bg shadow-2xl"
|
||||
>
|
||||
<TImage src={hero.image} eager sizes="(min-width: 1024px) 40vw, 90vw" />
|
||||
</motion.div>
|
||||
{[
|
||||
{ src: hero.imageSecondary, className: "top-0 right-0 size-[34%]", delay: 0.5 },
|
||||
{ src: hero.imageThird, className: "bottom-2 left-0 size-[30%]", delay: 0.7 },
|
||||
].map((photo, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1, y: [0, -10, 0] }}
|
||||
transition={{ scale: { type: "spring", bounce: 0.4, delay: photo.delay }, y: { duration: 5 + i, repeat: Infinity, ease: "easeInOut" } }}
|
||||
className={cn("absolute overflow-hidden rounded-full border-[6px] border-t-bg shadow-xl", photo.className)}
|
||||
>
|
||||
<TImage src={photo.src} sizes="200px" />
|
||||
</motion.div>
|
||||
))}
|
||||
<Sticker className="bottom-[18%] -right-2 bg-t-accent text-t-accent-fg" rotate={8}>{hero.sticker}</Sticker>
|
||||
<Sticker className="top-[10%] -left-4">{hero.stickerSecondary}</Sticker>
|
||||
</div>
|
||||
</div>
|
||||
<Wave className="text-t-bg" />
|
||||
</section>
|
||||
|
||||
{show("fresh") && (
|
||||
<section data-order="fresh" className={cn(container, "py-16 md:py-20")}>
|
||||
<Reveal>
|
||||
<div className="mx-auto max-w-3xl -rotate-1 rounded-[2rem] border-[10px] border-[#7a5a3c] bg-[#22302a] p-8 text-[#f5efe3] shadow-2xl md:p-12">
|
||||
<p className="text-center text-5xl" style={script}>{fresh.title}</p>
|
||||
<ul className="mt-8 space-y-3 text-lg">
|
||||
{fresh.items.map((item, i) => {
|
||||
const soldOut = item.status.toLowerCase().includes("aus")
|
||||
return (
|
||||
<li key={i} className={cn("flex items-baseline gap-3", soldOut && "opacity-50")}>
|
||||
<span className={cn(soldOut && "line-through")}>{item.name}</span>
|
||||
{item.status && <span className={cn("rounded-full px-2 py-0.5 text-xs font-bold", soldOut ? "bg-white/15" : "bg-t-accent text-t-accent-fg")}>{item.status}</span>}
|
||||
<span className="flex-1 border-b-2 border-dotted border-white/25" />
|
||||
<span>{item.price} €</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-8 text-center text-sm opacity-60">{fresh.note}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Products products={products} basket={basket} onChange={change} />
|
||||
|
||||
{show("story") && (
|
||||
<section data-order="story" className="relative bg-t-surface">
|
||||
<Wave flip className="text-t-bg" />
|
||||
<div className={cn(container, "grid items-center gap-14 py-20 md:grid-cols-2")}>
|
||||
<div className="relative mx-auto aspect-[4/5] w-full max-w-md">
|
||||
<Reveal className="absolute inset-0 rotate-[-4deg] overflow-hidden rounded-3xl border-8 border-t-bg shadow-xl">
|
||||
<TImage src={story.image} sizes="(min-width: 768px) 40vw, 90vw" />
|
||||
</Reveal>
|
||||
<Reveal delay={0.2} className="absolute -right-4 -bottom-8 w-1/2 rotate-[6deg] overflow-hidden rounded-3xl border-8 border-t-bg shadow-xl">
|
||||
<div className="relative aspect-square">
|
||||
<TImage src={story.imageSecondary} sizes="200px" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div>
|
||||
<Reveal><p className="mb-4 font-bold text-t-accent">{story.eyebrow}</p></Reveal>
|
||||
<RevealWords text={story.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)] leading-[1.02]")} />
|
||||
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{story.text}</p></Reveal>
|
||||
</div>
|
||||
</div>
|
||||
<Wave className="text-t-bg" />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("breakfast") && (
|
||||
<section data-order="breakfast" className={cn(container, "py-20 md:py-28")}>
|
||||
<RevealWords text={breakfast.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<div className="mx-auto mt-12 grid max-w-4xl gap-4 md:grid-cols-2">
|
||||
{breakfast.items.map((item, i) => (
|
||||
<Reveal key={i} delay={(i % 2) * 0.08}>
|
||||
<div className="flex h-full gap-4 rounded-3xl border-2 border-t-fg/10 p-6 transition-colors hover:border-t-accent">
|
||||
<div className="flex-1">
|
||||
<p className={cn(heading, "text-xl")}>{item.name}</p>
|
||||
<p className="mt-1 text-sm text-t-muted">{item.text}</p>
|
||||
</div>
|
||||
<p className={cn(heading, "text-xl text-t-accent")}>{item.price} €</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("social") && (
|
||||
<section data-order="social" className="pb-20">
|
||||
<p className={cn(heading, "mb-6 text-center text-2xl")}>Folge uns: <span className="text-t-accent">{social.handle}</span></p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6">
|
||||
{social.images.map((image, i) => (
|
||||
<div key={i} className="group relative aspect-square overflow-hidden">
|
||||
<TImage src={image.src} sizes="(min-width: 768px) 17vw, 33vw" className="transition-transform duration-500 group-hover:scale-110" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="besuch" className={cn(container, "pb-24")}>
|
||||
<div className="grid gap-8 rounded-[2.5rem] bg-t-accent p-8 text-t-accent-fg md:grid-cols-2 md:p-14">
|
||||
<div>
|
||||
<RevealWords text={visit.title} emClassName="underline decoration-wavy underline-offset-8" className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<p className="mt-4 text-lg opacity-85">{visit.text}</p>
|
||||
<p className="mt-8 flex items-center gap-2 font-bold"><MapPinIcon className="size-5" /> {brand.address}</p>
|
||||
</div>
|
||||
<dl className="self-center rounded-3xl bg-t-bg p-6 text-t-fg">
|
||||
{visit.hours.map((row, i) => (
|
||||
<div key={i} className="flex justify-between border-b border-t-fg/10 py-3 last:border-0">
|
||||
<dt>{row.days}</dt>
|
||||
<dd className="font-bold">{row.time}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={cn(container, "flex flex-col items-center gap-3 pb-10 text-center text-sm text-t-muted")}>
|
||||
<p className="text-3xl text-t-fg" style={script}>{brand.name}</p>
|
||||
<p>{brand.tagline} · {brand.phone}</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<BasketDrawer open={open} basket={basket} items={products.items} onChange={change} onClose={() => setOpen(false)} onDone={() => setBasket({})} />
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Nav({ name, count, onBasket }: { name: string; count: number; onBasket: () => void }) {
|
||||
const { scrolled, hidden } = useScrollNav()
|
||||
|
||||
return (
|
||||
<motion.header animate={{ y: hidden ? "-130%" : "0%" }} transition={{ duration: 0.45, ease }} className="fixed inset-x-0 top-0 z-50 px-3 pt-3">
|
||||
<nav className={cn("mx-auto flex h-16 max-w-[1240px] items-center justify-between rounded-full px-5 transition-all duration-500", scrolled && "bg-t-bg/90 shadow-lg backdrop-blur-xl")}>
|
||||
<a href="#top" className="text-2xl text-t-accent" style={script}>{name}</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="#besuch" className="hidden rounded-full px-4 py-2 font-semibold hover:bg-t-fg/5 sm:block">Öffnungszeiten</a>
|
||||
<button type="button" onClick={onBasket} aria-label={`Tüte mit ${count} Artikeln`} className="relative grid size-11 cursor-pointer place-items-center rounded-full bg-t-fg text-t-bg">
|
||||
<ShoppingBagIcon className="size-5" />
|
||||
<AnimatePresence>
|
||||
{count > 0 && (
|
||||
<motion.span key={count} initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="absolute -top-1 -right-1 grid size-5 place-items-center rounded-full bg-t-accent text-[11px] font-bold text-t-accent-fg">
|
||||
{count}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
|
||||
function Products({ products, basket, onChange }: { products: CafeData["products"]; basket: Basket; onChange: (name: string, delta: number) => void }) {
|
||||
const categories = [ALL, ...new Set(products.items.map((item) => item.category))]
|
||||
const [filter, setFilter] = useState(ALL)
|
||||
const visible = products.items.filter((item) => filter === ALL || item.category === filter)
|
||||
|
||||
return (
|
||||
<section id="auslage" className={cn(container, "py-16 md:py-24")}>
|
||||
<div className="text-center">
|
||||
<Reveal><p className="mb-3 font-bold text-t-accent">{products.eyebrow}</p></Reveal>
|
||||
<RevealWords text={products.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
aria-pressed={filter === category}
|
||||
onClick={() => setFilter(category)}
|
||||
className={cn("cursor-pointer rounded-full px-5 py-2.5 font-bold transition-all", filter === category ? "bg-t-fg text-t-bg" : "bg-t-surface hover:-translate-y-0.5")}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div layout className="mt-12 grid grid-cols-2 gap-4 md:gap-6 lg:grid-cols-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visible.map((item, i) => (
|
||||
<motion.article
|
||||
layout
|
||||
key={item.name}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
whileHover={{ rotate: i % 2 ? 1.5 : -1.5, y: -6 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
||||
className="flex flex-col overflow-hidden rounded-[1.75rem] bg-t-surface"
|
||||
>
|
||||
<div className="relative aspect-square">
|
||||
<TImage src={item.image} alt={item.name} sizes="(min-width: 1024px) 25vw, 50vw" />
|
||||
<span className="absolute top-3 left-3 rounded-full bg-t-bg px-3 py-1 text-xs font-bold">{item.category}</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4 md:p-5">
|
||||
<h3 className={cn(heading, "text-lg md:text-xl")}>{item.name}</h3>
|
||||
<p className="mt-1 flex-1 text-sm text-t-muted">{item.text}</p>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className={cn(heading, "text-xl")}>{item.price} €</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${item.name} in die Tüte`}
|
||||
onClick={() => onChange(item.name, 1)}
|
||||
className="flex h-10 min-w-10 cursor-pointer items-center justify-center gap-1 rounded-full bg-t-accent px-3 font-bold text-t-accent-fg transition-transform active:scale-90"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{basket[item.name] ? basket[item.name] : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BasketDrawer({ open, basket, items, onChange, onClose, onDone }: {
|
||||
open: boolean
|
||||
basket: Basket
|
||||
items: CafeData["products"]["items"]
|
||||
onChange: (name: string, delta: number) => void
|
||||
onClose: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [sent, setSent] = useState(false)
|
||||
const lines = items.filter((item) => basket[item.name])
|
||||
const total = lines.reduce((sum, item) => sum + price(item.price) * basket[item.name], 0)
|
||||
const close = () => {
|
||||
setSent(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} className="fixed inset-0 z-[80] bg-black/40" />
|
||||
<motion.aside
|
||||
role="dialog"
|
||||
aria-label="Deine Tüte"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: "0%" }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ duration: 0.45, ease }}
|
||||
className="fixed inset-x-0 bottom-0 z-[90] mx-auto max-h-[85vh] max-w-lg overflow-y-auto rounded-t-[2rem] bg-t-bg p-6 text-t-fg shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={cn(heading, "text-2xl")}>Deine Tüte</p>
|
||||
<button type="button" aria-label="Schließen" onClick={close} className="grid size-10 cursor-pointer place-items-center rounded-full bg-t-surface"><XIcon className="size-5" /></button>
|
||||
</div>
|
||||
{sent ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-4xl text-t-accent" style={script}>Bis gleich!</p>
|
||||
<p className="mt-3 text-t-muted">Deine Bestellung liegt zur Abholung bereit.</p>
|
||||
<DemoNote className="mt-4 text-sm opacity-60">Demo-Hinweis: Live kommt die Vorbestellung direkt in der Backstube an.</DemoNote>
|
||||
</div>
|
||||
) : lines.length === 0 ? (
|
||||
<p className="py-10 text-center text-t-muted">Noch leer – such dir was Leckeres aus!</p>
|
||||
) : (
|
||||
<form
|
||||
className="mt-6 grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setSent(true)
|
||||
onDone()
|
||||
}}
|
||||
>
|
||||
<ul className="divide-y divide-t-fg/10">
|
||||
{lines.map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-3 py-3">
|
||||
<span className="flex-1 font-semibold">{item.name}</span>
|
||||
<button type="button" aria-label="Weniger" onClick={() => onChange(item.name, -1)} className="grid size-8 cursor-pointer place-items-center rounded-full bg-t-surface"><MinusIcon className="size-4" /></button>
|
||||
<span className="w-5 text-center font-bold">{basket[item.name]}</span>
|
||||
<button type="button" aria-label="Mehr" onClick={() => onChange(item.name, 1)} className="grid size-8 cursor-pointer place-items-center rounded-full bg-t-surface"><PlusIcon className="size-4" /></button>
|
||||
<span className="w-20 text-right">{euro.format(price(item.price) * basket[item.name])}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="flex justify-between text-lg font-bold">Summe <span>{euro.format(total)}</span></p>
|
||||
<TSelect label="Abholung" variant="soft" options={["Heute, so schnell wie möglich", "Heute Nachmittag", "Morgen früh ab 7 Uhr"]} />
|
||||
<TField label="Dein Name" name="name" required variant="soft" />
|
||||
<button type="submit" className="cursor-pointer rounded-full bg-t-accent py-4 font-bold text-t-accent-fg">Verbindlich vorbestellen</button>
|
||||
</form>
|
||||
)}
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -101,7 +101,7 @@ export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
|||
{hero.cta} <ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
{show("offers") && (
|
||||
<a href="#angebote" className="rounded-full px-7 py-4 font-semibold ring-1 ring-t-fg/20 transition-colors hover:bg-t-surface">{hero.ctaSecondary}</a>
|
||||
<a data-order="offers" href="#angebote" className="rounded-full px-7 py-4 font-semibold ring-1 ring-t-fg/20 transition-colors hover:bg-t-surface">{hero.ctaSecondary}</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -141,7 +141,7 @@ export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
|||
</section>
|
||||
|
||||
{show("press") && (
|
||||
<section className="border-y border-t-fg/10 py-8">
|
||||
<section data-order="press" className="border-y border-t-fg/10 py-8">
|
||||
<div className={cn(container, "flex flex-col items-center gap-6 md:flex-row md:justify-between")}>
|
||||
<p className="text-sm text-t-muted">{press.title}</p>
|
||||
<div className="flex flex-wrap justify-center gap-x-10 gap-y-3">
|
||||
|
|
@ -154,7 +154,7 @@ export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
|||
)}
|
||||
|
||||
{show("pains") && (
|
||||
<section className={cn(container, "grid gap-12 py-24 md:grid-cols-2 md:py-32")}>
|
||||
<section data-order="pains" className={cn(container, "grid gap-12 py-24 md:grid-cols-2 md:py-32")}>
|
||||
<div>
|
||||
<Reveal><Eyebrow>{pains.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={pains.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||
|
|
@ -181,10 +181,10 @@ export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("story") && <Story story={story} />}
|
||||
{show("story") && <Story data-order="story" story={story} />}
|
||||
|
||||
{show("offers") && (
|
||||
<section id="angebote" className={cn(container, "py-24 md:py-32")}>
|
||||
<section data-order="offers" id="angebote" className={cn(container, "py-24 md:py-32")}>
|
||||
<Reveal><Eyebrow>{offers.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={offers.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||
<div className="mt-14 grid gap-5 lg:grid-cols-3">
|
||||
|
|
@ -209,7 +209,7 @@ export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
|||
)}
|
||||
|
||||
{show("results") && (
|
||||
<section className="bg-t-surface py-24 md:py-32">
|
||||
<section data-order="results" className="bg-t-surface py-24 md:py-32">
|
||||
<div className={container}>
|
||||
<Reveal><Eyebrow>{results.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={results.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||
|
|
@ -235,10 +235,10 @@ export function CoachTemplate({ content }: TemplateProps<CoachData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("podcast") && <Podcast podcast={podcast} />}
|
||||
{show("podcast") && <Podcast data-order="podcast" podcast={podcast} />}
|
||||
|
||||
{show("leadmagnet") && (
|
||||
<section className={cn(container, "pb-24 md:pb-32")}>
|
||||
<section data-order="leadmagnet" className={cn(container, "pb-24 md:pb-32")}>
|
||||
<div className="grid items-center gap-12 rounded-[2.5rem] bg-t-fg px-6 py-14 text-t-bg md:grid-cols-[0.8fr_1.2fr] md:px-14">
|
||||
<div className="[perspective:1200px]">
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export function EventTemplate({ content }: TemplateProps<EventData>) {
|
|||
</section>
|
||||
|
||||
{show("marquee") && (
|
||||
<section aria-label="Themen" className="space-y-3 py-12">
|
||||
<section data-order="marquee" aria-label="Themen" className="space-y-3 py-12">
|
||||
<div className="-rotate-1 bg-t-accent py-3 text-t-accent-fg">
|
||||
<Marquee duration={25}>
|
||||
{marquee.items.map((item, i) => (
|
||||
|
|
@ -173,7 +173,7 @@ export function EventTemplate({ content }: TemplateProps<EventData>) {
|
|||
)}
|
||||
|
||||
{show("speakers") && (
|
||||
<section id="speaker" className={cn(container, "py-24 md:py-32")}>
|
||||
<section data-order="speakers" id="speaker" className={cn(container, "py-24 md:py-32")}>
|
||||
<Reveal><Eyebrow>{speakers.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={speakers.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
|
||||
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -194,7 +194,7 @@ export function EventTemplate({ content }: TemplateProps<EventData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("agenda") && <Agenda agenda={agenda} />}
|
||||
{show("agenda") && <Agenda data-order="agenda" agenda={agenda} />}
|
||||
|
||||
<section id="tickets" className={cn(container, "py-24 md:py-32")}>
|
||||
<Reveal><Eyebrow>{tickets.eyebrow}</Eyebrow></Reveal>
|
||||
|
|
@ -241,7 +241,7 @@ export function EventTemplate({ content }: TemplateProps<EventData>) {
|
|||
</section>
|
||||
|
||||
{show("venue") && (
|
||||
<section id="location" className={cn(container, "grid items-center gap-10 pb-24 md:pb-32 lg:grid-cols-[1.3fr_1fr]")}>
|
||||
<section data-order="venue" id="location" className={cn(container, "grid items-center gap-10 pb-24 md:pb-32 lg:grid-cols-[1.3fr_1fr]")}>
|
||||
<ParallaxImage src={venue.image} offset={50} className="aspect-[16/10]" sizes="(min-width: 1024px) 55vw, 100vw" />
|
||||
<div>
|
||||
<Reveal><Eyebrow>{venue.eyebrow}</Eyebrow></Reveal>
|
||||
|
|
@ -255,7 +255,7 @@ export function EventTemplate({ content }: TemplateProps<EventData>) {
|
|||
)}
|
||||
|
||||
{show("partners") && (
|
||||
<section className="border-y border-t-fg/10 py-12">
|
||||
<section data-order="partners" className="border-y border-t-fg/10 py-12">
|
||||
<div className={cn(container, "flex flex-col items-center gap-8 md:flex-row md:justify-between")}>
|
||||
<p className={cn(label, "text-t-muted")}>{partners.title}</p>
|
||||
<div className="flex flex-wrap justify-center gap-x-12 gap-y-4">
|
||||
|
|
@ -268,7 +268,7 @@ export function EventTemplate({ content }: TemplateProps<EventData>) {
|
|||
)}
|
||||
|
||||
{show("faq") && (
|
||||
<section className={cn(container, "max-w-4xl py-24")}>
|
||||
<section data-order="faq" className={cn(container, "max-w-4xl py-24")}>
|
||||
<Accordion items={faq.items} className="divide-y divide-t-fg/15 border-y border-t-fg/15" questionClassName="py-6 text-lg font-bold" answerClassName="pb-6 leading-relaxed text-t-muted" />
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -82,12 +82,12 @@ export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
|
|||
<p className="max-w-md text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<SkewButton href="#probetraining">{hero.cta}</SkewButton>
|
||||
{show("classes") && <SkewButton href="#kurse" ghost>{hero.ctaSecondary}</SkewButton>}
|
||||
{show("classes") && <SkewButton data-order="classes" href="#kurse" ghost>{hero.ctaSecondary}</SkewButton>}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{show("stats") && (
|
||||
<dl className="mt-14 grid grid-cols-2 border-t border-t-fg/15 md:grid-cols-4">
|
||||
<dl data-order="stats" className="mt-14 grid grid-cols-2 border-t border-t-fg/15 md:grid-cols-4">
|
||||
{stats.items.map((stat, i) => (
|
||||
<div key={i} className={cn("py-6 pr-4", i > 0 && "md:border-l md:border-t-fg/15 md:pl-8")}>
|
||||
<dd className={cn(display, "text-4xl md:text-5xl")}>
|
||||
|
|
@ -103,7 +103,7 @@ export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
|
|||
</section>
|
||||
|
||||
{show("marquee") && (
|
||||
<div className="-rotate-2 scale-105 bg-t-accent py-4 text-t-accent-fg">
|
||||
<div data-order="marquee" className="-rotate-2 scale-105 bg-t-accent py-4 text-t-accent-fg">
|
||||
<Marquee duration={18}>
|
||||
{marquee.items.map((item, i) => (
|
||||
<span key={i} className={cn(display, "flex items-center gap-8 px-4 text-3xl font-black md:text-5xl")}>
|
||||
|
|
@ -115,12 +115,12 @@ export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{show("programs") && <Programs programs={programs} />}
|
||||
{show("classes") && <Schedule classes={classes} />}
|
||||
{show("transformation") && <Transformation data={transformation} />}
|
||||
{show("programs") && <Programs data-order="programs" programs={programs} />}
|
||||
{show("classes") && <Schedule data-order="classes" classes={classes} />}
|
||||
{show("transformation") && <Transformation data-order="transformation" data={transformation} />}
|
||||
|
||||
{show("pricing") && (
|
||||
<section id="preise" className={cn(container, "py-24 md:py-36")}>
|
||||
<section data-order="pricing" id="preise" className={cn(container, "py-24 md:py-36")}>
|
||||
<Reveal><Eyebrow>{pricing.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={pricing.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
|
||||
<div className="mt-14 grid gap-5 lg:grid-cols-3">
|
||||
|
|
@ -158,7 +158,7 @@ export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
|
|||
)}
|
||||
|
||||
{show("coaches") && (
|
||||
<section id="team" className={cn(container, "pb-24 md:pb-36")}>
|
||||
<section data-order="coaches" id="team" className={cn(container, "pb-24 md:pb-36")}>
|
||||
<Reveal><Eyebrow>{coaches.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={coaches.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
|
||||
<div className="mt-12 grid grid-cols-2 gap-3 md:grid-cols-4 md:gap-5">
|
||||
|
|
|
|||
34
modules/templates/catalog/font-library.ts
Normal file
34
modules/templates/catalog/font-library.ts
Normal file
|
|
@ -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<string, Record<string, FontPair>> = {
|
||||
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]
|
||||
}
|
||||
102
modules/templates/catalog/fotograf/definition.ts
Normal file
102
modules/templates/catalog/fotograf/definition.ts
Normal file
|
|
@ -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
|
||||
13
modules/templates/catalog/fotograf/fonts.ts
Normal file
13
modules/templates/catalog/fotograf/fonts.ts
Normal file
|
|
@ -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<string, FontPair> = {
|
||||
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)" },
|
||||
}
|
||||
263
modules/templates/catalog/fotograf/template.tsx
Normal file
263
modules/templates/catalog/fotograf/template.tsx
Normal file
|
|
@ -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 (
|
||||
<motion.div
|
||||
className="flex flex-col gap-4"
|
||||
animate={{ y: reverse ? ["-50%", "0%"] : ["0%", "-50%"] }}
|
||||
transition={{ duration, ease: "linear", repeat: Infinity }}
|
||||
>
|
||||
{[...images, ...images].map((image, i) => (
|
||||
<div key={i} className="relative aspect-[3/4] w-full shrink-0 overflow-hidden">
|
||||
<TImage src={image.src} sizes="(min-width: 1024px) 20vw, 45vw" eager={i < 2} />
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FotografTemplate({ content }: TemplateProps<FotografData>) {
|
||||
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 (
|
||||
<TemplateShell def={fotograf} content={content} fonts={fonts[content.fonts] ?? fonts.zeitlos}>
|
||||
<Nav brand={brand} cta={hero.cta} links={links} />
|
||||
|
||||
<section id="top" className={cn(container, "grid items-center gap-12 pt-28 lg:grid-cols-[1fr_1.1fr] lg:pt-24")}>
|
||||
<div className="py-10">
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className={cn(label, "flex items-center gap-3 text-t-muted")}>
|
||||
<CameraIcon className="size-4 text-t-accent" /> {brand.tagline}
|
||||
</motion.p>
|
||||
<RevealWords as="h1" onMount delay={0.3} text={hero.title} emClassName={em} className={cn(heading, "mt-8 text-[clamp(3rem,6.5vw,6.5rem)] leading-[1]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.8 }}>
|
||||
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
||||
<div className="mt-10 flex items-center gap-6">
|
||||
<a href="#anfrage" className="group inline-flex items-center gap-3 border-b border-t-fg pb-1 text-lg">
|
||||
{hero.cta} <ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
|
||||
</a>
|
||||
<span className="text-sm text-t-muted">{brand.location}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.2, delay: 0.3 }}
|
||||
className="relative grid h-[70vh] grid-cols-2 gap-4 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)] lg:h-[88vh]"
|
||||
>
|
||||
<Column images={hero.strip.slice(0, half)} duration={38} />
|
||||
<Column images={hero.strip.slice(half)} duration={44} reverse />
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<Portfolio portfolio={portfolio} />
|
||||
|
||||
{show("packages") && (
|
||||
<section data-order="packages" id="pakete" className="bg-t-surface py-24 md:py-32">
|
||||
<div className={container}>
|
||||
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{packages.eyebrow}</p></Reveal>
|
||||
<RevealWords text={packages.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)]")} />
|
||||
<div className="mt-14 grid gap-px bg-t-fg/15 md:grid-cols-3">
|
||||
{packages.items.map((item, i) => {
|
||||
const featured = Boolean(item.highlight.trim())
|
||||
return (
|
||||
<Reveal key={i} delay={i * 0.1} className={cn("flex flex-col p-8 md:p-10", featured ? "bg-t-fg text-t-bg" : "bg-t-surface")}>
|
||||
<p className={cn(label, featured ? "opacity-60" : "text-t-muted")}>{featured ? "Am beliebtesten" : `Paket ${i + 1}`}</p>
|
||||
<h3 className={cn(heading, "mt-4 text-4xl")}>{item.name}</h3>
|
||||
<p className={cn(heading, "mt-2 text-2xl italic", featured ? "text-t-accent" : "text-t-accent")}>{item.price}</p>
|
||||
<ul className="mt-8 flex-1 space-y-3 text-sm">
|
||||
{splitList(item.features, "\n").map((feature) => (
|
||||
<li key={feature} className={cn("border-b pb-3", featured ? "border-t-bg/15" : "border-t-fg/10")}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="#anfrage" className={cn("mt-8 text-center text-sm underline-offset-4 hover:underline", featured && "text-t-accent")}>Anfragen →</a>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("about") && (
|
||||
<section data-order="about" id="ueber-mich" className={cn(container, "grid items-center gap-14 py-24 md:grid-cols-[0.9fr_1.1fr] md:py-32")}>
|
||||
<Reveal>
|
||||
<div className="relative aspect-[4/5] overflow-hidden">
|
||||
<TImage src={about.image} sizes="(min-width: 768px) 40vw, 100vw" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<div>
|
||||
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mt-8 text-lg leading-relaxed text-t-muted">{about.text}</p>
|
||||
<p className={cn(heading, "mt-10 text-5xl italic text-t-accent")}>{about.signature}</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("process") && (
|
||||
<section data-order="process" className="border-y border-t-fg/10">
|
||||
<ol className={cn(container, "grid gap-10 py-16 md:grid-cols-4")}>
|
||||
{process.items.map((step, i) => (
|
||||
<Reveal key={i} delay={i * 0.08}>
|
||||
<li>
|
||||
<span className={cn(heading, "text-5xl italic text-t-accent")}>{String(i + 1).padStart(2, "0")}</span>
|
||||
<p className={cn(heading, "mt-4 text-2xl")}>{step.title}</p>
|
||||
<p className="mt-1 text-sm text-t-muted">{step.text}</p>
|
||||
</li>
|
||||
</Reveal>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("testimonials") && (
|
||||
<section data-order="testimonials" className="mx-auto max-w-[1000px] space-y-16 px-5 py-24 text-center md:py-32">
|
||||
{testimonials.items.map((item, i) => (
|
||||
<figure key={i}>
|
||||
<RevealWords as="p" text={`„${item.quote}“`} className={cn(heading, "text-[clamp(1.5rem,3vw,2.5rem)] italic leading-[1.3]")} />
|
||||
<Reveal delay={0.2}><figcaption className={cn(label, "mt-6 text-t-muted")}>{item.author}</figcaption></Reveal>
|
||||
</figure>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="anfrage" className="bg-t-fg py-24 text-t-bg md:py-32">
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-2")}>
|
||||
<div>
|
||||
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.75rem,6vw,5.5rem)] leading-[1]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mt-6 max-w-md text-lg opacity-75">{contact.text}</p>
|
||||
<a href={`mailto:${brand.email}`} className="mt-8 inline-block border-b border-current pb-1">{brand.email}</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<DemoForm
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
success={<p className={cn(heading, "py-10 text-4xl")}>Danke! Ich melde mich <span className={em}>in zwei Tagen.</span></p>}
|
||||
>
|
||||
<TField label="Euer Datum" name="date" type="date" required variant="line" />
|
||||
<TField label="Location" name="location" variant="line" />
|
||||
<TField label="Namen" name="name" required autoComplete="name" variant="line" />
|
||||
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="line" />
|
||||
<TSelect label="Paket" name="package" variant="line" options={[...packages.items.map((p) => p.name), "Noch unsicher"]} className="sm:col-span-2" />
|
||||
<TArea label="Erzählt mir von euch" name="message" variant="line" className="sm:col-span-2" />
|
||||
<button type="submit" className="cursor-pointer border border-current py-4 transition-colors hover:bg-t-bg hover:text-t-fg sm:col-span-2">Anfrage senden</button>
|
||||
</DemoForm>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={cn(container, "flex flex-col gap-4 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||
<p><span className={cn(heading, "text-lg text-t-fg")}>{brand.name}</span> · {brand.tagline}</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</div>
|
||||
</footer>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Nav({ brand, cta, links }: { brand: FotografData["brand"]; cta: string; links: { href: string; label: string }[] }) {
|
||||
const { scrolled, hidden } = useScrollNav()
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||
transition={{ duration: 0.45, ease }}
|
||||
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled && "bg-t-bg/90 backdrop-blur-xl")}
|
||||
>
|
||||
<nav className={cn(container, "grid h-20 grid-cols-[1fr_auto_1fr] items-center")}>
|
||||
<div className={cn(label, "hidden gap-8 md:flex")}>
|
||||
{links.map((link) => (
|
||||
<a key={link.href} href={link.href} className="text-t-fg/70 hover:text-t-fg">{link.label}</a>
|
||||
))}
|
||||
</div>
|
||||
<a href="#top" className={cn(heading, "col-start-1 text-2xl md:col-start-2")}>{brand.name}</a>
|
||||
<a href="#anfrage" className={cn(label, "col-start-3 justify-self-end border-b border-t-fg pb-1")}>{cta}</a>
|
||||
</nav>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
|
||||
function Portfolio({ portfolio }: { portfolio: FotografData["portfolio"] }) {
|
||||
const categories = [ALL, ...new Set(portfolio.items.map((item) => item.category))]
|
||||
const [filter, setFilter] = useState(ALL)
|
||||
const [open, setOpen] = useState<number | null>(null)
|
||||
const visible = portfolio.items.filter((item) => filter === ALL || item.category === filter)
|
||||
|
||||
return (
|
||||
<section id="portfolio" className={cn(container, "py-24 md:py-32")}>
|
||||
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{portfolio.eyebrow}</p></Reveal>
|
||||
<RevealWords text={portfolio.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)]")} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
aria-pressed={filter === category}
|
||||
onClick={() => setFilter(category)}
|
||||
className={cn(label, "cursor-pointer border-b pb-1 transition-colors", filter === category ? "border-t-accent text-t-fg" : "border-transparent text-t-muted hover:text-t-fg")}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={filter} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.4 }} className="mt-14 columns-1 gap-5 sm:columns-2 lg:columns-3">
|
||||
{visible.map((item, i) => (
|
||||
<button
|
||||
key={item.caption}
|
||||
type="button"
|
||||
onClick={() => setOpen(i)}
|
||||
className={cn("group relative mb-5 block w-full cursor-zoom-in overflow-hidden break-inside-avoid", ["aspect-[3/4]", "aspect-[4/5]", "aspect-[4/3]"][i % 3])}
|
||||
>
|
||||
<TImage src={item.src} alt={item.caption} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
|
||||
<span className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-5 text-left text-white opacity-0 transition-opacity duration-500 group-hover:opacity-100">
|
||||
<span>
|
||||
<span className={cn(label, "block opacity-70")}>{item.category}</span>
|
||||
<span className={cn(heading, "text-2xl")}>{item.caption}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Lightbox images={visible} index={open} onIndex={setOpen} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
|||
<div className="mt-10 flex flex-wrap items-center gap-8">
|
||||
<BlockButton href="#kontakt">{hero.cta}</BlockButton>
|
||||
{show("projects") && (
|
||||
<a href="#projekte" className="border-b-2 border-t-accent pb-1 text-sm font-semibold uppercase tracking-wider">
|
||||
<a data-order="projects" href="#projekte" className="border-b-2 border-t-accent pb-1 text-sm font-semibold uppercase tracking-wider">
|
||||
{hero.ctaSecondary}
|
||||
</a>
|
||||
)}
|
||||
|
|
@ -135,7 +135,7 @@ export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
|||
</section>
|
||||
|
||||
{show("marquee") && (
|
||||
<section aria-label="Leistungsbereiche" className="py-10 md:py-16">
|
||||
<section data-order="marquee" aria-label="Leistungsbereiche" className="py-10 md:py-16">
|
||||
<div className="-rotate-[1.5deg] scale-[1.04] bg-t-fg py-5 text-t-bg md:py-7">
|
||||
<Marquee duration={30}>
|
||||
{marquee.items.map((item, i) => (
|
||||
|
|
@ -158,7 +158,7 @@ export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
|||
)}
|
||||
|
||||
{show("services") && (
|
||||
<section id="leistungen" className={cn(container, "py-24 md:py-36")}>
|
||||
<section data-order="services" id="leistungen" className={cn(container, "py-24 md:py-36")}>
|
||||
<div className="mb-16 grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-6">
|
||||
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
|
||||
|
|
@ -190,7 +190,7 @@ export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
|||
)}
|
||||
|
||||
{show("stats") && (
|
||||
<section className="bg-t-fg py-20 text-t-bg md:py-28">
|
||||
<section data-order="stats" className="bg-t-fg py-20 text-t-bg md:py-28">
|
||||
<div className={cn(container, "grid grid-cols-2 gap-y-14 md:grid-cols-4")}>
|
||||
{stats.items.map((stat, i) => (
|
||||
<Reveal key={i} delay={i * 0.1} className="border-l border-t-bg/20 pl-5 md:pl-8">
|
||||
|
|
@ -206,7 +206,7 @@ export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
|||
)}
|
||||
|
||||
{show("projects") && (
|
||||
<section id="projekte" className={cn(container, "py-24 md:py-36")}>
|
||||
<section data-order="projects" id="projekte" className={cn(container, "py-24 md:py-36")}>
|
||||
<Reveal><Eyebrow>{projects.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={projects.title} emClassName={em} className={cn(display, "mb-14 text-[clamp(2.8rem,6.5vw,6.5rem)] leading-[0.88] md:mb-20")} />
|
||||
<div className="grid gap-x-6 gap-y-12 md:grid-cols-12">
|
||||
|
|
@ -238,10 +238,10 @@ export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("process") && <Process process={process} />}
|
||||
{show("process") && <Process data-order="process" process={process} />}
|
||||
|
||||
{show("testimonial") && (
|
||||
<section className={cn(container, "grid gap-8 py-24 md:grid-cols-12 md:py-36")}>
|
||||
<section data-order="testimonial" className={cn(container, "grid gap-8 py-24 md:grid-cols-12 md:py-36")}>
|
||||
<Reveal className="md:col-span-2">
|
||||
<span aria-hidden className={cn(display, "block text-[9rem] leading-[0.7] text-t-accent")}>“</span>
|
||||
</Reveal>
|
||||
|
|
|
|||
104
modules/templates/catalog/hotel/definition.ts
Normal file
104
modules/templates/catalog/hotel/definition.ts
Normal file
|
|
@ -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
|
||||
13
modules/templates/catalog/hotel/fonts.ts
Normal file
13
modules/templates/catalog/hotel/fonts.ts
Normal file
|
|
@ -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<string, FontPair> = {
|
||||
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)" },
|
||||
}
|
||||
313
modules/templates/catalog/hotel/template.tsx
Normal file
313
modules/templates/catalog/hotel/template.tsx
Normal file
|
|
@ -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<HotelData>) {
|
||||
const { brand, hero, intro, rooms, amenities, gallery, experiences, reviews, contact } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const [booking, setBooking] = useState<Booking>({ arrive: "", depart: "", guests: "2", room: rooms.items[0]?.name ?? "" })
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
const update = (patch: Partial<Booking>) => setBooking((b) => ({ ...b, ...patch }))
|
||||
|
||||
return (
|
||||
<TemplateShell def={hotel} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
|
||||
<Nav name={brand.name} cta={hero.cta} />
|
||||
|
||||
<section id="top" className="relative flex min-h-svh flex-col justify-end overflow-hidden text-white">
|
||||
<motion.div className="absolute inset-0" initial={{ scale: 1.12 }} animate={{ scale: 1 }} transition={{ duration: 8, ease: "linear" }}>
|
||||
<TImage src={hero.image} eager />
|
||||
</motion.div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/40" />
|
||||
<div className={cn(container, "relative pb-10 text-center")}>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.3 }} className={cn(label, "text-white/80")}>
|
||||
{hero.eyebrow}
|
||||
</motion.p>
|
||||
<RevealWords as="h1" onMount delay={0.4} text={hero.title} emClassName="italic" className={cn(heading, "mt-6 text-[clamp(3rem,8vw,7.5rem)] leading-[1]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 1 }} className="mx-auto mt-14 max-w-4xl">
|
||||
<BookingBar booking={booking} rooms={rooms.items} onChange={update} cta={hero.cta} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show("intro") && (
|
||||
<section data-order="intro" className="mx-auto max-w-[1100px] px-5 py-24 md:py-36">
|
||||
<ScrollWords text={intro.text} className={cn(heading, "text-center text-[clamp(1.75rem,3.8vw,3.25rem)] leading-[1.25]")} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Rooms rooms={rooms} onPick={(room) => update({ room })} />
|
||||
|
||||
{show("amenities") && (
|
||||
<section data-order="amenities" className="bg-t-surface py-24 md:py-32">
|
||||
<div className={container}>
|
||||
<RevealWords text={amenities.title} emClassName={em} className={cn(heading, "max-w-2xl text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||
<div className="mt-14 grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{amenities.items.map((item, i) => {
|
||||
const Icon = amenityIcons[i % amenityIcons.length]
|
||||
return (
|
||||
<Reveal key={i} delay={(i % 3) * 0.08} className="flex gap-5">
|
||||
<span className="grid size-14 shrink-0 place-items-center rounded-full border border-t-accent/40 text-t-accent">
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className={cn(heading, "text-2xl")}>{item.title}</h3>
|
||||
<p className="mt-1 text-t-muted">{item.text}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("gallery") && (
|
||||
<section data-order="gallery" className={cn(container, "py-24 md:py-32")}>
|
||||
<RevealWords text={gallery.title} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)]")} />
|
||||
<div className="mt-12 columns-1 gap-4 sm:columns-2 lg:columns-3">
|
||||
{gallery.images.map((image, i) => (
|
||||
<Reveal key={i} delay={(i % 3) * 0.08} className="mb-4 break-inside-avoid">
|
||||
<button type="button" onClick={() => setLightbox(i)} className={cn("group relative block w-full cursor-zoom-in overflow-hidden", ["aspect-[4/5]", "aspect-[4/3]", "aspect-square"][i % 3])}>
|
||||
<TImage src={image.src} alt={image.caption} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
|
||||
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 text-left text-sm text-white opacity-0 transition-opacity group-hover:opacity-100">{image.caption}</span>
|
||||
</button>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<Lightbox images={gallery.images} index={lightbox} onIndex={setLightbox} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("experiences") && (
|
||||
<section data-order="experiences" className={cn(container, "pb-24 md:pb-32")}>
|
||||
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{experiences.eyebrow}</p></Reveal>
|
||||
<RevealWords text={experiences.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
{experiences.items.map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<article className="group">
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-t-full">
|
||||
<TImage src={item.image} alt={item.title} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-[1.2s] group-hover:scale-105" />
|
||||
</div>
|
||||
<p className={cn(label, "mt-5 text-t-accent")}>{item.distance}</p>
|
||||
<h3 className={cn(heading, "mt-2 text-2xl")}>{item.title}</h3>
|
||||
<p className="mt-1 text-t-muted">{item.text}</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("reviews") && (
|
||||
<section data-order="reviews" className="bg-t-fg py-24 text-t-bg md:py-32">
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-[0.7fr_1.3fr] lg:items-center")}>
|
||||
<Reveal>
|
||||
<div className="text-center lg:text-left">
|
||||
<p className={cn(heading, "text-[7rem] leading-none text-t-accent")}>{reviews.score}</p>
|
||||
<p className={cn(heading, "text-3xl")}>{reviews.label}</p>
|
||||
<p className="mt-2 flex items-center justify-center gap-2 opacity-70 lg:justify-start">
|
||||
<StarIcon className="size-4 fill-current" /> {reviews.count}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="grid gap-5">
|
||||
{reviews.items.map((review, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<figure className="border-l border-t-bg/20 pl-6">
|
||||
<blockquote className={cn(heading, "text-2xl italic leading-snug")}>„{review.quote}“</blockquote>
|
||||
<figcaption className="mt-3 text-sm opacity-70">{review.author}</figcaption>
|
||||
</figure>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="buchen" className={cn(container, "grid gap-12 py-24 md:py-32 lg:grid-cols-2")}>
|
||||
<div>
|
||||
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[1.05]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mt-6 max-w-md text-lg text-t-muted">{contact.text}</p>
|
||||
<div className="mt-8 space-y-3">
|
||||
<p className="flex items-center gap-3"><MapPinIcon className="size-4 text-t-accent" /> {brand.address}</p>
|
||||
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="flex items-center gap-3"><PhoneIcon className="size-4 text-t-accent" /> {brand.phone}</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<BookingForm booking={booking} rooms={rooms.items} onChange={update} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-t-fg/10">
|
||||
<div className={cn(container, "flex flex-col gap-4 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||
<p><span className={cn(heading, "text-lg text-t-fg")}>{brand.name}</span> · {brand.suffix}</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Nav({ name, cta }: { name: string; cta: string }) {
|
||||
const { scrolled, hidden } = useScrollNav()
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||
transition={{ duration: 0.45, ease }}
|
||||
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled ? "bg-t-bg/90 text-t-fg backdrop-blur-xl" : "text-white")}
|
||||
>
|
||||
<nav className={cn(container, "flex h-20 items-center justify-between")}>
|
||||
<a href="#top" className={cn(heading, "text-2xl")}>{name}</a>
|
||||
<a href="#buchen" className="rounded-full border border-current/40 px-5 py-2.5 text-sm transition-colors hover:border-t-accent hover:bg-t-accent hover:text-t-accent-fg">{cta}</a>
|
||||
</nav>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
|
||||
function nightsBetween(arrive: string, depart: string) {
|
||||
const nights = Math.round((Date.parse(depart) - Date.parse(arrive)) / DAY)
|
||||
return Number.isFinite(nights) && nights > 0 ? nights : 0
|
||||
}
|
||||
|
||||
function BookingBar({ booking, rooms, onChange, cta }: { booking: Booking; rooms: HotelData["rooms"]["items"]; onChange: (patch: Partial<Booking>) => void; cta: string }) {
|
||||
const nights = nightsBetween(booking.arrive, booking.depart)
|
||||
const room = rooms.find((r) => r.name === booking.room)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-3xl bg-white/12 p-2 text-left ring-1 ring-white/20 backdrop-blur-xl md:grid-cols-[1fr_1fr_0.7fr_auto]">
|
||||
{(["arrive", "depart"] as const).map((key) => (
|
||||
<label key={key} className="rounded-2xl px-4 py-2.5 hover:bg-white/10">
|
||||
<span className={cn(label, "block text-white/70")}>{key === "arrive" ? "Anreise" : "Abreise"}</span>
|
||||
<input type="date" value={booking[key]} onChange={(e) => onChange({ [key]: e.target.value })} className="w-full bg-transparent text-white outline-none [color-scheme:dark]" />
|
||||
</label>
|
||||
))}
|
||||
<label className="rounded-2xl px-4 py-2.5 hover:bg-white/10">
|
||||
<span className={cn(label, "block text-white/70")}>Gäste</span>
|
||||
<select value={booking.guests} onChange={(e) => onChange({ guests: e.target.value })} className="w-full bg-transparent text-white outline-none [&>option]:text-zinc-900">
|
||||
{["1", "2", "3", "4"].map((g) => (
|
||||
<option key={g} value={g}>{g} {g === "1" ? "Gast" : "Gäste"}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<a href="#buchen" className="flex flex-col items-center justify-center rounded-2xl bg-t-accent px-6 py-3 text-t-accent-fg">
|
||||
<span className="font-semibold">{cta}</span>
|
||||
{nights > 0 && room && <span className="text-xs opacity-80">{nights} Nächte · ab {Number(room.price) * nights} €</span>}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Rooms({ rooms, onPick }: { rooms: HotelData["rooms"]; onPick: (room: string) => void }) {
|
||||
const track = useRef<HTMLDivElement>(null)
|
||||
const scroll = (direction: number) => track.current?.scrollBy({ left: direction * track.current.clientWidth * 0.8, behavior: "smooth" })
|
||||
|
||||
return (
|
||||
<section className="py-24 md:py-32">
|
||||
<div className={cn(container, "flex items-end justify-between gap-6")}>
|
||||
<div>
|
||||
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{rooms.eyebrow}</p></Reveal>
|
||||
<RevealWords text={rooms.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
|
||||
</div>
|
||||
<div className="hidden gap-2 md:flex">
|
||||
<button type="button" aria-label="Zurück" onClick={() => scroll(-1)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-t-fg/20 hover:border-t-accent"><ChevronLeftIcon className="size-5" /></button>
|
||||
<button type="button" aria-label="Weiter" onClick={() => scroll(1)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-t-fg/20 hover:border-t-accent"><ChevronRightIcon className="size-5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={track} className="mt-12 flex snap-x snap-mandatory gap-5 overflow-x-auto px-5 pb-4 [scrollbar-width:none] md:px-[max(2.5rem,calc((100vw-1320px)/2+2.5rem))]">
|
||||
{rooms.items.map((room, i) => (
|
||||
<motion.article
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, ease, delay: i * 0.08 }}
|
||||
className="group w-[82vw] shrink-0 snap-start sm:w-[420px]"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl">
|
||||
<TImage src={room.image} alt={room.name} sizes="420px" className="transition-transform duration-[1.2s] group-hover:scale-105" />
|
||||
<span className="absolute top-4 right-4 rounded-full bg-t-bg/90 px-3 py-1 text-sm">ab {room.price} € / Nacht</span>
|
||||
</div>
|
||||
<h3 className={cn(heading, "mt-5 text-3xl")}>{room.name}</h3>
|
||||
<p className="mt-2 text-t-muted">{room.text}</p>
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span className="flex gap-4 text-t-muted">
|
||||
<span className="flex items-center gap-1.5"><MaximizeIcon className="size-4" /> {room.size}</span>
|
||||
<span className="flex items-center gap-1.5"><UsersIcon className="size-4" /> {room.guests}</span>
|
||||
</span>
|
||||
<a href="#buchen" onClick={() => onPick(room.name)} className="flex items-center gap-1.5 font-semibold text-t-accent">
|
||||
<BedDoubleIcon className="size-4" /> Wählen
|
||||
</a>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BookingForm({ booking, rooms, onChange }: { booking: Booking; rooms: HotelData["rooms"]["items"]; onChange: (patch: Partial<Booking>) => void }) {
|
||||
const [sent, setSent] = useState(false)
|
||||
const nights = nightsBetween(booking.arrive, booking.depart)
|
||||
const room = rooms.find((r) => r.name === booking.room)
|
||||
const total = room ? Number(room.price) * nights : 0
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-t-surface p-10 text-center">
|
||||
<p className={cn(heading, "text-4xl")}>Herzlichen <span className={em}>Dank!</span></p>
|
||||
<p className="mt-3 text-t-muted">Wir prüfen Ihre Anfrage und melden uns innerhalb weniger Stunden.</p>
|
||||
<DemoNote className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Anfrage direkt im Buchungssystem.</DemoNote>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 rounded-3xl bg-t-surface p-6 sm:grid-cols-2 md:p-8"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setSent(true)
|
||||
}}
|
||||
>
|
||||
<TField label="Anreise" type="date" required value={booking.arrive} onChange={(e) => onChange({ arrive: e.target.value })} />
|
||||
<TField label="Abreise" type="date" required value={booking.depart} onChange={(e) => onChange({ depart: e.target.value })} />
|
||||
<TSelect label="Zimmer" value={booking.room} onChange={(e) => onChange({ room: e.target.value })} options={rooms.map((r) => r.name)} />
|
||||
<TSelect label="Gäste" value={booking.guests} onChange={(e) => onChange({ guests: e.target.value })} options={["1", "2", "3", "4"]} />
|
||||
<TField label="Name" name="name" required autoComplete="name" />
|
||||
<TField label="E-Mail" name="email" type="email" required autoComplete="email" />
|
||||
<div className="flex items-center justify-between rounded-2xl bg-t-bg px-5 py-4 sm:col-span-2">
|
||||
<span className="text-sm text-t-muted">{nights > 0 ? `${nights} Nächte · ${booking.room}` : "Bitte Reisedaten wählen"}</span>
|
||||
<span className={cn(heading, "text-2xl")}>{total > 0 ? `${total} €` : "–"}</span>
|
||||
</div>
|
||||
<button type="submit" className="cursor-pointer rounded-full bg-t-accent py-4 font-semibold text-t-accent-fg sm:col-span-2">Unverbindlich anfragen</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
|
|||
<HeroSlider hero={hero} slides={slides.items} />
|
||||
|
||||
{show("listings") && (
|
||||
<div className={cn(container, "relative z-10 -mt-12")}>
|
||||
<div data-order="listings" className={cn(container, "relative z-10 -mt-12")}>
|
||||
<Reveal>
|
||||
<div className="grid gap-4 bg-t-bg p-5 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.45)] ring-1 ring-t-fg/10 md:grid-cols-[auto_1fr_auto] md:items-end md:p-6">
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -73,10 +73,10 @@ export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{show("listings") && <Listings listings={listings} type={type} place={place} onType={setType} onReset={() => setPlace(ALL_PLACES)} />}
|
||||
{show("listings") && <Listings data-order="listings" listings={listings} type={type} place={place} onType={setType} onReset={() => setPlace(ALL_PLACES)} />}
|
||||
|
||||
{show("about") && (
|
||||
<section id="ueber-uns" className={cn(container, "grid items-center gap-14 py-24 md:grid-cols-2 md:py-36")}>
|
||||
<section data-order="about" id="ueber-uns" className={cn(container, "grid items-center gap-14 py-24 md:grid-cols-2 md:py-36")}>
|
||||
<ParallaxImage src={about.image} offset={50} className="aspect-[4/5]" sizes="(min-width: 768px) 45vw, 100vw" />
|
||||
<div>
|
||||
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
||||
|
|
@ -94,7 +94,7 @@ export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("projects") && <ProjectIndex projects={projects} />}
|
||||
{show("projects") && <ProjectIndex data-order="projects" projects={projects} />}
|
||||
|
||||
<section id="bewertung" className="relative overflow-hidden py-24 md:py-36">
|
||||
<TImage src={valuation.image} sizes="100vw" />
|
||||
|
|
@ -130,7 +130,7 @@ export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
|
|||
</section>
|
||||
|
||||
{show("testimonial") && (
|
||||
<section className="mx-auto max-w-[1000px] px-5 py-24 text-center md:py-36">
|
||||
<section data-order="testimonial" className="mx-auto max-w-[1000px] px-5 py-24 text-center md:py-36">
|
||||
<RevealWords as="p" text={testimonial.quote} className={cn(heading, "text-[clamp(1.75rem,3.6vw,3.25rem)] leading-[1.2]")} />
|
||||
<Reveal delay={0.2}><p className={cn(label, "mt-10 text-t-muted")}>{testimonial.author}</p></Reveal>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateProps } from "../../lib/types"
|
||||
import { DemoNote } from "../shared/demo"
|
||||
import { TField } from "../shared/fields"
|
||||
import { splitList, useCountdown } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
|
|
@ -115,7 +116,7 @@ export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
|||
</section>
|
||||
|
||||
{show("trust") && (
|
||||
<section className="border-y border-t-fg/10 bg-t-surface">
|
||||
<section data-order="trust" className="border-y border-t-fg/10 bg-t-surface">
|
||||
<ul className={cn(container, "grid grid-cols-2 gap-4 py-6 md:grid-cols-4")}>
|
||||
{trust.items.map((item, i) => {
|
||||
const Icon = trustIcons[i % trustIcons.length]
|
||||
|
|
@ -130,7 +131,7 @@ export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
|||
)}
|
||||
|
||||
{show("benefits") && (
|
||||
<section className={cn(container, "py-20 md:py-28")}>
|
||||
<section data-order="benefits" className={cn(container, "py-20 md:py-28")}>
|
||||
<RevealWords text={benefits.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-2">
|
||||
{benefits.items.map((item, i) => {
|
||||
|
|
@ -153,10 +154,10 @@ export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("calculator") && <Calculator calculator={calculator} />}
|
||||
{show("calculator") && <Calculator data-order="calculator" calculator={calculator} />}
|
||||
|
||||
{show("steps") && (
|
||||
<section className={cn(container, "py-20 md:py-28")}>
|
||||
<section data-order="steps" className={cn(container, "py-20 md:py-28")}>
|
||||
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
<ol className="mt-12 grid gap-4 md:grid-cols-4">
|
||||
{steps.items.map((step, i) => (
|
||||
|
|
@ -173,7 +174,7 @@ export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
|||
)}
|
||||
|
||||
{show("reviews") && (
|
||||
<section className="bg-t-surface py-20 md:py-28">
|
||||
<section data-order="reviews" className="bg-t-surface py-20 md:py-28">
|
||||
<div className={cn(container, "text-center")}>
|
||||
<Stars className="justify-center" />
|
||||
<RevealWords text={reviews.title} emClassName={em} className={cn(heading, "mt-4 text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
|
|
@ -194,7 +195,7 @@ export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
|||
)}
|
||||
|
||||
{show("guarantee") && (
|
||||
<section className={cn(container, "py-16")}>
|
||||
<section data-order="guarantee" className={cn(container, "py-16")}>
|
||||
<Reveal>
|
||||
<div className="flex flex-col items-center gap-6 rounded-[2rem] border-2 border-dashed border-t-accent p-8 text-center md:flex-row md:text-left">
|
||||
<ShieldCheckIcon className="size-16 shrink-0 text-t-accent" />
|
||||
|
|
@ -208,7 +209,7 @@ export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
|
|||
)}
|
||||
|
||||
{show("faq") && (
|
||||
<section className={cn(container, "max-w-3xl py-16 md:py-24")}>
|
||||
<section data-order="faq" className={cn(container, "max-w-3xl py-16 md:py-24")}>
|
||||
<h2 className={cn(heading, "text-center text-[clamp(2rem,4vw,3rem)]")}>Häufige Fragen</h2>
|
||||
<Accordion items={faq.items} className="mt-10 space-y-3" itemClassName="rounded-2xl bg-t-surface px-6" questionClassName="py-5 font-semibold" answerClassName="pb-6 leading-relaxed text-t-muted" />
|
||||
</section>
|
||||
|
|
@ -299,7 +300,7 @@ function Quiz({ quiz, image }: { quiz: KampagneData["quiz"]; image: string }) {
|
|||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="py-6 text-center">
|
||||
<CircleCheckIcon className="mx-auto size-16 text-t-accent" />
|
||||
<p className={cn(heading, "mt-4 text-2xl")}>{quiz.success}</p>
|
||||
<p className="mt-4 text-sm opacity-60">Demo-Hinweis: Live landet die Anfrage direkt im CRM bzw. Postfach.</p>
|
||||
<DemoNote className="mt-4 text-sm opacity-60">Demo-Hinweis: Live landet die Anfrage direkt im CRM bzw. Postfach.</DemoNote>
|
||||
</motion.div>
|
||||
) : (
|
||||
<StepPanel step={step}>
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ export function KanzleiTemplate({ content }: TemplateProps<KanzleiData>) {
|
|||
</dl>
|
||||
</section>
|
||||
|
||||
{show("areas") && <Areas areas={areas} />}
|
||||
{show("areas") && <Areas data-order="areas" areas={areas} />}
|
||||
|
||||
{show("about") && (
|
||||
<section id="ueber-uns" className={cn(container, "grid gap-14 py-24 md:grid-cols-12 md:gap-10 md:py-40")}>
|
||||
<section data-order="about" id="ueber-uns" className={cn(container, "grid gap-14 py-24 md:grid-cols-12 md:gap-10 md:py-40")}>
|
||||
<div className="md:col-span-5">
|
||||
<div className="md:sticky md:top-36">
|
||||
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
||||
|
|
@ -113,7 +113,7 @@ export function KanzleiTemplate({ content }: TemplateProps<KanzleiData>) {
|
|||
)}
|
||||
|
||||
{show("team") && (
|
||||
<section id="team" className="border-t border-t-fg/10 py-24 md:py-40">
|
||||
<section data-order="team" id="team" className="border-t border-t-fg/10 py-24 md:py-40">
|
||||
<div className={container}>
|
||||
<Reveal><Eyebrow>{team.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||
|
|
@ -141,7 +141,7 @@ export function KanzleiTemplate({ content }: TemplateProps<KanzleiData>) {
|
|||
)}
|
||||
|
||||
{show("steps") && (
|
||||
<section className="bg-t-fg py-24 text-t-bg md:py-36">
|
||||
<section data-order="steps" className="bg-t-fg py-24 text-t-bg md:py-36">
|
||||
<div className={container}>
|
||||
<Reveal><Eyebrow className="text-t-bg/60">{steps.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
|
||||
|
|
@ -166,7 +166,7 @@ export function KanzleiTemplate({ content }: TemplateProps<KanzleiData>) {
|
|||
)}
|
||||
|
||||
{show("quotes") && (
|
||||
<section className={cn(container, "grid gap-14 py-24 md:grid-cols-2 md:gap-20 md:py-36")}>
|
||||
<section data-order="quotes" className={cn(container, "grid gap-14 py-24 md:grid-cols-2 md:gap-20 md:py-36")}>
|
||||
{quotes.items.map((quote, i) => (
|
||||
<Reveal key={i} delay={i * 0.15}>
|
||||
<figure className="border-t border-t-fg/15 pt-10">
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} 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 { splitList } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
|
|
@ -52,7 +53,7 @@ export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
|
|||
<Hero hero={hero} openJobs={jobs.items.length} />
|
||||
|
||||
{show("benefits") && (
|
||||
<section id="benefits" className={cn(container, "py-20 md:py-28")}>
|
||||
<section data-order="benefits" id="benefits" className={cn(container, "py-20 md:py-28")}>
|
||||
<Reveal><Eyebrow>{benefits.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={benefits.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -79,7 +80,7 @@ export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
|
|||
)}
|
||||
|
||||
{show("facts") && (
|
||||
<section className="bg-t-fg py-14 text-t-bg">
|
||||
<section data-order="facts" className="bg-t-fg py-14 text-t-bg">
|
||||
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
|
||||
{facts.items.map((fact, i) => (
|
||||
<div key={i}>
|
||||
|
|
@ -97,7 +98,7 @@ export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
|
|||
<Jobs jobs={jobs} onApply={setJob} />
|
||||
|
||||
{show("voices") && (
|
||||
<section id="team" className="bg-t-surface py-20 md:py-28">
|
||||
<section data-order="voices" id="team" className="bg-t-surface py-20 md:py-28">
|
||||
<div className={container}>
|
||||
<Reveal><Eyebrow>{voices.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={voices.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||
|
|
@ -124,7 +125,7 @@ export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
|
|||
)}
|
||||
|
||||
{show("process") && (
|
||||
<section className={cn(container, "py-20 md:py-28")}>
|
||||
<section data-order="process" className={cn(container, "py-20 md:py-28")}>
|
||||
<Reveal><Eyebrow>{process.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={process.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
|
||||
<ol className="relative mt-14 grid gap-10 md:grid-cols-4 md:gap-6">
|
||||
|
|
@ -361,7 +362,7 @@ function Apply({ apply, jobs, job, onJob }: { apply: KarriereData["apply"]; jobs
|
|||
})}
|
||||
<p className={cn(heading, "text-4xl")}>Stark! 🎉</p>
|
||||
<p className="mt-3 text-t-muted">Wir melden uns innerhalb von 48 Stunden bei dir.</p>
|
||||
<p className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Bewerbung direkt im Postfach der Personalabteilung.</p>
|
||||
<DemoNote className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Bewerbung direkt im Postfach der Personalabteilung.</DemoNote>
|
||||
</div>
|
||||
) : (
|
||||
<StepPanel step={current}>
|
||||
|
|
|
|||
76
modules/templates/catalog/linkinbio/definition.ts
Normal file
76
modules/templates/catalog/linkinbio/definition.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||
|
||||
const defaults = {
|
||||
brand: {
|
||||
name: "Mila Sommer",
|
||||
bio: "Food & Travel Creator · Rezepte aus aller Welt · Berlin",
|
||||
avatar: unsplash("1524504388940-b1c1722653e1", 400),
|
||||
},
|
||||
socials: {
|
||||
items: [
|
||||
{ name: "Instagram", handle: "@milasommer", count: "248 Tsd." },
|
||||
{ name: "TikTok", handle: "@mila.kocht", count: "512 Tsd." },
|
||||
{ name: "YouTube", handle: "Mila Sommer", count: "96 Tsd." },
|
||||
{ name: "Pinterest", handle: "milasommer", count: "40 Tsd." },
|
||||
],
|
||||
},
|
||||
featured: {
|
||||
label: "Neu",
|
||||
title: "Mein Kochbuch „Fernweh auf dem Teller“",
|
||||
text: "Jetzt vorbestellen – mit signierter Erstausgabe.",
|
||||
image: unsplash("1490645935967-10de6ba17061", 1000),
|
||||
url: "#",
|
||||
},
|
||||
links: {
|
||||
items: [
|
||||
{ title: "Neues Video: 5 Pasta-Rezepte in 15 Minuten", tag: "YouTube", url: "#" },
|
||||
{ title: "Meine Reise-Guides für Italien", tag: "Blog", url: "#" },
|
||||
{ title: "Lieblings-Küchenhelfer", tag: "Shop · Werbung", url: "#" },
|
||||
{ title: "Kooperationen & Presse", tag: "Kontakt", url: "#" },
|
||||
],
|
||||
},
|
||||
gallery: {
|
||||
title: "Zuletzt gepostet",
|
||||
images: [
|
||||
{ src: unsplash("1488646953014-85cb44e25828", 600) },
|
||||
{ src: unsplash("1476514525535-07fb3b4ae5f1", 600) },
|
||||
{ src: unsplash("1507525428034-b723cf961d3e", 600) },
|
||||
{ src: unsplash("1530789253388-582c481c54b0", 600) },
|
||||
{ src: unsplash("1512621776951-a57141f2eefd", 600) },
|
||||
{ src: unsplash("1501594907352-04cda38ebc29", 600) },
|
||||
],
|
||||
},
|
||||
newsletter: { title: "Rezepte per Mail", text: "Jeden Sonntag ein neues Lieblingsrezept – kostenlos.", cta: "Abonnieren" },
|
||||
}
|
||||
|
||||
export const linkinbio = defineTemplate({
|
||||
key: "linkinbio",
|
||||
name: "Bio-Link",
|
||||
industry: "Link-in-Bio für Social Media",
|
||||
description: "Eine Seite für den Link im Instagram- oder TikTok-Profil: Profil, Kanäle mit Reichweite, Highlight-Karte, Links, Bilder und Newsletter – mobil optimiert.",
|
||||
cover: unsplash("1488646953014-85cb44e25828", 1200),
|
||||
samples: {
|
||||
inquiry: "Hi Mila, wir sind ein Hersteller von Gusseisenpfannen und würden gern eine Kooperation für drei Reels im November anfragen. Was wären deine Konditionen? Liebe Grüße, Tom vom Team Ferrum",
|
||||
topic: "Neues Reel: Shakshuka in 10 Minuten",
|
||||
},
|
||||
palettes: [
|
||||
{ key: "sunset", label: "Sunset", bg: "#fff4ec", fg: "#2a1a12", muted: "#8a6b5a", surface: "#ffe3d1", accent: "#ff6b35" },
|
||||
{ key: "ozean", label: "Ozean", dark: true, bg: "#08131f", fg: "#e8f3ff", muted: "#8aa3bd", surface: "#102236", accent: "#46c2ff" },
|
||||
{ key: "lavendel", label: "Lavendel", bg: "#f5f0ff", fg: "#24163d", muted: "#776a94", surface: "#e8dcff", accent: "#8b5cf6" },
|
||||
],
|
||||
fonts: [
|
||||
{ key: "modern", label: "Urbanist" },
|
||||
{ key: "retro", label: "Righteous & Urbanist" },
|
||||
],
|
||||
sections: describeSections(defaults, {
|
||||
brand: "Profil",
|
||||
socials: { label: "Kanäle", optional: true, items: { items: "Kanal" } },
|
||||
featured: { label: "Highlight", optional: true },
|
||||
links: { label: "Links", items: { items: "Link" } },
|
||||
gallery: { label: "Bilder", optional: true, items: { images: "Bild" } },
|
||||
newsletter: { label: "Newsletter", optional: true },
|
||||
}),
|
||||
defaults,
|
||||
})
|
||||
|
||||
export type LinkinbioData = typeof linkinbio.defaults
|
||||
11
modules/templates/catalog/linkinbio/fonts.ts
Normal file
11
modules/templates/catalog/linkinbio/fonts.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Righteous, Urbanist } from "next/font/google"
|
||||
import type { FontPair } from "../../lib/types"
|
||||
|
||||
// preload: false – siehe restaurant/fonts.ts
|
||||
const urbanist = Urbanist({ subsets: ["latin"], variable: "--font-bio-urbanist", preload: false })
|
||||
const righteous = Righteous({ subsets: ["latin"], weight: "400", variable: "--font-bio-righteous", preload: false })
|
||||
|
||||
export const fonts: Record<string, FontPair> = {
|
||||
modern: { className: urbanist.variable, display: "var(--font-bio-urbanist)", body: "var(--font-bio-urbanist)" },
|
||||
retro: { className: `${righteous.variable} ${urbanist.variable}`, display: "var(--font-bio-righteous)", body: "var(--font-bio-urbanist)" },
|
||||
}
|
||||
126
modules/templates/catalog/linkinbio/template.tsx
Normal file
126
modules/templates/catalog/linkinbio/template.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { ArrowUpRightIcon, BadgeCheckIcon, MailIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateProps } from "../../lib/types"
|
||||
import { DemoForm } from "../shared/demo-form"
|
||||
import { TImage } from "../shared/media"
|
||||
import { ease } from "../shared/motion"
|
||||
import { TemplateShell } from "../shared/shell"
|
||||
import { linkinbio, type LinkinbioData } from "./definition"
|
||||
import { fonts } from "./fonts"
|
||||
|
||||
const column = "relative mx-auto w-full max-w-md px-5"
|
||||
const heading = "font-t-display font-bold tracking-[-0.02em]"
|
||||
|
||||
const appear = (i: number) => ({
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.6, ease, delay: 0.15 + i * 0.07 },
|
||||
})
|
||||
|
||||
export function LinkinbioTemplate({ content }: TemplateProps<LinkinbioData>) {
|
||||
const { brand, socials, featured, links, gallery, newsletter } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
|
||||
return (
|
||||
<TemplateShell def={linkinbio} content={content} fonts={fonts[content.fonts] ?? fonts.modern} className="pb-20">
|
||||
<div aria-hidden className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||
<motion.div className="absolute -top-32 -left-32 size-96 rounded-full bg-t-accent/30 blur-3xl" animate={{ x: [0, 80, 0], y: [0, 60, 0] }} transition={{ duration: 16, repeat: Infinity, ease: "easeInOut" }} />
|
||||
<motion.div className="absolute -right-32 bottom-0 size-[28rem] rounded-full bg-t-accent/20 blur-3xl" animate={{ x: [0, -60, 0], y: [0, -80, 0] }} transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }} />
|
||||
</div>
|
||||
|
||||
<header className={cn(column, "pt-14 text-center")}>
|
||||
<motion.div initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ type: "spring", bounce: 0.4 }} className="relative mx-auto size-28">
|
||||
<div className="absolute -inset-1.5 rounded-full bg-[conic-gradient(from_0deg,var(--t-accent),transparent,var(--t-accent))] animate-[spin_6s_linear_infinite]" />
|
||||
<div className="relative size-full overflow-hidden rounded-full border-4 border-t-bg">
|
||||
<TImage src={brand.avatar} alt={brand.name} eager sizes="112px" />
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.h1 {...appear(0)} className={cn(heading, "mt-5 flex items-center justify-center gap-1.5 text-3xl")}>
|
||||
{brand.name} <BadgeCheckIcon className="size-6 text-t-accent" />
|
||||
</motion.h1>
|
||||
<motion.p {...appear(1)} className="mx-auto mt-2 max-w-xs text-t-muted">{brand.bio}</motion.p>
|
||||
</header>
|
||||
|
||||
{show("socials") && (
|
||||
<motion.section data-order="socials" {...appear(2)} className={cn(column, "mt-6 flex flex-wrap justify-center gap-2")}>
|
||||
{socials.items.map((social) => (
|
||||
<a key={social.name} href="#" className="rounded-full bg-t-surface px-4 py-2 text-sm font-semibold transition-transform hover:-translate-y-0.5">
|
||||
{social.name} <span className="font-normal text-t-muted">· {social.count}</span>
|
||||
</a>
|
||||
))}
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
{show("featured") && (
|
||||
<motion.section data-order="featured" {...appear(3)} className={cn(column, "mt-8")}>
|
||||
<a href={featured.url || "#"} className="group block overflow-hidden rounded-3xl bg-t-surface shadow-lg ring-1 ring-t-fg/5">
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<TImage src={featured.image} alt={featured.title} sizes="448px" className="transition-transform duration-700 group-hover:scale-105" />
|
||||
<span className="absolute top-3 left-3 rounded-full bg-t-accent px-3 py-1 text-xs font-bold text-t-accent-fg">{featured.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-5">
|
||||
<div className="flex-1">
|
||||
<p className={cn(heading, "text-lg leading-snug")}>{featured.title}</p>
|
||||
<p className="mt-1 text-sm text-t-muted">{featured.text}</p>
|
||||
</div>
|
||||
<ArrowUpRightIcon className="size-5 shrink-0 transition-transform group-hover:rotate-45" />
|
||||
</div>
|
||||
</a>
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
<section className={cn(column, "mt-4 grid gap-3")}>
|
||||
{links.items.map((link, i) => (
|
||||
<motion.a
|
||||
key={i}
|
||||
href={link.url || "#"}
|
||||
{...appear(4 + i)}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center gap-4 rounded-2xl border-2 border-t-fg/10 bg-t-bg/80 px-5 py-4 backdrop-blur transition-colors hover:border-t-accent"
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="block font-semibold leading-snug">{link.title}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-t-accent">{link.tag}</span>
|
||||
</span>
|
||||
<ArrowUpRightIcon className="size-5 shrink-0 text-t-muted" />
|
||||
</motion.a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{show("gallery") && (
|
||||
<section data-order="gallery" className={cn(column, "mt-10")}>
|
||||
<p className={cn(heading, "mb-3 text-center")}>{gallery.title}</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 overflow-hidden rounded-2xl">
|
||||
{gallery.images.map((image, i) => (
|
||||
<motion.div key={i} {...appear(8 + i)} className="group relative aspect-square overflow-hidden">
|
||||
<TImage src={image.src} sizes="150px" className="transition-transform duration-500 group-hover:scale-110" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("newsletter") && (
|
||||
<section data-order="newsletter" className={cn(column, "mt-10")}>
|
||||
<DemoForm className="rounded-3xl bg-t-fg p-6 text-t-bg" success={<p className={cn(heading, "py-4 text-center text-xl")}>Danke! Bis Sonntag in deinem Postfach.</p>}>
|
||||
<p className={cn(heading, "flex items-center gap-2 text-xl")}><MailIcon className="size-5 text-t-accent" /> {newsletter.title}</p>
|
||||
<p className="mt-1 text-sm opacity-70">{newsletter.text}</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input type="email" required placeholder="deine@mail.de" aria-label="E-Mail-Adresse" className="min-w-0 flex-1 rounded-full bg-t-bg/10 px-4 py-3 text-sm outline-none placeholder:text-t-bg/50 focus:ring-2 focus:ring-t-accent" />
|
||||
<button type="submit" className="cursor-pointer rounded-full bg-t-accent px-5 text-sm font-bold text-t-accent-fg">{newsletter.cta}</button>
|
||||
</div>
|
||||
</DemoForm>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className={cn(column, "mt-10 flex justify-center gap-5 text-xs text-t-muted")}>
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</footer>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
123
modules/templates/catalog/nutzfahrzeuge/definition.ts
Normal file
123
modules/templates/catalog/nutzfahrzeuge/definition.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||
|
||||
const defaults = {
|
||||
brand: { name: "Brandl", suffix: "Nutzfahrzeuge", phone: "089 123 456 78", email: "verkauf@brandl-nutzfahrzeuge.de", logo: "" },
|
||||
hero: {
|
||||
eyebrow: "Transporter · Pickup · LKW · Sattelzug",
|
||||
title: "Nutzfahrzeuge,\ndie *liefern.*",
|
||||
text: "Über 120 geprüfte Transporter und LKW sofort verfügbar – mit Finanzierung, Leasing und eigener Meisterwerkstatt.",
|
||||
cta: "Bestand ansehen",
|
||||
ctaSecondary: "Fahrzeug verkaufen",
|
||||
image: unsplash("1592838064575-70ed626d3a0e"),
|
||||
},
|
||||
stats: {
|
||||
items: [
|
||||
{ value: "120", suffix: "+", label: "Fahrzeuge auf Lager" },
|
||||
{ value: "28", suffix: "", label: "Jahre Erfahrung" },
|
||||
{ value: "24", suffix: " h", label: "bis zum Ankaufsangebot" },
|
||||
{ value: "2", suffix: "", label: "Standorte in Bayern" },
|
||||
],
|
||||
},
|
||||
vehicles: {
|
||||
eyebrow: "Bestand",
|
||||
title: "Sofort *verfügbar.*",
|
||||
text: "",
|
||||
link: "",
|
||||
linkLabel: "Gesamten Bestand ansehen",
|
||||
rate: "4,9",
|
||||
items: [
|
||||
{ title: "Camper-Van, voll ausgebaut", type: "Transporter", condition: "Gebraucht", badge: "Top-Angebot", price: "44.900", year: "2019", km: "88.000", power: "140 PS", gearbox: "Automatik", payload: "900 kg", image: unsplash("1527786356703-4b100091cd2c", 1200) },
|
||||
{ title: "Pickup Doppelkabine 4x4", type: "Pickup", condition: "Jahreswagen", badge: "", price: "38.500", year: "2024", km: "15.000", power: "204 PS", gearbox: "Automatik", payload: "1.000 kg", image: unsplash("1559416523-140ddc3d238c", 1200) },
|
||||
{ title: "LKW 7,5 t Koffer mit Ladebordwand", type: "LKW", condition: "Gebraucht", badge: "", price: "54.900", year: "2020", km: "145.000", power: "220 PS", gearbox: "Automatik", payload: "2.900 kg", image: unsplash("1616432043562-3671ea2e5242", 1200) },
|
||||
{ title: "Sattelzugmaschine 450 PS", type: "Sattelzug", condition: "Gebraucht", badge: "Neu eingetroffen", price: "79.900", year: "2021", km: "310.000", power: "450 PS", gearbox: "Automatik", payload: "18 t zGG", image: unsplash("1601584115197-04ecc0da31d7", 1200) },
|
||||
{ title: "Kipper 3-Achser", type: "LKW", condition: "Gebraucht", badge: "", price: "99.000", year: "2019", km: "180.000", power: "420 PS", gearbox: "Automatik", payload: "15.000 kg", image: unsplash("1519003722824-194d4455a60c", 1200) },
|
||||
{ title: "Fernverkehrs-Sattelzug Euro 6", type: "Sattelzug", condition: "Gebraucht", badge: "", price: "89.500", year: "2022", km: "240.000", power: "500 PS", gearbox: "Automatik", payload: "18 t zGG", image: unsplash("1591768793355-74d04bb6608f", 1200) },
|
||||
{ title: "Bulli-Oldtimer, restauriert", type: "Transporter", condition: "Gebraucht", badge: "Liebhaberstück", price: "59.900", year: "1967", km: "12.000", power: "44 PS", gearbox: "Schaltgetriebe", payload: "750 kg", image: unsplash("1561361513-2d000a50f0dc", 1200) },
|
||||
{ title: "Kleinbus 9-Sitzer", type: "Transporter", condition: "Gebraucht", badge: "", price: "31.900", year: "2018", km: "96.000", power: "150 PS", gearbox: "Schaltgetriebe", payload: "800 kg", image: unsplash("1515876305430-f06edab8282a", 1200) },
|
||||
],
|
||||
},
|
||||
financing: {
|
||||
eyebrow: "Finanzierung & Leasing",
|
||||
title: "Monatlich statt\n*auf einmal.*",
|
||||
text: "Finanzierung ab 0 € Anzahlung und gewerbliches Leasing mit planbaren Raten – Zusage meist am selben Tag.",
|
||||
},
|
||||
purchase: {
|
||||
eyebrow: "Ankauf",
|
||||
title: "Wir kaufen\nIhr *Fahrzeug.*",
|
||||
text: "Faires Angebot innerhalb von 24 Stunden, Abholung deutschlandweit und sofortige Auszahlung – auch mit Restfinanzierung.",
|
||||
steps: "Daten senden, Angebot in 24 h, Abholung & Auszahlung",
|
||||
image: unsplash("1592805144716-feeccccef5ac", 1600),
|
||||
},
|
||||
about: {
|
||||
eyebrow: "Über uns",
|
||||
title: "Ein Händler,\nder *zuhört.*",
|
||||
text: "Seit 1998 vermitteln wir Nutzfahrzeuge an Handwerk, Logistik und Mittelstand. Wir verkaufen nur Fahrzeuge, die wir selbst fahren würden.\n\nUnd auch nach dem Kauf bleiben wir Ihr persönlicher Ansprechpartner – für Service, Ersatz oder das nächste Fahrzeug.",
|
||||
values: "Ehrlich, Zuverlässig, Persönlich",
|
||||
image: unsplash("1610647752706-3bb12232b3ab", 1400),
|
||||
},
|
||||
services: {
|
||||
eyebrow: "Service",
|
||||
title: "Alles rund ums *Fahrzeug.*",
|
||||
image: unsplash("1504222490345-c075b6008014", 1400),
|
||||
items: [
|
||||
{ title: "Meisterwerkstatt", text: "Wartung, Reparatur und Inspektion für alle Marken – auch am Samstag." },
|
||||
{ title: "Finanzierung & Leasing", text: "Flexible Laufzeiten und gewerbliche Leasingangebote." },
|
||||
{ title: "Zulassung & Überführung", text: "Wir melden an und liefern bis vor Ihr Firmengelände." },
|
||||
{ title: "Aufbauten & Umbauten", text: "Regalsysteme, Kühlaufbauten, Ladebordwände und Beschriftung." },
|
||||
{ title: "HU/AU-Service", text: "Prüfung direkt bei uns im Haus, ohne Wartezeit." },
|
||||
{ title: "Garantie", text: "Bis zu 24 Monate Gebrauchtwagengarantie auf alle Fahrzeuge." },
|
||||
],
|
||||
},
|
||||
testimonials: {
|
||||
items: [
|
||||
{ quote: "Drei Transporter in einer Woche – inklusive Regalausbau und Beschriftung. So schnell war noch kein Händler.", author: "Sandra Meier", role: "Meier Gartenbau" },
|
||||
{ quote: "Unseren alten Sattelzug haben sie fair angekauft und den neuen gleich mitfinanziert. Alles aus einer Hand.", author: "Thomas Huber", role: "Huber Logistik GmbH" },
|
||||
{ quote: "Die Werkstatt ist Gold wert: Ausfälle werden sofort erledigt, oft mit Leihfahrzeug.", author: "Ali Demir", role: "Kurierdienst Demir" },
|
||||
],
|
||||
},
|
||||
locations: {
|
||||
eyebrow: "Standorte",
|
||||
title: "Besuchen Sie *uns.*",
|
||||
text: "",
|
||||
items: [
|
||||
{ name: "Garching (Hauptsitz)", address: "Gewerbering 12, 85748 Garching", hours: "Mo–Fr 7–18 Uhr · Sa 9–13 Uhr", phone: "089 123 456 78" },
|
||||
{ name: "Augsburg", address: "Industriestraße 4, 86167 Augsburg", hours: "Mo–Fr 8–17 Uhr", phone: "0821 123 456 7" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const nutzfahrzeuge = defineTemplate({
|
||||
key: "nutzfahrzeuge",
|
||||
name: "Fleet",
|
||||
industry: "Autohandel: Vans & Trucks",
|
||||
description: "Für Nutzfahrzeughändler: Bestand mit Schnellsuche, Filtern und Sortierung, Detail-Panel mit Ratenrechner, Finanzierung, Ankauf, Werkstatt und Standorte.",
|
||||
cover: unsplash("1601584115197-04ecc0da31d7", 1200),
|
||||
samples: {
|
||||
inquiry: "Guten Tag, wir sind ein Malerbetrieb und suchen zwei Transporter (Hochdach, möglichst mit Regalausbau) bis maximal 35.000 € pro Fahrzeug. Bieten Sie auch Leasing an, und können Sie unseren alten Kastenwagen in Zahlung nehmen? Viele Grüße, Markus Lehner",
|
||||
topic: "Neu eingetroffen: Sattelzugmaschine 450 PS zum Top-Preis",
|
||||
},
|
||||
palettes: [
|
||||
{ key: "asphalt", label: "Asphalt", dark: true, bg: "#101214", fg: "#eef0f2", muted: "#8a9099", surface: "#1a1d21", accent: "#ffb800" },
|
||||
{ key: "werkhalle", label: "Werkhalle", bg: "#f2f3f5", fg: "#111418", muted: "#5c636e", surface: "#e3e6ea", accent: "#e4572e" },
|
||||
{ key: "flotte", label: "Flotte", dark: true, bg: "#0b1626", fg: "#eaf0f7", muted: "#8597ad", surface: "#13233a", accent: "#33c3ff" },
|
||||
],
|
||||
fonts: [
|
||||
{ key: "technik", label: "Chakra Petch & Saira" },
|
||||
{ key: "kraft", label: "Oswald & Archivo Narrow" },
|
||||
],
|
||||
sections: describeSections(defaults, {
|
||||
brand: "Händler",
|
||||
hero: "Startbereich",
|
||||
stats: { label: "Zahlen", optional: true, items: { items: "Kennzahl" } },
|
||||
vehicles: { label: "Fahrzeugbestand", items: { items: "Fahrzeug" } },
|
||||
financing: { label: "Finanzierungsrechner", optional: true },
|
||||
purchase: { label: "Ankauf", optional: true },
|
||||
about: { label: "Über uns", optional: true },
|
||||
services: { label: "Service", optional: true, items: { items: "Leistung" } },
|
||||
testimonials: { label: "Kundenstimmen", optional: true, items: { items: "Stimme" } },
|
||||
locations: { label: "Standorte & Kontakt", items: { items: "Standort" } },
|
||||
}),
|
||||
defaults,
|
||||
})
|
||||
|
||||
export type NutzfahrzeugeData = typeof nutzfahrzeuge.defaults
|
||||
13
modules/templates/catalog/nutzfahrzeuge/fonts.ts
Normal file
13
modules/templates/catalog/nutzfahrzeuge/fonts.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Archivo_Narrow, Chakra_Petch, Oswald, Saira } from "next/font/google"
|
||||
import type { FontPair } from "../../lib/types"
|
||||
|
||||
// preload: false – siehe restaurant/fonts.ts
|
||||
const chakra = Chakra_Petch({ subsets: ["latin"], weight: ["500", "600", "700"], variable: "--font-nfz-chakra", preload: false })
|
||||
const saira = Saira({ subsets: ["latin"], variable: "--font-nfz-saira", preload: false })
|
||||
const oswald = Oswald({ subsets: ["latin"], variable: "--font-nfz-oswald", preload: false })
|
||||
const archivoNarrow = Archivo_Narrow({ subsets: ["latin"], variable: "--font-nfz-narrow", preload: false })
|
||||
|
||||
export const fonts: Record<string, FontPair> = {
|
||||
technik: { className: `${chakra.variable} ${saira.variable}`, display: "var(--font-nfz-chakra)", body: "var(--font-nfz-saira)" },
|
||||
kraft: { className: `${oswald.variable} ${archivoNarrow.variable}`, display: "var(--font-nfz-oswald)", body: "var(--font-nfz-narrow)" },
|
||||
}
|
||||
642
modules/templates/catalog/nutzfahrzeuge/template.tsx
Normal file
642
modules/templates/catalog/nutzfahrzeuge/template.tsx
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
BanknoteIcon,
|
||||
CalendarIcon,
|
||||
FileCheckIcon,
|
||||
GaugeIcon,
|
||||
HammerIcon,
|
||||
MapPinIcon,
|
||||
PhoneIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SlidersHorizontalIcon,
|
||||
TruckIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} 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 { TImage } from "../shared/media"
|
||||
import { Counter, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
||||
import { TemplateShell } from "../shared/shell"
|
||||
import { nutzfahrzeuge, type NutzfahrzeugeData } from "./definition"
|
||||
import { fonts } from "./fonts"
|
||||
|
||||
type Vehicle = NutzfahrzeugeData["vehicles"]["items"][number]
|
||||
|
||||
const em = "text-t-accent"
|
||||
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-8"
|
||||
const display = "font-t-display font-bold uppercase tracking-[-0.01em]"
|
||||
const serviceIcons = [WrenchIcon, BanknoteIcon, FileCheckIcon, HammerIcon, ShieldCheckIcon, ShieldCheckIcon]
|
||||
const ALL = "Alle"
|
||||
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })
|
||||
const SORTS = { price: "Preis aufsteigend", priceDesc: "Preis absteigend", km: "Kilometerstand", year: "Neueste zuerst" } as const
|
||||
|
||||
const num = (value: string) => Number(value.replace(/\./g, "").replace(",", ".")) || 0
|
||||
|
||||
// Annuität: Rate = Betrag · i / (1 − (1 + i)^−n)
|
||||
function monthlyRate(amount: number, ratePercent: number, months: number) {
|
||||
const i = ratePercent / 100 / 12
|
||||
if (amount <= 0) return 0
|
||||
return i === 0 ? amount / months : (amount * i) / (1 - (1 + i) ** -months)
|
||||
}
|
||||
|
||||
function Eyebrow({ children }: { children: string }) {
|
||||
return (
|
||||
<p className="mb-5 flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.25em] text-t-accent">
|
||||
<span className="h-0.5 w-8 bg-t-accent" />
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function NutzfahrzeugeTemplate({ content }: TemplateProps<NutzfahrzeugeData>) {
|
||||
const { brand, hero, stats, vehicles, financing, purchase, about, services, testimonials, locations } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const rate = num(vehicles.rate) || 4.9
|
||||
const types = [ALL, ...new Set(vehicles.items.map((v) => v.type))]
|
||||
const [type, setType] = useState(ALL)
|
||||
const [maxPrice, setMaxPrice] = useState(0)
|
||||
const [selected, setSelected] = useState<Vehicle | null>(null)
|
||||
const links = [
|
||||
{ href: "#bestand", label: "Bestand", visible: true },
|
||||
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
|
||||
{ href: "#finanzierung", label: "Finanzierung", visible: show("financing") },
|
||||
{ href: "#ankauf", label: "Ankauf", visible: show("purchase") },
|
||||
{ href: "#standorte", label: locations.eyebrow, visible: true },
|
||||
].filter((link) => link.visible)
|
||||
|
||||
return (
|
||||
<TemplateShell def={nutzfahrzeuge} content={content} fonts={fonts[content.fonts] ?? fonts.technik}>
|
||||
<Nav brand={brand} links={links} />
|
||||
|
||||
<section id="top" className="relative flex min-h-svh flex-col justify-end overflow-hidden">
|
||||
<motion.div className="absolute inset-0" initial={{ scale: 1.15 }} animate={{ scale: 1 }} transition={{ duration: 2, ease }}>
|
||||
<TImage src={hero.image} eager />
|
||||
</motion.div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-t-bg via-t-bg/75 to-t-bg/10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-transparent to-transparent" />
|
||||
<motion.div
|
||||
aria-hidden
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: "0%" }}
|
||||
transition={{ duration: 1.2, ease, delay: 0.2 }}
|
||||
className="absolute top-0 bottom-0 left-[38%] hidden w-24 -skew-x-12 bg-t-accent/15 lg:block"
|
||||
/>
|
||||
<div className={cn(container, "relative pt-36 pb-10")}>
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.7, ease, delay: 0.2 }}>
|
||||
<Eyebrow>{hero.eyebrow}</Eyebrow>
|
||||
</motion.div>
|
||||
<RevealWords as="h1" onMount delay={0.3} text={hero.title} emClassName={em} className={cn(display, "max-w-4xl text-[clamp(3rem,8vw,7.5rem)] leading-[0.92]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.8 }}>
|
||||
<p className="mt-7 max-w-xl text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a href="#bestand" className="inline-flex items-center gap-2 bg-t-accent px-7 py-4 font-bold text-t-accent-fg uppercase transition-transform hover:-translate-y-0.5">
|
||||
{hero.cta} <ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
{show("purchase") && (
|
||||
<a href="#ankauf" className="inline-flex items-center gap-2 border border-t-fg/30 px-7 py-4 font-bold uppercase transition-colors hover:border-t-accent hover:text-t-accent">
|
||||
{hero.ctaSecondary}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease, delay: 1.1 }}
|
||||
className={cn("mt-12 grid gap-3 border border-t-fg/10 bg-t-surface/90 p-4 backdrop-blur md:grid-cols-[1fr_1fr_auto] md:items-end", vehicles.items.length === 0 && "hidden")}
|
||||
>
|
||||
<TSelect label="Fahrzeugtyp" variant="glass" value={type} onChange={(e) => setType(e.target.value)} options={types} />
|
||||
<TSelect
|
||||
label="Preis bis"
|
||||
variant="glass"
|
||||
value={maxPrice ? euro.format(maxPrice) : "Beliebig"}
|
||||
onChange={(e) => setMaxPrice(num(e.target.value.replace(/[^\d]/g, "")))}
|
||||
options={["Beliebig", ...[40000, 60000, 80000, 100000].map((v) => euro.format(v))]}
|
||||
/>
|
||||
<a href="#bestand" className="flex items-center justify-center gap-2 bg-t-accent px-6 py-3.5 font-bold text-t-accent-fg uppercase">
|
||||
<SlidersHorizontalIcon className="size-4" /> Fahrzeuge finden
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show("stats") && (
|
||||
<section data-order="stats" className="bg-t-accent py-10 text-t-accent-fg">
|
||||
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
|
||||
{stats.items.map((stat, i) => (
|
||||
<div key={i}>
|
||||
<dd className={cn(display, "text-5xl")}>
|
||||
<Counter value={stat.value} />
|
||||
{stat.suffix}
|
||||
</dd>
|
||||
<dt className="mt-1 text-sm font-medium opacity-80">{stat.label}</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Inventory vehicles={vehicles} rate={rate} types={types} type={type} maxPrice={maxPrice} onType={setType} onReset={() => setMaxPrice(0)} onSelect={setSelected} />
|
||||
|
||||
{show("financing") && <Financing data={financing} rate={rate} />}
|
||||
|
||||
{show("purchase") && (
|
||||
<section data-order="purchase" id="ankauf" className="relative overflow-hidden py-24 md:py-32">
|
||||
<TImage src={purchase.image} sizes="100vw" />
|
||||
<div className="absolute inset-0 bg-t-bg/85" />
|
||||
<div className={cn(container, "relative grid items-center gap-12 lg:grid-cols-2")}>
|
||||
<div>
|
||||
<Reveal><Eyebrow>{purchase.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={purchase.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5rem)] leading-[0.95]")} />
|
||||
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg text-t-fg/80">{purchase.text}</p></Reveal>
|
||||
<ol className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{splitList(purchase.steps).map((step, i) => (
|
||||
<li key={step} className="border-l-2 border-t-accent pl-3 text-sm">
|
||||
<span className={cn(display, "block text-2xl text-t-accent")}>0{i + 1}</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<DemoForm
|
||||
className="grid gap-4 bg-t-surface p-6 sm:grid-cols-2 md:p-8"
|
||||
success={<p className={cn(display, "py-8 text-3xl")}>Danke! Ihr Angebot kommt <span className={em}>in 24 Stunden.</span></p>}
|
||||
>
|
||||
<TField label="Marke & Modell" name="model" required variant="glass" className="sm:col-span-2" />
|
||||
<TField label="Erstzulassung" name="year" inputMode="numeric" variant="glass" />
|
||||
<TField label="Kilometerstand" name="km" inputMode="numeric" variant="glass" />
|
||||
<TField label="Name" name="name" required autoComplete="name" variant="glass" />
|
||||
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="glass" />
|
||||
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Kostenloses Angebot anfordern</button>
|
||||
</DemoForm>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("about") && (
|
||||
<section data-order="about" id="ueber-uns" className={cn(container, "grid items-center gap-12 py-24 md:py-32 lg:grid-cols-2")}>
|
||||
<Reveal>
|
||||
<div className="relative aspect-[4/5] overflow-hidden">
|
||||
<TImage src={about.image} sizes="(min-width: 1024px) 45vw, 100vw" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-t-accent" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<div>
|
||||
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={about.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-6 space-y-4 text-lg leading-relaxed text-t-muted">
|
||||
{splitList(about.text, /\n+/).map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
<ul className="mt-8 flex flex-wrap gap-2">
|
||||
{splitList(about.values).map((value) => (
|
||||
<li key={value} className="flex items-center gap-2 border border-t-fg/15 px-4 py-2 text-sm font-semibold uppercase tracking-wider">
|
||||
<ShieldCheckIcon className="size-4 text-t-accent" /> {value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("services") && (
|
||||
<section data-order="services" className={cn(container, "grid gap-12 py-24 md:py-32 lg:grid-cols-[1fr_1.3fr]")}>
|
||||
<div>
|
||||
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={services.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<div className="relative mt-8 aspect-[4/3] overflow-hidden">
|
||||
<TImage src={services.image} sizes="(min-width: 1024px) 40vw, 100vw" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="grid gap-px self-end bg-t-fg/10 sm:grid-cols-2">
|
||||
{services.items.map((item, i) => {
|
||||
const Icon = serviceIcons[i % serviceIcons.length]
|
||||
return (
|
||||
<Reveal key={i} delay={(i % 2) * 0.08} className={cn("bg-t-bg p-6", i === services.items.length - 1 && i % 2 === 0 && "sm:col-span-2")}>
|
||||
<Icon className="size-7 text-t-accent" />
|
||||
<h3 className={cn(display, "mt-4 text-xl")}>{item.title}</h3>
|
||||
<p className="mt-2 text-sm text-t-muted">{item.text}</p>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("testimonials") && (
|
||||
<section data-order="testimonials" className="bg-t-surface py-20">
|
||||
<div className={cn(container, "grid gap-5 md:grid-cols-3")}>
|
||||
{testimonials.items.map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<figure className="h-full border-t-2 border-t-accent bg-t-bg p-6">
|
||||
<blockquote className="leading-relaxed">„{item.quote}“</blockquote>
|
||||
<figcaption className="mt-5 text-sm">
|
||||
<span className="font-bold">{item.author}</span> <span className="text-t-muted">· {item.role}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="standorte" className={cn(container, "py-24")}>
|
||||
<Reveal><Eyebrow>{locations.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={locations.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
|
||||
{locations.text && (
|
||||
<Reveal delay={0.1}><p className="mt-6 max-w-2xl text-lg leading-relaxed text-t-muted">{locations.text}</p></Reveal>
|
||||
)}
|
||||
<div className="mt-10 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{locations.items.map((location, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<div className="flex h-full flex-col gap-3 border border-t-fg/10 p-6 md:p-8">
|
||||
<h3 className={cn(display, "text-2xl")}>{location.name}</h3>
|
||||
<p className="flex items-center gap-2 text-t-muted"><MapPinIcon className="size-4 shrink-0 text-t-accent" /> {location.address}</p>
|
||||
<p className="flex items-center gap-2 text-t-muted"><CalendarIcon className="size-4 shrink-0 text-t-accent" /> {location.hours}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<a href={`tel:${location.phone.replace(/\s/g, "")}`} className="flex items-center gap-2 font-bold"><PhoneIcon className="size-4 text-t-accent" /> {location.phone}</a>
|
||||
<a
|
||||
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(location.address)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm font-semibold text-t-fg/70 uppercase transition-colors hover:text-t-accent"
|
||||
>
|
||||
Route planen <ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
<Reveal delay={0.2} className="flex-1">
|
||||
<div className="flex h-full flex-col justify-end gap-1 bg-t-accent p-6 text-t-accent-fg md:p-8">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.25em]">Direkt erreichen</p>
|
||||
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className={cn(display, "text-[clamp(1.75rem,3vw,2.5rem)] [line-height:1.1]")}>{brand.phone}</a>
|
||||
<a href={`mailto:${brand.email}`} className="break-all font-semibold hover:underline">{brand.email}</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<DemoForm
|
||||
className="grid gap-4 bg-t-surface p-6 sm:grid-cols-2 md:p-8"
|
||||
success={<p className={cn(display, "py-8 text-3xl")}>Danke! Wir melden uns <span className={em}>schnellstmöglich.</span></p>}
|
||||
>
|
||||
<p className={cn(display, "text-2xl sm:col-span-2")}>Anfrage senden</p>
|
||||
<TField label="Name" name="name" required autoComplete="name" variant="glass" />
|
||||
<TField label="Telefon" name="phone" type="tel" autoComplete="tel" variant="glass" />
|
||||
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="glass" />
|
||||
<TSelect label="Anliegen" name="topic" variant="glass" options={["Frage zu einem Fahrzeug", "Besichtigungstermin", "Fahrzeug verkaufen", "Sonstiges"]} />
|
||||
<TArea label="Nachricht" name="message" variant="glass" className="sm:col-span-2" />
|
||||
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Absenden</button>
|
||||
</DemoForm>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-t-fg/10">
|
||||
<div className={cn(container, "flex flex-col gap-4 py-8 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||
<p>{brand.name} {brand.suffix} · {brand.email}</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<Drawer vehicle={selected} rate={rate} onClose={() => setSelected(null)} />
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Nav({ brand, links }: { brand: NutzfahrzeugeData["brand"]; links: { href: string; label: string }[] }) {
|
||||
const { scrolled, hidden } = useScrollNav()
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||
transition={{ duration: 0.45, ease }}
|
||||
className={cn("fixed inset-x-0 top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent")}
|
||||
>
|
||||
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
|
||||
{brand.logo ? (
|
||||
<a href="#top" aria-label={brand.name} className="relative block h-14 w-24">
|
||||
<TImage src={brand.logo} alt={brand.name} sizes="96px" eager className="object-contain object-left" />
|
||||
</a>
|
||||
) : (
|
||||
<a href="#top" className="flex items-center gap-3">
|
||||
<span className="grid size-10 place-items-center bg-t-accent text-t-accent-fg"><TruckIcon className="size-5" /></span>
|
||||
<span className="leading-none">
|
||||
<span className={cn(display, "block text-xl")}>{brand.name}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.25em] text-t-muted">{brand.suffix}</span>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
<div className="hidden items-center gap-8 text-sm font-semibold uppercase tracking-wider lg:flex">
|
||||
{links.map((link) => (
|
||||
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">{link.label}</a>
|
||||
))}
|
||||
</div>
|
||||
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="flex items-center gap-2 bg-t-fg px-4 py-2.5 text-sm font-bold text-t-bg">
|
||||
<PhoneIcon className="size-4" /> <span className="hidden sm:inline">{brand.phone}</span>
|
||||
</a>
|
||||
</nav>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
|
||||
function Inventory({ vehicles, rate, types, type, maxPrice, onType, onReset, onSelect }: {
|
||||
vehicles: NutzfahrzeugeData["vehicles"]
|
||||
rate: number
|
||||
types: string[]
|
||||
type: string
|
||||
maxPrice: number
|
||||
onType: (type: string) => void
|
||||
onReset: () => void
|
||||
onSelect: (vehicle: Vehicle) => void
|
||||
}) {
|
||||
const [sort, setSort] = useState<keyof typeof SORTS>("price")
|
||||
const visible = vehicles.items
|
||||
.filter((v) => (type === ALL || v.type === type) && (!maxPrice || num(v.price) <= maxPrice))
|
||||
.sort((a, b) =>
|
||||
sort === "price" ? num(a.price) - num(b.price) : sort === "priceDesc" ? num(b.price) - num(a.price) : sort === "km" ? num(a.km) - num(b.km) : num(b.year) - num(a.year)
|
||||
)
|
||||
|
||||
const intro = (
|
||||
<div>
|
||||
<Reveal><Eyebrow>{vehicles.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={vehicles.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92]")} />
|
||||
{vehicles.text && (
|
||||
<Reveal delay={0.1}><p className="mt-5 max-w-2xl text-lg leading-relaxed text-t-muted">{vehicles.text}</p></Reveal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Ohne eigenen Bestand (z. B. alles auf mobile.de) zeigt die Sektion nur den Weg zum Bestand
|
||||
if (vehicles.items.length === 0) {
|
||||
return (
|
||||
<section id="bestand" className={cn(container, "py-24 md:py-32")}>
|
||||
{intro}
|
||||
<Reveal delay={0.15}>
|
||||
<StockLink vehicles={vehicles} large />
|
||||
</Reveal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="bestand" className={cn(container, "py-24 md:py-32")}>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
{intro}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<span className="text-t-muted">Sortieren</span>
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value as keyof typeof SORTS)} className="border border-t-fg/15 bg-t-surface px-3 py-2.5 outline-none [&>option]:text-zinc-900">
|
||||
{Object.entries(SORTS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{types.map((option) => {
|
||||
const count = option === ALL ? vehicles.items.length : vehicles.items.filter((v) => v.type === option).length
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={type === option}
|
||||
onClick={() => onType(option)}
|
||||
className={cn("cursor-pointer border px-4 py-2.5 text-sm font-bold uppercase transition-colors", type === option ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15 hover:border-t-fg")}
|
||||
>
|
||||
{option} <span className="opacity-60">({count})</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-6 text-sm text-t-muted">
|
||||
{visible.length} Fahrzeuge gefunden
|
||||
{maxPrice > 0 && (
|
||||
<>
|
||||
{" "}bis {euro.format(maxPrice)} ·{" "}
|
||||
<button type="button" onClick={onReset} className="cursor-pointer text-t-accent hover:underline">Preisfilter entfernen</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<motion.div layout className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visible.map((vehicle) => (
|
||||
<motion.article
|
||||
layout
|
||||
key={vehicle.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease }}
|
||||
className="group flex flex-col border border-t-fg/10 bg-t-surface transition-colors hover:border-t-accent"
|
||||
>
|
||||
<button type="button" onClick={() => onSelect(vehicle)} className="relative aspect-[4/3] cursor-pointer overflow-hidden text-left">
|
||||
<TImage src={vehicle.image} alt={vehicle.title} sizes="(min-width: 1280px) 25vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
|
||||
{vehicle.badge && <span className="absolute top-3 left-3 bg-t-accent px-2.5 py-1 text-xs font-bold text-t-accent-fg uppercase">{vehicle.badge}</span>}
|
||||
<span className="absolute right-3 bottom-3 bg-t-bg/85 px-2.5 py-1 text-xs font-semibold backdrop-blur">{vehicle.condition}</span>
|
||||
</button>
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-t-accent">{vehicle.type}</p>
|
||||
<h3 className="mt-1 text-lg leading-snug font-semibold">{vehicle.title}</h3>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs text-t-muted">
|
||||
<div className="flex items-center gap-1.5"><CalendarIcon className="size-3.5" /> EZ {vehicle.year}</div>
|
||||
<div className="flex items-center gap-1.5"><GaugeIcon className="size-3.5" /> {vehicle.km} km</div>
|
||||
<div className="flex items-center gap-1.5"><TruckIcon className="size-3.5" /> {vehicle.power}</div>
|
||||
<div className="flex items-center gap-1.5"><SettingsIcon className="size-3.5" /> {vehicle.gearbox}</div>
|
||||
</dl>
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-5">
|
||||
<div>
|
||||
<p className={cn(display, "text-2xl")}>{vehicle.price} €</p>
|
||||
<p className="text-xs text-t-muted">ab {euro.format(monthlyRate(num(vehicle.price) * 0.8, rate, 60))} / Monat</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onSelect(vehicle)} className="grid size-11 cursor-pointer place-items-center bg-t-accent text-t-accent-fg">
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
{vehicles.link && <StockLink vehicles={vehicles} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StockLink({ vehicles, large }: { vehicles: NutzfahrzeugeData["vehicles"]; large?: boolean }) {
|
||||
let host = ""
|
||||
try {
|
||||
host = new URL(vehicles.link).hostname.replace(/^www\./, "").replace(/^home\./, "")
|
||||
} catch {
|
||||
host = ""
|
||||
}
|
||||
const external = host !== ""
|
||||
|
||||
return (
|
||||
<a
|
||||
href={external ? vehicles.link : "#standorte"}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noreferrer" : undefined}
|
||||
className={cn(
|
||||
"group mt-10 flex items-center justify-between gap-6 border border-t-fg/10 bg-t-surface transition-colors hover:border-t-accent",
|
||||
large ? "p-8 md:p-12" : "p-5 md:p-6"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<span className={cn(display, "block", large ? "text-3xl md:text-5xl" : "text-xl md:text-2xl")}>{vehicles.linkLabel}</span>
|
||||
<span className="mt-1 block text-t-muted">{external ? `Aktueller Bestand auf ${host}` : "Sprechen Sie uns an – wir finden Ihr Wunschfahrzeug."}</span>
|
||||
</span>
|
||||
<span className={cn("grid shrink-0 place-items-center bg-t-accent text-t-accent-fg", large ? "size-16" : "size-12")}>
|
||||
<ArrowUpRightIcon className="size-6 transition-transform duration-500 group-hover:rotate-45" />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function RateCalculator({ price, rate, compact }: { price: number; rate: number; compact?: boolean }) {
|
||||
const [down, setDown] = useState(20)
|
||||
const [months, setMonths] = useState(48)
|
||||
const amount = price * (1 - down / 100)
|
||||
const monthly = monthlyRate(amount, rate, months)
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-5", !compact && "md:grid-cols-2 md:gap-10")}>
|
||||
<div className="grid gap-5">
|
||||
<label className="block text-sm">
|
||||
<span className="flex justify-between text-t-muted">Anzahlung <span className="font-semibold text-t-fg">{down} % · {euro.format(price * (down / 100))}</span></span>
|
||||
<input type="range" min={0} max={50} step={5} value={down} onChange={(e) => setDown(Number(e.target.value))} className="mt-2 w-full cursor-pointer accent-[var(--t-accent)]" />
|
||||
</label>
|
||||
<div className="text-sm">
|
||||
<p className="text-t-muted">Laufzeit</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{[24, 36, 48, 60].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={months === option}
|
||||
onClick={() => setMonths(option)}
|
||||
className={cn("cursor-pointer py-2 font-semibold transition-colors", months === option ? "bg-t-accent text-t-accent-fg" : "bg-t-fg/5 hover:bg-t-fg/10")}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center border-l-4 border-t-accent bg-t-fg/5 p-5">
|
||||
<p className="text-sm text-t-muted">Ihre monatliche Rate</p>
|
||||
<motion.p key={Math.round(monthly)} initial={{ opacity: 0.4, y: 6 }} animate={{ opacity: 1, y: 0 }} className="font-t-display text-5xl font-bold text-t-accent">
|
||||
{euro.format(monthly)}
|
||||
</motion.p>
|
||||
<p className="mt-2 text-xs text-t-muted">
|
||||
{months} Monate · {rate.toLocaleString("de-DE")} % Sollzins p. a. · Finanzierungsbetrag {euro.format(amount)}. Unverbindliches Rechenbeispiel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Financing({ data, rate }: { data: NutzfahrzeugeData["financing"]; rate: number }) {
|
||||
const [price, setPrice] = useState(50000)
|
||||
|
||||
return (
|
||||
<section data-order="financing" id="finanzierung" className="bg-t-surface py-24 md:py-32">
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-[1fr_1.4fr]")}>
|
||||
<div>
|
||||
<Reveal><Eyebrow>{data.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={data.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
|
||||
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg text-t-muted">{data.text}</p></Reveal>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<div className="grid gap-6 bg-t-bg p-6 md:p-8">
|
||||
<label className="block text-sm">
|
||||
<span className="flex justify-between text-t-muted">Fahrzeugpreis <span className={cn(display, "text-2xl text-t-fg")}>{euro.format(price)}</span></span>
|
||||
<input type="range" min={10000} max={150000} step={1000} value={price} onChange={(e) => setPrice(Number(e.target.value))} className="mt-2 w-full cursor-pointer accent-[var(--t-accent)]" />
|
||||
</label>
|
||||
<RateCalculator price={price} rate={rate} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Drawer({ vehicle, rate, onClose }: { vehicle: Vehicle | null; rate: number; onClose: () => void }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{vehicle && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} className="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm" />
|
||||
<motion.aside
|
||||
role="dialog"
|
||||
aria-label={vehicle.title}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: "0%" }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.45, ease }}
|
||||
className="fixed inset-y-0 right-0 z-[90] flex w-full max-w-xl flex-col overflow-y-auto bg-t-bg text-t-fg shadow-2xl"
|
||||
>
|
||||
<div className="relative aspect-[16/10] shrink-0">
|
||||
<TImage src={vehicle.image} alt={vehicle.title} sizes="576px" />
|
||||
<button type="button" aria-label="Schließen" onClick={onClose} className="absolute top-4 right-4 grid size-10 cursor-pointer place-items-center bg-t-bg/90 text-t-fg">
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-6 p-6 md:p-8">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-t-accent">{vehicle.type} · {vehicle.condition}</p>
|
||||
<h2 className={cn(display, "mt-2 text-3xl leading-tight")}>{vehicle.title}</h2>
|
||||
<p className={cn(display, "mt-3 text-4xl text-t-accent")}>{vehicle.price} €</p>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-px bg-t-fg/10 text-sm">
|
||||
{[
|
||||
["Erstzulassung", vehicle.year],
|
||||
["Kilometerstand", `${vehicle.km} km`],
|
||||
["Leistung", vehicle.power],
|
||||
["Getriebe", vehicle.gearbox],
|
||||
["Nutzlast / zGG", vehicle.payload],
|
||||
["Zustand", vehicle.condition],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="bg-t-bg p-3">
|
||||
<dt className="text-xs text-t-muted">{label}</dt>
|
||||
<dd className="font-semibold">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div>
|
||||
<p className={cn(display, "mb-4 text-xl")}>Finanzierung berechnen</p>
|
||||
<RateCalculator price={num(vehicle.price)} rate={rate} compact />
|
||||
</div>
|
||||
<DemoForm className="grid gap-3 sm:grid-cols-2" success={<p className="text-lg font-semibold">Danke! Wir melden uns heute noch für die Probefahrt.</p>}>
|
||||
<p className={cn(display, "text-xl sm:col-span-2")}>Probefahrt anfragen</p>
|
||||
<TField label="Name" name="name" required autoComplete="name" />
|
||||
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" />
|
||||
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Termin anfragen</button>
|
||||
</DemoForm>
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
111
modules/templates/catalog/pflege/definition.ts
Normal file
111
modules/templates/catalog/pflege/definition.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||
|
||||
const defaults = {
|
||||
brand: {
|
||||
name: "Herzenshand",
|
||||
suffix: "Ambulanter Pflegedienst",
|
||||
phone: "0231 123 456 78",
|
||||
email: "kontakt@herzenshand-pflege.de",
|
||||
address: "Kaiserstraße 40, 44135 Dortmund",
|
||||
emergency: "Rund um die Uhr erreichbar",
|
||||
},
|
||||
hero: {
|
||||
title: "Pflege mit *Herz*,\nbei Ihnen zu Hause.",
|
||||
text: "Wir unterstützen Sie und Ihre Familie mit erfahrenen Pflegekräften, festen Bezugspersonen und viel Zeit für das Menschliche.",
|
||||
cta: "Kostenlos beraten lassen",
|
||||
image: unsplash("1576765974257-b414b9ea0051", 1400),
|
||||
areas: "441, 442, 443, 444, 58239",
|
||||
},
|
||||
services: {
|
||||
title: "Unsere *Leistungen*",
|
||||
items: [
|
||||
{ title: "Grundpflege", text: "Hilfe beim Waschen, Anziehen und bei der Körperpflege – würdevoll und in Ihrem Tempo." },
|
||||
{ title: "Behandlungspflege", text: "Medikamente, Verbandswechsel, Injektionen und Wundversorgung nach ärztlicher Verordnung." },
|
||||
{ title: "Hauswirtschaft", text: "Einkaufen, Kochen, Wäsche und ein sauberes Zuhause." },
|
||||
{ title: "Demenzbetreuung", text: "Geduldige Begleitung, Beschäftigung und Entlastung für Angehörige." },
|
||||
{ title: "Verhinderungspflege", text: "Wir springen ein, wenn pflegende Angehörige eine Pause brauchen." },
|
||||
{ title: "Pflegeberatung", text: "Beratungsbesuche nach § 37.3 SGB XI und Hilfe bei allen Anträgen." },
|
||||
],
|
||||
},
|
||||
budget: {
|
||||
title: "Was die *Pflegekasse* übernimmt",
|
||||
text: "Ab Pflegegrad 2 haben Sie Anspruch auf Pflegesachleistungen. Wir rechnen direkt mit Ihrer Kasse ab – Sie müssen sich um nichts kümmern.",
|
||||
note: "Monatliche Höchstbeträge für Pflegesachleistungen. Bitte vor Veröffentlichung auf aktuelle Werte prüfen.",
|
||||
items: [
|
||||
{ grade: "Pflegegrad 2", amount: "796" },
|
||||
{ grade: "Pflegegrad 3", amount: "1.497" },
|
||||
{ grade: "Pflegegrad 4", amount: "1.859" },
|
||||
{ grade: "Pflegegrad 5", amount: "2.299" },
|
||||
],
|
||||
},
|
||||
steps: {
|
||||
title: "So einfach *starten* Sie",
|
||||
items: [
|
||||
{ title: "Anrufen", text: "Wir hören zu und beantworten Ihre ersten Fragen." },
|
||||
{ title: "Kennenlernen", text: "Kostenloser Hausbesuch mit ausführlicher Beratung." },
|
||||
{ title: "Planen", text: "Gemeinsam erstellen wir Ihren persönlichen Pflegeplan." },
|
||||
{ title: "Loslegen", text: "Ihr festes Team kommt zu Ihnen nach Hause." },
|
||||
],
|
||||
},
|
||||
team: {
|
||||
title: "Menschen, denen Sie *vertrauen* können",
|
||||
text: "Unsere 34 Pflegekräfte sind examiniert, regelmäßig geschult und kommen aus der Nachbarschaft. Sie sehen jeden Tag vertraute Gesichter – keine wechselnden Fremden.",
|
||||
image: unsplash("1631815589968-fdb09a223b1e", 1400),
|
||||
members: [
|
||||
{ name: "Sabine Krüger", role: "Pflegedienstleitung", image: unsplash("1573497019940-1c28c88b4f3e", 600) },
|
||||
{ name: "Aylin Yildiz", role: "Pflegefachkraft", image: unsplash("1580489944761-15a19d654956", 600) },
|
||||
{ name: "Daniel Becker", role: "Pflegefachkraft", image: unsplash("1607990281513-2c110a25bd8c", 600) },
|
||||
{ name: "Monika Hahn", role: "Betreuungskraft", image: unsplash("1438761681033-6461ffad8d80", 600) },
|
||||
],
|
||||
},
|
||||
testimonials: {
|
||||
items: [
|
||||
{ quote: "Seit Herzenshand meine Mutter betreut, kann ich wieder ruhig schlafen. Alle sind herzlich, pünktlich und immer erreichbar.", author: "Petra W., Tochter einer Patientin" },
|
||||
{ quote: "Ich wollte unbedingt zu Hause bleiben. Dank der täglichen Besuche geht das – und ich freue mich jedes Mal auf Frau Yildiz.", author: "Herbert S., 84 Jahre" },
|
||||
],
|
||||
},
|
||||
jobs: {
|
||||
title: "Wir suchen *Verstärkung*",
|
||||
text: "Pflegefachkräfte (m/w/d) in Voll- und Teilzeit – mit Dienstwagen, fairen Touren und echter Wertschätzung.",
|
||||
cta: "Zu den Stellen",
|
||||
},
|
||||
contact: {
|
||||
title: "Wir sind für Sie *da.*",
|
||||
text: "Rufen Sie uns an oder hinterlassen Sie eine Rückrufbitte – wir melden uns noch am selben Tag.",
|
||||
},
|
||||
}
|
||||
|
||||
export const pflege = defineTemplate({
|
||||
key: "pflege",
|
||||
name: "Herzenshand",
|
||||
industry: "Pflege & Betreuung",
|
||||
description: "Warm und barrierearm: Schriftgrößen-Umschalter, Notruf-Leiste, Postleitzahl-Check fürs Einzugsgebiet, Pflegekassen-Rechner, Team und Rückrufbitte.",
|
||||
cover: unsplash("1576765974257-b414b9ea0051", 1200),
|
||||
samples: {
|
||||
inquiry: "Guten Tag, mein Vater (82) hat Pflegegrad 3 bekommen und lebt allein in Dortmund-Hörde. Er braucht morgens Hilfe beim Waschen und Anziehen und jemanden, der seine Medikamente stellt. Können Sie das übernehmen? Viele Grüße, Claudia Neumann",
|
||||
topic: "Entlastung für pflegende Angehörige: Was die Pflegekasse zahlt",
|
||||
},
|
||||
palettes: [
|
||||
{ key: "herz", label: "Herz", bg: "#fffaf5", fg: "#2a1f1a", muted: "#5e5048", surface: "#fbeee2", accent: "#c94a2f" },
|
||||
{ key: "vertrauen", label: "Vertrauen", bg: "#f5f8fb", fg: "#10243d", muted: "#4a5a6e", surface: "#e5edf5", accent: "#1f62c4" },
|
||||
{ key: "natur", label: "Natur", bg: "#f4f8f2", fg: "#1d2e1f", muted: "#4f6150", surface: "#e3eedf", accent: "#2f7a3a" },
|
||||
],
|
||||
fonts: [
|
||||
{ key: "lesbar", label: "Atkinson Hyperlegible" },
|
||||
{ key: "freundlich", label: "Lexend & Atkinson Hyperlegible" },
|
||||
],
|
||||
sections: describeSections(defaults, {
|
||||
brand: "Pflegedienst & Kontakt",
|
||||
hero: "Startbereich",
|
||||
services: { label: "Leistungen", items: { items: "Leistung" } },
|
||||
budget: { label: "Pflegekassen-Rechner", optional: true, items: { items: "Pflegegrad" } },
|
||||
steps: { label: "Ablauf", optional: true, items: { items: "Schritt" } },
|
||||
team: { label: "Team", optional: true, items: { members: "Person" } },
|
||||
testimonials: { label: "Stimmen", optional: true, items: { items: "Stimme" } },
|
||||
jobs: { label: "Stellen-Hinweis", optional: true },
|
||||
contact: "Rückrufbitte",
|
||||
}),
|
||||
defaults,
|
||||
})
|
||||
|
||||
export type PflegeData = typeof pflege.defaults
|
||||
11
modules/templates/catalog/pflege/fonts.ts
Normal file
11
modules/templates/catalog/pflege/fonts.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Atkinson_Hyperlegible, Lexend } from "next/font/google"
|
||||
import type { FontPair } from "../../lib/types"
|
||||
|
||||
// preload: false – siehe restaurant/fonts.ts. Beide Schriften sind für gute Lesbarkeit entwickelt.
|
||||
const atkinson = Atkinson_Hyperlegible({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-pflege-atkinson", preload: false })
|
||||
const lexend = Lexend({ subsets: ["latin"], variable: "--font-pflege-lexend", preload: false })
|
||||
|
||||
export const fonts: Record<string, FontPair> = {
|
||||
lesbar: { className: atkinson.variable, display: "var(--font-pflege-atkinson)", body: "var(--font-pflege-atkinson)" },
|
||||
freundlich: { className: `${lexend.variable} ${atkinson.variable}`, display: "var(--font-pflege-lexend)", body: "var(--font-pflege-atkinson)" },
|
||||
}
|
||||
315
modules/templates/catalog/pflege/template.tsx
Normal file
315
modules/templates/catalog/pflege/template.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import { ArrowRightIcon, BrainIcon, CheckIcon, ClipboardCheckIcon, HandHeartIcon, HeartHandshakeIcon, HouseIcon, MapPinIcon, PhoneCallIcon, PillIcon, UsersIcon, XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateProps } from "../../lib/types"
|
||||
import { DemoForm } from "../shared/demo-form"
|
||||
import { TField, TSelect } from "../shared/fields"
|
||||
import { splitList } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
import { Reveal, RevealWords, ease } from "../shared/motion"
|
||||
import { TemplateShell } from "../shared/shell"
|
||||
import { pflege, type PflegeData } from "./definition"
|
||||
import { fonts } from "./fonts"
|
||||
|
||||
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-bold tracking-[-0.015em]"
|
||||
const icons = [HandHeartIcon, PillIcon, HouseIcon, BrainIcon, UsersIcon, ClipboardCheckIcon]
|
||||
// Schriftgröße per zoom, damit alles mitwächst (auch Abstände und feste Leisten)
|
||||
const SCALES = [
|
||||
{ label: "A", className: "" },
|
||||
{ label: "A+", className: "[zoom:1.15]" },
|
||||
{ label: "A++", className: "[zoom:1.3]" },
|
||||
]
|
||||
|
||||
export function PflegeTemplate({ content }: TemplateProps<PflegeData>) {
|
||||
const { brand, hero, services, budget, steps, team, testimonials, jobs, contact } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const [scale, setScale] = useState(0)
|
||||
const tel = `tel:${brand.phone.replace(/\s/g, "")}`
|
||||
|
||||
return (
|
||||
<TemplateShell def={pflege} content={content} fonts={fonts[content.fonts] ?? fonts.lesbar} className={cn("text-[17px]", SCALES[scale].className)}>
|
||||
<div className="bg-t-accent py-2.5 text-center font-bold text-t-accent-fg">
|
||||
<a href={tel} className="inline-flex items-center gap-2">
|
||||
<PhoneCallIcon className="size-5" /> {brand.emergency}: {brand.phone}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<header className="sticky top-0 z-50 border-b border-t-fg/10 bg-t-bg/95 backdrop-blur">
|
||||
<nav className={cn(container, "flex h-20 items-center justify-between gap-4")}>
|
||||
<a href="#top" className="flex items-center gap-3">
|
||||
<span className="grid size-11 place-items-center rounded-2xl bg-t-accent text-t-accent-fg"><HeartHandshakeIcon className="size-6" /></span>
|
||||
<span className="leading-tight">
|
||||
<span className={cn(heading, "block text-xl")}>{brand.name}</span>
|
||||
<span className="text-sm text-t-muted">{brand.suffix}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
<div role="group" aria-label="Schriftgröße" className="flex rounded-full border-2 border-t-fg/15 p-1">
|
||||
{SCALES.map((option, i) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
aria-pressed={scale === i}
|
||||
aria-label={`Schriftgröße ${option.label}`}
|
||||
onClick={() => setScale(i)}
|
||||
className={cn("cursor-pointer rounded-full px-2.5 py-1 font-bold transition-colors", scale === i ? "bg-t-fg text-t-bg" : "hover:bg-t-fg/5")}
|
||||
style={{ fontSize: `${0.8 + i * 0.15}rem` }}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<a href="#kontakt" className="hidden rounded-full bg-t-fg px-5 py-3 font-bold text-t-bg md:block">{hero.cta}</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="top" className={cn(container, "grid items-center gap-12 py-14 md:py-20 lg:grid-cols-2")}>
|
||||
<div>
|
||||
<RevealWords as="h1" onMount delay={0.1} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5.5vw,4.5rem)] leading-[1.08]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.5 }}>
|
||||
<p className="mt-6 text-xl leading-relaxed text-t-muted">{hero.text}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a href="#kontakt" className="inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 text-lg font-bold text-t-accent-fg">
|
||||
{hero.cta} <ArrowRightIcon className="size-5" />
|
||||
</a>
|
||||
<a href={tel} className="inline-flex items-center gap-2 rounded-full border-2 border-t-fg px-7 py-4 text-lg font-bold">
|
||||
<PhoneCallIcon className="size-5" /> Anrufen
|
||||
</a>
|
||||
</div>
|
||||
<AreaCheck areas={hero.areas} />
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.9, ease }} className="relative">
|
||||
<div className="relative aspect-[4/3.6] overflow-hidden rounded-[2rem]">
|
||||
<TImage src={hero.image} eager sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</div>
|
||||
{["Feste Bezugspersonen", "Alle Pflegekassen"].map((badge, i) => (
|
||||
<span
|
||||
key={badge}
|
||||
className={cn("absolute flex items-center gap-2 rounded-2xl bg-t-bg px-4 py-3 font-bold shadow-xl", i === 0 ? "-bottom-5 left-4 md:-left-6" : "top-6 -right-2 md:-right-6")}
|
||||
>
|
||||
<CheckIcon className="size-5 text-t-accent" /> {badge}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section id="leistungen" className="bg-t-surface py-20 md:py-28">
|
||||
<div className={container}>
|
||||
<RevealWords text={services.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.5rem)]")} />
|
||||
<div className="mt-12 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{services.items.map((item, i) => {
|
||||
const Icon = icons[i % icons.length]
|
||||
return (
|
||||
<Reveal key={i} delay={(i % 3) * 0.08}>
|
||||
<article className="h-full rounded-3xl bg-t-bg p-7 ring-2 ring-transparent transition-shadow hover:ring-t-accent">
|
||||
<span className="grid size-14 place-items-center rounded-2xl bg-t-surface text-t-accent"><Icon className="size-7" /></span>
|
||||
<h3 className={cn(heading, "mt-5 text-2xl")}>{item.title}</h3>
|
||||
<p className="mt-2 text-lg leading-relaxed text-t-muted">{item.text}</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show("budget") && <Budget data-order="budget" budget={budget} />}
|
||||
|
||||
{show("steps") && (
|
||||
<section data-order="steps" className={cn(container, "py-20 md:py-28")}>
|
||||
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.5rem)]")} />
|
||||
<ol className="mt-12 grid gap-6 md:grid-cols-4">
|
||||
{steps.items.map((step, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<li className="text-center">
|
||||
<span className={cn(heading, "mx-auto grid size-16 place-items-center rounded-full bg-t-accent text-2xl text-t-accent-fg")}>{i + 1}</span>
|
||||
<p className={cn(heading, "mt-4 text-xl")}>{step.title}</p>
|
||||
<p className="mt-1 text-lg text-t-muted">{step.text}</p>
|
||||
</li>
|
||||
</Reveal>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("team") && (
|
||||
<section data-order="team" className="bg-t-surface py-20 md:py-28">
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-2 lg:items-center")}>
|
||||
<div>
|
||||
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.1]")} />
|
||||
<Reveal delay={0.1}><p className="mt-6 text-xl leading-relaxed text-t-muted">{team.text}</p></Reveal>
|
||||
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{team.members.map((member, i) => (
|
||||
<Reveal key={i} delay={i * 0.06} className="text-center">
|
||||
<div className="relative mx-auto aspect-square w-full overflow-hidden rounded-full">
|
||||
<TImage src={member.image} alt={member.name} sizes="140px" />
|
||||
</div>
|
||||
<p className="mt-2 font-bold leading-tight">{member.name}</p>
|
||||
<p className="text-sm text-t-muted">{member.role}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<div className="relative aspect-[4/3] overflow-hidden rounded-[2rem]">
|
||||
<TImage src={team.image} sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("testimonials") && (
|
||||
<section data-order="testimonials" className={cn(container, "grid gap-6 py-20 md:grid-cols-2 md:py-28")}>
|
||||
{testimonials.items.map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<figure className="h-full rounded-3xl border-2 border-t-fg/10 p-8">
|
||||
<blockquote className="text-2xl leading-snug">„{item.quote}“</blockquote>
|
||||
<figcaption className="mt-6 font-bold text-t-accent">{item.author}</figcaption>
|
||||
</figure>
|
||||
</Reveal>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("jobs") && (
|
||||
<section data-order="jobs" className={cn(container, "pb-20")}>
|
||||
<div className="flex flex-col items-start gap-6 rounded-[2rem] bg-t-fg p-8 text-t-bg md:flex-row md:items-center md:justify-between md:p-12">
|
||||
<div>
|
||||
<RevealWords text={jobs.title} emClassName={em} className={cn(heading, "text-3xl md:text-4xl")} />
|
||||
<p className="mt-3 max-w-xl text-lg opacity-80">{jobs.text}</p>
|
||||
</div>
|
||||
<a href="#kontakt" className="shrink-0 rounded-full bg-t-accent px-7 py-4 text-lg font-bold text-t-accent-fg">{jobs.cta}</a>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="kontakt" className="bg-t-surface py-20 md:py-28">
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-2")}>
|
||||
<div>
|
||||
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<p className="mt-5 text-xl text-t-muted">{contact.text}</p>
|
||||
<a href={tel} className={cn(heading, "mt-8 flex items-center gap-3 text-3xl text-t-accent md:text-4xl")}>
|
||||
<PhoneCallIcon className="size-8" /> {brand.phone}
|
||||
</a>
|
||||
<p className="mt-4 flex items-center gap-2 text-lg"><MapPinIcon className="size-5 text-t-accent" /> {brand.address}</p>
|
||||
</div>
|
||||
<DemoForm
|
||||
className="grid gap-5 rounded-3xl bg-t-bg p-6 md:p-9"
|
||||
success={<p className={cn(heading, "py-8 text-3xl")}>Vielen Dank! Wir rufen Sie <span className={em}>heute noch</span> zurück.</p>}
|
||||
>
|
||||
<p className={cn(heading, "text-2xl")}>Rückruf erbitten</p>
|
||||
<TSelect label="Worum geht es?" name="topic" options={["Pflege für mich", "Pflege für Angehörige", "Beratung", "Bewerbung"]} />
|
||||
<TField label="Ihr Name" name="name" required autoComplete="name" />
|
||||
<TField label="Telefonnummer" name="phone" type="tel" required autoComplete="tel" />
|
||||
<TSelect label="Wann sind Sie erreichbar?" name="time" options={["Vormittags", "Nachmittags", "Abends", "Egal"]} />
|
||||
<button type="submit" className="cursor-pointer rounded-full bg-t-accent py-4 text-lg font-bold text-t-accent-fg">Rückruf anfordern</button>
|
||||
</DemoForm>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={cn(container, "flex flex-col gap-3 py-10 text-t-muted md:flex-row md:justify-between")}>
|
||||
<p>{brand.name} · {brand.suffix} · {brand.email}</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
||||
</div>
|
||||
</footer>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function AreaCheck({ areas }: { areas: string }) {
|
||||
const [zip, setZip] = useState("")
|
||||
const [result, setResult] = useState<boolean | null>(null)
|
||||
const prefixes = splitList(areas)
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-10 rounded-3xl bg-t-surface p-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setResult(prefixes.some((prefix) => zip.startsWith(prefix)))
|
||||
}}
|
||||
>
|
||||
<label htmlFor="pflege-zip" className="font-bold">Sind wir in Ihrer Nähe? Postleitzahl prüfen:</label>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
id="pflege-zip"
|
||||
value={zip}
|
||||
onChange={(e) => {
|
||||
setZip(e.target.value.replace(/\D/g, "").slice(0, 5))
|
||||
setResult(null)
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder="z. B. 44135"
|
||||
className="min-w-0 flex-1 rounded-full border-2 border-t-fg/15 bg-t-bg px-5 py-3 text-lg outline-none focus:border-t-accent"
|
||||
/>
|
||||
<button type="submit" disabled={zip.length < 5} className="cursor-pointer rounded-full bg-t-fg px-6 font-bold text-t-bg disabled:opacity-40">Prüfen</button>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{result !== null && (
|
||||
<motion.p
|
||||
key={String(result)}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cn("mt-3 flex items-center gap-2 font-bold", result ? "text-emerald-700" : "text-t-muted")}
|
||||
>
|
||||
{result ? <CheckIcon className="size-5" /> : <XIcon className="size-5" />}
|
||||
{result ? "Gute Nachricht: Wir sind in Ihrer Gegend unterwegs!" : "Leider nicht in unserem Gebiet – rufen Sie uns trotzdem an, wir helfen gern weiter."}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Budget({ budget }: { budget: PflegeData["budget"] }) {
|
||||
const [index, setIndex] = useState(1)
|
||||
const current = budget.items[Math.min(index, budget.items.length - 1)]
|
||||
|
||||
return (
|
||||
<section className={cn(container, "py-20 md:py-28")}>
|
||||
<div className="grid gap-10 rounded-[2rem] border-2 border-t-fg/10 p-6 md:p-12 lg:grid-cols-2 lg:items-center">
|
||||
<div>
|
||||
<RevealWords text={budget.title} emClassName={em} className={cn(heading, "text-[clamp(2rem,4vw,3.25rem)] leading-[1.1]")} />
|
||||
<p className="mt-5 text-lg leading-relaxed text-t-muted">{budget.text}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div role="tablist" aria-label="Pflegegrad" className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{budget.items.map((item, i) => (
|
||||
<button
|
||||
key={item.grade}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={index === i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={cn("cursor-pointer rounded-2xl px-3 py-3 font-bold transition-colors", index === i ? "bg-t-accent text-t-accent-fg" : "bg-t-surface hover:bg-t-fg/10")}
|
||||
>
|
||||
{item.grade}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{current && (
|
||||
<div className="mt-6 rounded-3xl bg-t-surface p-7 text-center">
|
||||
<p className="text-lg text-t-muted">bis zu</p>
|
||||
<motion.p key={current.amount} initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className={cn(heading, "text-6xl text-t-accent")}>
|
||||
{current.amount} €
|
||||
</motion.p>
|
||||
<p className="mt-1 text-lg">pro Monat für Pflege durch uns</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-sm text-t-muted">{budget.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} 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 { minutesOf, splitList, useNow } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
|
|
@ -142,7 +143,7 @@ export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
|
|||
</section>
|
||||
|
||||
{show("services") && (
|
||||
<section id="leistungen" className={cn(container, "py-20 md:py-28")}>
|
||||
<section data-order="services" id="leistungen" className={cn(container, "py-20 md:py-28")}>
|
||||
<div className="max-w-2xl">
|
||||
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={services.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
|
|
@ -168,7 +169,7 @@ export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
|
|||
)}
|
||||
|
||||
{show("about") && (
|
||||
<section className={cn(container, "grid items-center gap-14 py-20 md:grid-cols-2 md:py-28")}>
|
||||
<section data-order="about" className={cn(container, "grid items-center gap-14 py-20 md:grid-cols-2 md:py-28")}>
|
||||
<div className="relative">
|
||||
<ParallaxImage src={about.image} offset={40} className="aspect-[5/4] rounded-[2rem]" sizes="(min-width: 768px) 45vw, 100vw" />
|
||||
<Reveal delay={0.2} className="absolute -right-2 -bottom-10 w-[42%] md:-right-10">
|
||||
|
|
@ -193,10 +194,10 @@ export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("booking") && <Booking booking={booking} services={services.items} hours={hours.items} />}
|
||||
{show("booking") && <Booking data-order="booking" booking={booking} services={services.items} hours={hours.items} />}
|
||||
|
||||
{show("team") && (
|
||||
<section id="team" className={cn(container, "py-20 md:py-28")}>
|
||||
<section data-order="team" id="team" className={cn(container, "py-20 md:py-28")}>
|
||||
<Reveal><Eyebrow>{team.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
<div className="mt-12 grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-6">
|
||||
|
|
@ -216,7 +217,7 @@ export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
|
|||
)}
|
||||
|
||||
{show("testimonial") && (
|
||||
<section className={cn(container, "py-16 md:py-24")}>
|
||||
<section data-order="testimonial" className={cn(container, "py-16 md:py-24")}>
|
||||
<Reveal>
|
||||
<figure className="relative overflow-hidden rounded-[2.5rem] bg-t-accent px-7 py-14 text-t-accent-fg md:px-16 md:py-20">
|
||||
<span aria-hidden className="absolute -top-10 right-8 font-t-display text-[14rem] leading-none opacity-15">“</span>
|
||||
|
|
@ -228,7 +229,7 @@ export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
|
|||
)}
|
||||
|
||||
{show("faq") && (
|
||||
<section id="faq" className={cn(container, "grid gap-10 py-20 md:grid-cols-[1fr_1.4fr] md:py-28")}>
|
||||
<section data-order="faq" id="faq" className={cn(container, "grid gap-10 py-20 md:grid-cols-[1fr_1.4fr] md:py-28")}>
|
||||
<div>
|
||||
<Reveal><Eyebrow>{faq.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={faq.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
|
|
@ -367,7 +368,7 @@ function Booking({ booking, services, hours }: { booking: PraxisData["booking"];
|
|||
</span>
|
||||
<p className={cn(heading, "mt-6 text-2xl")}>Termin angefragt!</p>
|
||||
<p className="mt-2 text-t-muted">{service} · {day?.label} um {slot} Uhr</p>
|
||||
<p className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Buchung direkt im Praxiskalender.</p>
|
||||
<DemoNote className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Buchung direkt im Praxiskalender.</DemoNote>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
|
|||
<Hero hero={hero} />
|
||||
|
||||
{show("marquee") && (
|
||||
<section className="border-y border-t-fg/10 py-8 md:py-10">
|
||||
<section data-order="marquee" className="border-y border-t-fg/10 py-8 md:py-10">
|
||||
<Marquee duration={38}>
|
||||
{marquee.items.map((item, i) => (
|
||||
<span key={i} className="flex items-center font-t-display text-4xl italic md:text-6xl">
|
||||
|
|
@ -45,7 +45,7 @@ export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
|
|||
)}
|
||||
|
||||
{show("about") && (
|
||||
<section id="ueber-uns" className={cn(container, "grid gap-20 py-28 md:grid-cols-12 md:gap-10 md:py-44")}>
|
||||
<section data-order="about" id="ueber-uns" className={cn(container, "grid gap-20 py-28 md:grid-cols-12 md:gap-10 md:py-44")}>
|
||||
<div className="md:col-span-6 md:pt-20">
|
||||
<Reveal><p className={eyebrow}>{about.eyebrow}</p></Reveal>
|
||||
<RevealWords
|
||||
|
|
@ -71,10 +71,10 @@ export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("menu") && <Menu menu={menu} />}
|
||||
{show("menu") && <Menu data-order="menu" menu={menu} />}
|
||||
|
||||
{show("gallery") && (
|
||||
<div id="galerie">
|
||||
<div data-order="gallery" id="galerie">
|
||||
<HorizontalScroll
|
||||
trackClassName="items-start gap-5 px-6 md:gap-8 md:px-10"
|
||||
header={
|
||||
|
|
@ -110,7 +110,7 @@ export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
|
|||
)}
|
||||
|
||||
{show("quote") && (
|
||||
<section className="mx-auto max-w-[1100px] px-6 py-28 text-center md:py-44">
|
||||
<section data-order="quote" className="mx-auto max-w-[1100px] px-6 py-28 text-center md:py-44">
|
||||
<Reveal>
|
||||
<span aria-hidden className="block font-t-display text-8xl leading-[0.5] text-t-accent">“</span>
|
||||
</Reveal>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function SaasTemplate({ content }: TemplateProps<SaasData>) {
|
|||
</section>
|
||||
|
||||
{show("logos") && (
|
||||
<section className="py-16">
|
||||
<section data-order="logos" className="py-16">
|
||||
<p className="mb-8 text-center text-sm text-t-muted">{logos.title}</p>
|
||||
<div className="[mask-image:linear-gradient(to_right,transparent,black_15%,black_85%,transparent)]">
|
||||
<Marquee duration={30}>
|
||||
|
|
@ -114,7 +114,7 @@ export function SaasTemplate({ content }: TemplateProps<SaasData>) {
|
|||
)}
|
||||
|
||||
{show("features") && (
|
||||
<section id="funktionen" className={cn(container, "py-24 md:py-32")}>
|
||||
<section data-order="features" id="funktionen" className={cn(container, "py-24 md:py-32")}>
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<Reveal><Eyebrow>{features.eyebrow}</Eyebrow></Reveal>
|
||||
<RevealWords text={features.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
|
|
@ -141,11 +141,11 @@ export function SaasTemplate({ content }: TemplateProps<SaasData>) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{show("steps") && <Steps steps={steps} name={brand.name} />}
|
||||
{show("pricing") && <Pricing pricing={pricing} />}
|
||||
{show("steps") && <Steps data-order="steps" steps={steps} name={brand.name} />}
|
||||
{show("pricing") && <Pricing data-order="pricing" pricing={pricing} />}
|
||||
|
||||
{show("testimonials") && (
|
||||
<section className={cn(container, "py-24 md:py-32")}>
|
||||
<section data-order="testimonials" className={cn(container, "py-24 md:py-32")}>
|
||||
<RevealWords text={testimonials.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
<div className="mt-14 gap-4 [column-fill:_balance] sm:columns-2">
|
||||
{testimonials.items.map((item, i) => (
|
||||
|
|
@ -169,7 +169,7 @@ export function SaasTemplate({ content }: TemplateProps<SaasData>) {
|
|||
)}
|
||||
|
||||
{show("faq") && (
|
||||
<section id="faq" className={cn(container, "max-w-3xl py-24 md:py-32")}>
|
||||
<section data-order="faq" id="faq" className={cn(container, "max-w-3xl py-24 md:py-32")}>
|
||||
<RevealWords text={faq.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
|
||||
<Accordion
|
||||
items={faq.items}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { DemoNote } from "./demo"
|
||||
import { ease } from "./motion"
|
||||
|
||||
// Formulare der Vorlagen senden nichts – nach dem Absenden erscheint eine Bestätigung mit Demo-Hinweis
|
||||
// Formulare der Vorlagen senden nichts – nach dem Absenden erscheint eine Bestätigung (im Portal mit Demo-Hinweis).
|
||||
// Im exportierten Projekt ist hier der Versand anzubinden.
|
||||
export function DemoForm({ children, success, className }: { children: ReactNode; success: ReactNode; className?: string }) {
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
|
|
@ -20,9 +22,9 @@ export function DemoForm({ children, success, className }: { children: ReactNode
|
|||
role="status"
|
||||
>
|
||||
{success}
|
||||
<p className="mt-4 text-sm opacity-60">
|
||||
<DemoNote className="mt-4 text-sm opacity-60">
|
||||
Demo-Hinweis: Auf Ihrer fertigen Webseite landet diese Anfrage direkt in Ihrem Postfach.
|
||||
</p>
|
||||
</DemoNote>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
|
|
|
|||
18
modules/templates/catalog/shared/demo.tsx
Normal file
18
modules/templates/catalog/shared/demo.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
// Portal-Rahmen einer Vorlage (Entwurfs-Hinweis, KI-Chat, Demo-Hinweise). Setzt nur der TemplateRenderer im Portal –
|
||||
// exportierte Projekte (modules/templates/export) rendern die Vorlagen ohne diesen Kontext und zeigen davon nichts.
|
||||
export const DemoContext = createContext<{ overlay: ReactNode } | null>(null)
|
||||
|
||||
export function useDemo() {
|
||||
return useContext(DemoContext)
|
||||
}
|
||||
|
||||
// Erklärt, was auf der fertigen Seite live passieren würde – erscheint nur in der Demo
|
||||
export function DemoNote({ children, className, as: Tag = "p" }: { children: ReactNode; className?: string; as?: "p" | "span" }) {
|
||||
const demo = useDemo()
|
||||
if (!demo) return null
|
||||
return <Tag className={className}>{children}</Tag>
|
||||
}
|
||||
75
modules/templates/catalog/shared/lightbox.tsx
Normal file
75
modules/templates/catalog/shared/lightbox.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useEffect, useEffectEvent } from "react"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"
|
||||
import { TImage } from "./media"
|
||||
|
||||
// Vollbild-Ansicht für Galerien: Pfeiltasten, Escape, Klick daneben schließt
|
||||
export function Lightbox({ images, index, onIndex }: {
|
||||
images: { src: string; caption?: string }[]
|
||||
index: number | null
|
||||
onIndex: (index: number | null) => void
|
||||
}) {
|
||||
const count = images.length
|
||||
const step = (offset: number) => index !== null && onIndex((index + offset + count) % count)
|
||||
|
||||
const onKey = useEffectEvent((event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onIndex(null)
|
||||
if (event.key === "ArrowRight") step(1)
|
||||
if (event.key === "ArrowLeft") step(-1)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (index === null) return
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [index])
|
||||
|
||||
const image = index !== null ? images[index] : undefined
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{image && (
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-label="Bildansicht"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => onIndex(null)}
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/90 p-4 md:p-12"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.figure
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="relative h-full max-h-[85vh] w-full max-w-6xl"
|
||||
>
|
||||
<TImage src={image.src} alt={image.caption} sizes="100vw" className="object-contain" />
|
||||
{image.caption && <figcaption className="absolute inset-x-0 -bottom-8 text-center text-sm text-white/70">{image.caption}</figcaption>}
|
||||
</motion.figure>
|
||||
</AnimatePresence>
|
||||
<button type="button" aria-label="Schließen" onClick={() => onIndex(null)} className="absolute top-4 right-4 grid size-11 cursor-pointer place-items-center rounded-full bg-white/10 text-white hover:bg-white/20">
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
{count > 1 && (
|
||||
<>
|
||||
<button type="button" aria-label="Vorheriges Bild" onClick={(e) => { e.stopPropagation(); step(-1) }} className="absolute left-4 grid size-12 cursor-pointer place-items-center rounded-full bg-white/10 text-white hover:bg-white/20">
|
||||
<ChevronLeftIcon className="size-6" />
|
||||
</button>
|
||||
<button type="button" aria-label="Nächstes Bild" onClick={(e) => { e.stopPropagation(); step(1) }} className="absolute right-4 grid size-12 cursor-pointer place-items-center rounded-full bg-white/10 text-white hover:bg-white/20">
|
||||
<ChevronRightIcon className="size-6" />
|
||||
</button>
|
||||
<p className="absolute top-6 left-6 font-mono text-sm text-white/60">{(index ?? 0) + 1} / {count}</p>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import Image from "next/image"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function isImageUrl(src: string) {
|
||||
// Eigene Dateien aus /public (z. B. /pictures/templates/...)
|
||||
if (src.startsWith("/") && !src.startsWith("//")) return true
|
||||
try {
|
||||
const { protocol } = new URL(src)
|
||||
return protocol === "https:" || protocol === "http:"
|
||||
|
|
@ -10,7 +12,7 @@ function isImageUrl(src: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Bild als Füllung des (relativ positionierten) Elternelements. Unsplash läuft über die
|
||||
// Bild als Füllung des (relativ positionierten) Elternelements. Eigene Dateien und Unsplash laufen über die
|
||||
// Bildoptimierung, beliebige andere Links unoptimiert. Leere oder halb getippte Links zeigen eine Fläche.
|
||||
export function TImage({ src, alt = "", className, sizes = "100vw", eager }: {
|
||||
src: string
|
||||
|
|
@ -34,7 +36,7 @@ export function TImage({ src, alt = "", className, sizes = "100vw", eager }: {
|
|||
alt={alt}
|
||||
fill
|
||||
sizes={sizes}
|
||||
unoptimized={!src.startsWith("https://images.unsplash.com/")}
|
||||
unoptimized={!src.startsWith("/") && !src.startsWith("https://images.unsplash.com/")}
|
||||
loading={eager ? "eager" : undefined}
|
||||
fetchPriority={eager ? "high" : undefined}
|
||||
className={cn("object-cover", className)}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ export function parseRich(text: string): Token[][] {
|
|||
let word = raw
|
||||
const opens = word.startsWith("*")
|
||||
if (opens) word = word.slice(1)
|
||||
const closes = word.endsWith("*")
|
||||
if (closes) word = word.slice(0, -1)
|
||||
// Schließendes Sternchen auch vor Satzzeichen erkennen: "*Herz*," → "Herz,"
|
||||
const closing = word.match(/\*([.,;:!?…"“”)]*)$/)
|
||||
const closes = closing !== null
|
||||
if (closing) word = word.slice(0, closing.index) + closing[1]
|
||||
if (opens) em = true
|
||||
const token = { word, em }
|
||||
if (closes) em = false
|
||||
|
|
|
|||
|
|
@ -1,15 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import { MotionConfig } from "motion/react"
|
||||
import { useEffect, type CSSProperties, type ReactNode } from "react"
|
||||
import { Children, isValidElement, useEffect, type CSSProperties, type ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { themeStyle } from "../../lib/content"
|
||||
import { ChatWidget } from "../../ai/ui/chat-widget"
|
||||
import type { FontPair, TemplateContent, TemplateDefinition } from "../../lib/types"
|
||||
import { DemoBadge } from "./demo-badge"
|
||||
import { themeStyle } from "../../lib/theme"
|
||||
import type { FontPair, TemplateContent, TemplateDefinition, TemplateStyle } from "../../lib/types"
|
||||
import { libraryFont } from "../font-library"
|
||||
import { useDemo } from "./demo"
|
||||
|
||||
// Rahmen jeder Vorlage: Palette und Schriften als CSS-Variablen (bg-t-*, font-t-*), reduzierte
|
||||
// Bewegung laut Systemeinstellung, sanftes Scrollen zu Ankern, Entwurfs-Hinweis und KI-Chat.
|
||||
const imageStyles: Record<TemplateStyle["images"], string> = {
|
||||
original: "",
|
||||
mono: "[&_img]:grayscale",
|
||||
warm: "[&_img]:sepia-[.3] [&_img]:saturate-[1.2]",
|
||||
soft: "[&_img]:saturate-[.75] [&_img]:brightness-[1.05] [&_img]:contrast-[.95]",
|
||||
contrast: "[&_img]:contrast-[1.2] [&_img]:saturate-[1.15]",
|
||||
}
|
||||
|
||||
// Sektionen mit data-order="<schlüssel>" tauschen ihre Plätze nach content.order; alle anderen bleiben, wo sie sind
|
||||
function arrange(children: ReactNode, order: string[]) {
|
||||
const items = Children.toArray(children)
|
||||
const keyOf = (node: ReactNode) => (isValidElement(node) ? (node.props as Record<string, unknown>)["data-order"] : undefined)
|
||||
const slots = items.flatMap((node, i) => (typeof keyOf(node) === "string" ? [i] : []))
|
||||
const rank = (node: ReactNode) => {
|
||||
const index = order.indexOf(keyOf(node) as string)
|
||||
return index < 0 ? order.length : index
|
||||
}
|
||||
const movable = slots.map((i) => items[i]).sort((a, b) => rank(a) - rank(b))
|
||||
const arranged = [...items]
|
||||
slots.forEach((slot, n) => {
|
||||
arranged[slot] = movable[n]
|
||||
})
|
||||
return arranged
|
||||
}
|
||||
|
||||
// Rahmen jeder Vorlage: Palette und Schriften als CSS-Variablen (bg-t-*, font-t-*), Bildstil, Bewegung,
|
||||
// Sektions-Reihenfolge, sanftes Scrollen zu Ankern und – nur im Portal – Entwurfs-Hinweis und KI-Chat (shared/demo.tsx).
|
||||
// Fixe Leisten am unteren Rand (z. B. mobile CTA) schieben Badge und Chat über --t-safe-bottom nach oben.
|
||||
export function TemplateShell({ def, content, fonts, className, children }: {
|
||||
def: TemplateDefinition
|
||||
|
|
@ -18,6 +43,8 @@ export function TemplateShell({ def, content, fonts, className, children }: {
|
|||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const demo = useDemo()
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const previous = root.style.scrollBehavior
|
||||
|
|
@ -27,21 +54,22 @@ export function TemplateShell({ def, content, fonts, className, children }: {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const style = { ...themeStyle(def, content), "--t-display": fonts.display, "--t-body": fonts.body } as CSSProperties
|
||||
const pair = (content.fonts.includes(":") && libraryFont(content.fonts)) || fonts
|
||||
const style = { ...themeStyle(def, content), "--t-display": pair.display, "--t-body": pair.body } as CSSProperties
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<MotionConfig reducedMotion={content.style.motion === "subtle" ? "always" : "user"}>
|
||||
<div
|
||||
style={style}
|
||||
className={cn(
|
||||
fonts.className,
|
||||
pair.className,
|
||||
"relative min-h-dvh overflow-x-clip bg-t-bg font-t-body text-t-fg antialiased selection:bg-t-accent selection:text-t-accent-fg",
|
||||
imageStyles[content.style.images],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DemoBadge content={content} />
|
||||
<ChatWidget content={content} />
|
||||
{arrange(children, content.order)}
|
||||
{demo?.overlay}
|
||||
</div>
|
||||
</MotionConfig>
|
||||
)
|
||||
|
|
|
|||
100
modules/templates/catalog/shop/definition.ts
Normal file
100
modules/templates/catalog/shop/definition.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||
|
||||
const defaults = {
|
||||
brand: { name: "Sonar", tagline: "Kopfhörer für den Alltag", offer: "Kostenloser Versand · 30 Tage Rückgabe · 2 Jahre Garantie" },
|
||||
product: {
|
||||
name: "Sonar One",
|
||||
subtitle: "Kabelloser Over-Ear-Kopfhörer mit adaptivem Noise Cancelling",
|
||||
price: "249",
|
||||
oldPrice: "299",
|
||||
rating: "4,8",
|
||||
reviews: "2.134",
|
||||
cta: "In den Warenkorb",
|
||||
variants: [
|
||||
{ name: "Mitternacht", color: "#1f2024", image: unsplash("1546435770-a3e426bf472b", 1400) },
|
||||
{ name: "Sonnengelb", color: "#f5c518", image: unsplash("1505740420928-5e560c06d30e", 1400) },
|
||||
{ name: "Rosé", color: "#f0c6c6", image: unsplash("1524678606370-a47ad25cb82a", 1400) },
|
||||
{ name: "Graphit", color: "#5b5f66", image: unsplash("1583394838336-acd977736f90", 1400) },
|
||||
],
|
||||
},
|
||||
features: {
|
||||
title: "Durchdacht bis ins *Detail*",
|
||||
image: unsplash("1618366712010-f4ae9c647dcb", 1600),
|
||||
items: [
|
||||
{ title: "Adaptives Noise Cancelling", text: "Passt sich in Echtzeit an deine Umgebung an – vom Großraumbüro bis zum Flugzeug.", x: "34", y: "30" },
|
||||
{ title: "40 Stunden Akku", text: "Eine Ladung reicht für eine ganze Arbeitswoche. 10 Minuten Laden = 5 Stunden Musik.", x: "68", y: "38" },
|
||||
{ title: "Memory-Foam-Polster", text: "Weich wie ein Kissen, auch nach Stunden kein Druck auf den Ohren.", x: "28", y: "68" },
|
||||
{ title: "Touch-Steuerung", text: "Wischen für Lautstärke, tippen für Pause – ganz ohne Knöpfe.", x: "72", y: "70" },
|
||||
],
|
||||
},
|
||||
specs: {
|
||||
title: "Technische *Daten*",
|
||||
items: [
|
||||
{ label: "Akkulaufzeit", value: "bis zu 40 Stunden" },
|
||||
{ label: "Schnellladen", value: "10 Min. = 5 Stunden" },
|
||||
{ label: "Bluetooth", value: "5.3 mit Multipoint" },
|
||||
{ label: "Gewicht", value: "254 g" },
|
||||
{ label: "Treiber", value: "40 mm, dynamisch" },
|
||||
{ label: "Codecs", value: "SBC, AAC, LDAC" },
|
||||
],
|
||||
box: "Kopfhörer\nTransport-Etui\nUSB-C-Kabel\n3,5-mm-Audiokabel\nFlugzeugadapter",
|
||||
},
|
||||
lifestyle: {
|
||||
title: "Gemacht für *dein* Leben",
|
||||
text: "Ob konzentriertes Arbeiten, lange Bahnfahrten oder einfach nur Ruhe auf dem Sofa – Sonar One schaltet die Welt leiser.",
|
||||
images: [
|
||||
{ src: unsplash("1484704849700-f032a568e944", 1200), caption: "Im Homeoffice" },
|
||||
{ src: unsplash("1590658268037-6bf12165a8df", 1200), caption: "Mit Etui unterwegs" },
|
||||
],
|
||||
},
|
||||
reviews: {
|
||||
title: "Das sagen *Kund:innen*",
|
||||
breakdown: "82, 12, 4, 1, 1",
|
||||
items: [
|
||||
{ quote: "Das Noise Cancelling ist absurd gut. Im ICE höre ich nur noch meine Musik.", author: "Jonas B.", rating: "5" },
|
||||
{ quote: "Super bequem, auch mit Brille. Der Akku hält ewig.", author: "Melanie K.", rating: "5" },
|
||||
{ quote: "Klang und Verarbeitung top. Einziger Wunsch: noch mehr Farben!", author: "Ahmet S.", rating: "4" },
|
||||
],
|
||||
},
|
||||
faq: {
|
||||
items: [
|
||||
{ question: "Wie schnell wird geliefert?", answer: "Bestellungen bis 14 Uhr verschicken wir am selben Tag. Die Lieferung dauert in der Regel ein bis zwei Werktage." },
|
||||
{ question: "Kann ich den Kopfhörer zurückgeben?", answer: "Ja, du hast 30 Tage Rückgaberecht – kostenlos und ohne Angabe von Gründen." },
|
||||
{ question: "Funktioniert er mit iPhone und Android?", answer: "Ja, mit allen Bluetooth-Geräten. Über die App passt du Klang und Noise Cancelling an." },
|
||||
{ question: "Gibt es Garantie?", answer: "Zwei Jahre Herstellergarantie, verlängerbar auf drei Jahre bei Registrierung." },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const shop = defineTemplate({
|
||||
key: "shop",
|
||||
name: "Produkt",
|
||||
industry: "Online-Shop & Produkt",
|
||||
description: "Für Produkt-Launches und D2C-Marken: Farbvarianten mit Bildwechsel, Hotspots am Produkt, Warenkorb-Drawer, Sticky-Kaufleiste und Bewertungs-Verteilung.",
|
||||
cover: unsplash("1505740420928-5e560c06d30e", 1200),
|
||||
samples: {
|
||||
inquiry: "Hallo, ich möchte 25 Kopfhörer für unser Team bestellen, gern mit unserem Logo auf dem Etui. Gibt es Mengenrabatt, und wie lange dauert die Lieferung? Viele Grüße, Lea Hartmann, Office Managerin",
|
||||
topic: "Neue Farbe: Sonar One jetzt in Sonnengelb",
|
||||
},
|
||||
palettes: [
|
||||
{ key: "graphit", label: "Graphit", dark: true, bg: "#0d0d0f", fg: "#f2f2f4", muted: "#8b8b93", surface: "#18181c", accent: "#ff6a3d" },
|
||||
{ key: "studio", label: "Studio", bg: "#f6f6f4", fg: "#111111", muted: "#6b6b6b", surface: "#e9e9e6", accent: "#2f54eb" },
|
||||
{ key: "zitrus", label: "Zitrus", bg: "#fdf6dc", fg: "#1a1a1a", muted: "#6b6455", surface: "#f8e9ad", accent: "#1a1a1a" },
|
||||
],
|
||||
fonts: [
|
||||
{ key: "klar", label: "Red Hat Display" },
|
||||
{ key: "rund", label: "Rubik" },
|
||||
],
|
||||
sections: describeSections(defaults, {
|
||||
brand: "Marke",
|
||||
product: { label: "Produkt", items: { variants: "Farbe" } },
|
||||
features: { label: "Funktionen (Hotspots)", optional: true, items: { items: "Hotspot" } },
|
||||
specs: { label: "Technische Daten", optional: true, items: { items: "Angabe" } },
|
||||
lifestyle: { label: "Lifestyle", optional: true, items: { images: "Bild" } },
|
||||
reviews: { label: "Bewertungen", optional: true, items: { items: "Bewertung" } },
|
||||
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
|
||||
}),
|
||||
defaults,
|
||||
})
|
||||
|
||||
export type ShopData = typeof shop.defaults
|
||||
11
modules/templates/catalog/shop/fonts.ts
Normal file
11
modules/templates/catalog/shop/fonts.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Red_Hat_Display, Rubik } from "next/font/google"
|
||||
import type { FontPair } from "../../lib/types"
|
||||
|
||||
// preload: false – siehe restaurant/fonts.ts
|
||||
const redHat = Red_Hat_Display({ subsets: ["latin"], variable: "--font-shop-redhat", preload: false })
|
||||
const rubik = Rubik({ subsets: ["latin"], variable: "--font-shop-rubik", preload: false })
|
||||
|
||||
export const fonts: Record<string, FontPair> = {
|
||||
klar: { className: redHat.variable, display: "var(--font-shop-redhat)", body: "var(--font-shop-redhat)" },
|
||||
rund: { className: rubik.variable, display: "var(--font-shop-rubik)", body: "var(--font-shop-rubik)" },
|
||||
}
|
||||
407
modules/templates/catalog/shop/template.tsx
Normal file
407
modules/templates/catalog/shop/template.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence, motion, useInView } from "motion/react"
|
||||
import { useRef, useState } from "react"
|
||||
import { CheckIcon, MinusIcon, PlusIcon, RotateCcwIcon, ShieldCheckIcon, ShoppingBagIcon, StarIcon, TruckIcon, XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateProps } from "../../lib/types"
|
||||
import { DemoNote } from "../shared/demo"
|
||||
import { splitList } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
import { Accordion, Reveal, RevealWords, ease } from "../shared/motion"
|
||||
import { TemplateShell } from "../shared/shell"
|
||||
import { shop, type ShopData } from "./definition"
|
||||
import { fonts } from "./fonts"
|
||||
|
||||
type Variant = ShopData["product"]["variants"][number]
|
||||
type Line = { variant: string; quantity: number }
|
||||
|
||||
const em = "text-t-accent"
|
||||
const container = "mx-auto w-full max-w-[1320px] px-5 md:px-8"
|
||||
const heading = "font-t-display font-bold tracking-[-0.03em]"
|
||||
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
|
||||
const num = (value: string) => Number(value.replace(/\./g, "").replace(",", ".")) || 0
|
||||
|
||||
function Stars({ value, className }: { value: number; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex text-amber-400", className)}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<StarIcon key={i} className={cn("size-4", i < Math.round(value) ? "fill-current" : "opacity-30")} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShopTemplate({ content }: TemplateProps<ShopData>) {
|
||||
const { brand, product, features, specs, lifestyle, reviews, faq } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const [variantIndex, setVariantIndex] = useState(0)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [cart, setCart] = useState<Line[]>([])
|
||||
const [cartOpen, setCartOpen] = useState(false)
|
||||
const heroRef = useRef<HTMLElement>(null)
|
||||
const heroVisible = useInView(heroRef, { margin: "0px 0px -40% 0px" })
|
||||
const variant: Variant | undefined = product.variants[Math.min(variantIndex, product.variants.length - 1)]
|
||||
const price = num(product.price)
|
||||
const oldPrice = num(product.oldPrice)
|
||||
const count = cart.reduce((sum, line) => sum + line.quantity, 0)
|
||||
|
||||
function addToCart() {
|
||||
const name = variant?.name ?? product.name
|
||||
setCart((lines) => {
|
||||
const existing = lines.find((line) => line.variant === name)
|
||||
return existing ? lines.map((line) => (line === existing ? { ...line, quantity: line.quantity + quantity } : line)) : [...lines, { variant: name, quantity }]
|
||||
})
|
||||
setCartOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateShell def={shop} content={content} fonts={fonts[content.fonts] ?? fonts.klar} className="[--t-safe-bottom:5rem]">
|
||||
<div className="bg-t-fg py-2 text-center text-sm text-t-bg">{brand.offer}</div>
|
||||
<header className="sticky top-0 z-50 border-b border-t-fg/10 bg-t-bg/90 backdrop-blur-xl">
|
||||
<nav className={cn(container, "flex h-16 items-center justify-between")}>
|
||||
<a href="#top" className={cn(heading, "text-2xl")}>{brand.name}<span className="text-t-accent">.</span></a>
|
||||
<div className="hidden gap-8 text-sm font-medium md:flex">
|
||||
{show("features") && <a href="#funktionen" className="text-t-muted hover:text-t-fg">Funktionen</a>}
|
||||
{show("specs") && <a href="#technik" className="text-t-muted hover:text-t-fg">Technik</a>}
|
||||
{show("reviews") && <a href="#bewertungen" className="text-t-muted hover:text-t-fg">Bewertungen</a>}
|
||||
</div>
|
||||
<button type="button" onClick={() => setCartOpen(true)} aria-label={`Warenkorb mit ${count} Artikeln`} className="relative grid size-11 cursor-pointer place-items-center rounded-full hover:bg-t-surface">
|
||||
<ShoppingBagIcon className="size-5" />
|
||||
{count > 0 && <span className="absolute top-0.5 right-0.5 grid size-5 place-items-center rounded-full bg-t-accent text-[11px] font-bold text-t-accent-fg">{count}</span>}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section ref={heroRef} id="top" className={cn(container, "grid gap-10 py-10 md:py-16 lg:grid-cols-[1.15fr_1fr] lg:items-center")}>
|
||||
<div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-[2rem] bg-t-surface">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div key={variant?.image} className="absolute inset-0" initial={{ opacity: 0, scale: 1.04 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.6, ease }}>
|
||||
<TImage src={variant?.image ?? ""} eager sizes="(min-width: 1024px) 55vw, 100vw" />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{oldPrice > price && <span className="absolute top-5 left-5 rounded-full bg-t-accent px-3 py-1.5 text-sm font-bold text-t-accent-fg">−{Math.round((1 - price / oldPrice) * 100)} %</span>}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-4 gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
<button key={v.name} type="button" onClick={() => setVariantIndex(i)} aria-label={v.name} className={cn("relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-t-surface ring-2 transition", i === variantIndex ? "ring-t-accent" : "ring-transparent opacity-70 hover:opacity-100")}>
|
||||
<TImage src={v.image} sizes="150px" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Stars value={num(product.rating)} />
|
||||
<span className="font-semibold">{product.rating}</span>
|
||||
<a href="#bewertungen" className="text-t-muted underline-offset-4 hover:underline">({product.reviews} Bewertungen)</a>
|
||||
</div>
|
||||
<motion.h1 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease }} className={cn(heading, "mt-4 text-[clamp(3rem,6vw,5.5rem)] leading-[0.95]")}>
|
||||
{product.name}
|
||||
</motion.h1>
|
||||
<p className="mt-4 text-lg text-t-muted">{product.subtitle}</p>
|
||||
<p className="mt-6 flex items-baseline gap-3">
|
||||
<span className={cn(heading, "text-4xl")}>{euro.format(price)}</span>
|
||||
{oldPrice > price && <span className="text-lg text-t-muted line-through">{euro.format(oldPrice)}</span>}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<p className="text-sm">Farbe: <span className="font-semibold">{variant?.name}</span></p>
|
||||
<div className="mt-3 flex gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
aria-label={v.name}
|
||||
aria-pressed={i === variantIndex}
|
||||
onClick={() => setVariantIndex(i)}
|
||||
className={cn("size-10 cursor-pointer rounded-full ring-2 ring-offset-4 ring-offset-t-bg transition", i === variantIndex ? "ring-t-fg" : "ring-transparent hover:ring-t-fg/30")}
|
||||
style={{ background: v.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex gap-3">
|
||||
<div className="flex items-center rounded-full border border-t-fg/15">
|
||||
<button type="button" aria-label="Weniger" onClick={() => setQuantity((q) => Math.max(1, q - 1))} className="grid size-12 cursor-pointer place-items-center"><MinusIcon className="size-4" /></button>
|
||||
<span className="w-6 text-center font-semibold">{quantity}</span>
|
||||
<button type="button" aria-label="Mehr" onClick={() => setQuantity((q) => Math.min(9, q + 1))} className="grid size-12 cursor-pointer place-items-center"><PlusIcon className="size-4" /></button>
|
||||
</div>
|
||||
<motion.button type="button" whileTap={{ scale: 0.97 }} onClick={addToCart} className="flex-1 cursor-pointer rounded-full bg-t-accent px-6 font-semibold text-t-accent-fg">
|
||||
{product.cta} · {euro.format(price * quantity)}
|
||||
</motion.button>
|
||||
</div>
|
||||
<ul className="mt-8 grid gap-3 border-t border-t-fg/10 pt-6 text-sm text-t-muted sm:grid-cols-3">
|
||||
<li className="flex items-center gap-2"><TruckIcon className="size-4 text-t-fg" /> Versand in 24 h</li>
|
||||
<li className="flex items-center gap-2"><RotateCcwIcon className="size-4 text-t-fg" /> 30 Tage Rückgabe</li>
|
||||
<li className="flex items-center gap-2"><ShieldCheckIcon className="size-4 text-t-fg" /> 2 Jahre Garantie</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show("features") && <Hotspots data-order="features" features={features} />}
|
||||
|
||||
{show("specs") && (
|
||||
<section data-order="specs" id="technik" className={cn(container, "grid gap-12 py-24 md:py-32 lg:grid-cols-[1fr_1.4fr]")}>
|
||||
<div>
|
||||
<RevealWords text={specs.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<Reveal delay={0.1}>
|
||||
<p className="mt-8 text-sm font-semibold uppercase tracking-wider text-t-muted">Im Lieferumfang</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{splitList(specs.box, "\n").map((item) => (
|
||||
<li key={item} className="flex items-center gap-2"><CheckIcon className="size-4 text-t-accent" /> {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</div>
|
||||
<dl className="grid gap-px overflow-hidden rounded-3xl bg-t-fg/10 sm:grid-cols-2">
|
||||
{specs.items.map((item, i) => (
|
||||
<Reveal key={i} delay={(i % 2) * 0.06} className="bg-t-bg p-6">
|
||||
<dt className="text-sm text-t-muted">{item.label}</dt>
|
||||
<dd className={cn(heading, "mt-1 text-2xl")}>{item.value}</dd>
|
||||
</Reveal>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("lifestyle") && (
|
||||
<section data-order="lifestyle" className={cn(container, "pb-24 md:pb-32")}>
|
||||
<div className="max-w-2xl">
|
||||
<RevealWords text={lifestyle.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<Reveal delay={0.1}><p className="mt-5 text-lg text-t-muted">{lifestyle.text}</p></Reveal>
|
||||
</div>
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-[1.4fr_1fr]">
|
||||
{lifestyle.images.map((image, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<figure className="group relative aspect-[4/3] overflow-hidden rounded-[2rem] md:aspect-auto md:h-[30rem]">
|
||||
<TImage src={image.src} alt={image.caption} sizes="(min-width: 768px) 50vw, 100vw" className="transition-transform duration-[1.2s] group-hover:scale-105" />
|
||||
<figcaption className="absolute bottom-5 left-5 rounded-full bg-t-bg/85 px-4 py-2 text-sm font-medium backdrop-blur">{image.caption}</figcaption>
|
||||
</figure>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("reviews") && <Reviews data-order="reviews" reviews={reviews} product={product} />}
|
||||
|
||||
{show("faq") && (
|
||||
<section data-order="faq" className={cn(container, "max-w-3xl py-24")}>
|
||||
<h2 className={cn(heading, "text-center text-4xl")}>Fragen?</h2>
|
||||
<Accordion items={faq.items} className="mt-10 divide-y divide-t-fg/10 border-y border-t-fg/10" questionClassName="py-5 text-lg font-semibold" answerClassName="pb-5 text-t-muted" />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="border-t border-t-fg/10 pb-28">
|
||||
<div className={cn(container, "flex flex-col gap-4 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
|
||||
<p><span className="font-bold text-t-fg">{brand.name}</span> · {brand.tagline}</p>
|
||||
<p>Zahlung: PayPal · Klarna · Kreditkarte · Apple Pay</p>
|
||||
<div className="flex gap-6">
|
||||
<a href="#" className="hover:text-t-fg">Impressum</a>
|
||||
<a href="#" className="hover:text-t-fg">AGB</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<AnimatePresence>
|
||||
{!heroVisible && (
|
||||
<motion.div
|
||||
initial={{ y: "120%" }}
|
||||
animate={{ y: "0%" }}
|
||||
exit={{ y: "120%" }}
|
||||
transition={{ duration: 0.4, ease }}
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-t-fg/10 bg-t-bg/95 backdrop-blur-xl"
|
||||
>
|
||||
<div className={cn(container, "flex items-center gap-4 py-3")}>
|
||||
<div className="relative hidden size-12 overflow-hidden rounded-xl bg-t-surface sm:block">
|
||||
<TImage src={variant?.image ?? ""} sizes="48px" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold">{product.name} · {variant?.name}</p>
|
||||
<p className="text-sm text-t-muted">{euro.format(price)}</p>
|
||||
</div>
|
||||
<button type="button" onClick={addToCart} className="cursor-pointer rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">{product.cta}</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<CartDrawer open={cartOpen} lines={cart} product={product} onChange={setCart} onClose={() => setCartOpen(false)} />
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Hotspots({ features }: { features: ShopData["features"] }) {
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
return (
|
||||
<section id="funktionen" className="bg-t-fg py-24 text-t-bg md:py-32">
|
||||
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-[1.3fr_1fr]")}>
|
||||
<div className="relative aspect-[4/3] overflow-hidden rounded-[2rem]">
|
||||
<TImage src={features.image} sizes="(min-width: 1024px) 55vw, 100vw" />
|
||||
{features.items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label={item.title}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => setActive(i)}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer"
|
||||
style={{ left: `${Number(item.x) || 50}%`, top: `${Number(item.y) || 50}%` }}
|
||||
>
|
||||
<span className={cn("absolute inset-0 animate-ping rounded-full bg-t-accent/60", active !== i && "hidden")} />
|
||||
<span className={cn("relative grid size-9 place-items-center rounded-full text-sm font-bold transition-colors", active === i ? "bg-t-accent text-t-accent-fg" : "bg-white text-zinc-900")}>{i + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<RevealWords text={features.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<ol className="mt-8 space-y-2">
|
||||
{features.items.map((item, i) => (
|
||||
<li key={i}>
|
||||
<button type="button" onClick={() => setActive(i)} className={cn("w-full cursor-pointer rounded-2xl p-5 text-left transition-colors", active === i ? "bg-t-bg/10" : "hover:bg-t-bg/5")}>
|
||||
<span className="flex items-center gap-3 text-lg font-semibold">
|
||||
<span className={cn("grid size-7 place-items-center rounded-full text-xs", active === i ? "bg-t-accent text-t-accent-fg" : "bg-t-bg/15")}>{i + 1}</span>
|
||||
{item.title}
|
||||
</span>
|
||||
<AnimatePresence initial={false}>
|
||||
{active === i && (
|
||||
<motion.p initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden pt-2 pl-10 opacity-75">
|
||||
{item.text}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Reviews({ reviews, product }: { reviews: ShopData["reviews"]; product: ShopData["product"] }) {
|
||||
const shares = splitList(reviews.breakdown).map((value) => Number(value) || 0)
|
||||
|
||||
return (
|
||||
<section id="bewertungen" className="bg-t-surface py-24 md:py-32">
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-[0.8fr_1.2fr]")}>
|
||||
<div>
|
||||
<RevealWords text={reviews.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||||
<div className="mt-8 flex items-end gap-4">
|
||||
<p className={cn(heading, "text-7xl")}>{product.rating}</p>
|
||||
<div className="pb-2">
|
||||
<Stars value={num(product.rating)} />
|
||||
<p className="mt-1 text-sm text-t-muted">{product.reviews} Bewertungen</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="mt-6 space-y-2">
|
||||
{shares.slice(0, 5).map((share, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-10">{5 - i} ★</span>
|
||||
<span className="h-2 flex-1 overflow-hidden rounded-full bg-t-fg/10">
|
||||
<motion.span className="block h-full rounded-full bg-t-accent" initial={{ width: 0 }} whileInView={{ width: `${share}%` }} viewport={{ once: true }} transition={{ duration: 1, ease, delay: i * 0.08 }} />
|
||||
</span>
|
||||
<span className="w-10 text-right text-t-muted">{share} %</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{reviews.items.map((review, i) => (
|
||||
<Reveal key={i} delay={i * 0.08}>
|
||||
<figure className="rounded-3xl bg-t-bg p-6">
|
||||
<Stars value={Number(review.rating) || 5} />
|
||||
<blockquote className="mt-3 text-lg">„{review.quote}“</blockquote>
|
||||
<figcaption className="mt-3 flex items-center gap-2 text-sm text-t-muted">
|
||||
{review.author} · <span className="flex items-center gap-1 text-emerald-600"><CheckIcon className="size-3.5" /> Verifizierter Kauf</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CartDrawer({ open, lines, product, onChange, onClose }: { open: boolean; lines: Line[]; product: ShopData["product"]; onChange: (lines: Line[]) => void; onClose: () => void }) {
|
||||
const [ordered, setOrdered] = useState(false)
|
||||
const price = num(product.price)
|
||||
const total = lines.reduce((sum, line) => sum + line.quantity * price, 0)
|
||||
const close = () => {
|
||||
setOrdered(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} className="fixed inset-0 z-[80] bg-black/50" />
|
||||
<motion.aside
|
||||
role="dialog"
|
||||
aria-label="Warenkorb"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: "0%" }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.4, ease }}
|
||||
className="fixed inset-y-0 right-0 z-[90] flex w-full max-w-md flex-col bg-t-bg text-t-fg shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-t-fg/10 p-5">
|
||||
<p className={cn(heading, "text-2xl")}>Warenkorb</p>
|
||||
<button type="button" aria-label="Schließen" onClick={close} className="grid size-10 cursor-pointer place-items-center rounded-full hover:bg-t-surface"><XIcon className="size-5" /></button>
|
||||
</div>
|
||||
{ordered ? (
|
||||
<div className="grid flex-1 place-items-center p-8 text-center">
|
||||
<div>
|
||||
<CheckIcon className="mx-auto size-14 text-t-accent" />
|
||||
<p className={cn(heading, "mt-4 text-3xl")}>Danke für deine Bestellung!</p>
|
||||
<DemoNote className="mt-3 text-sm text-t-muted">Demo-Hinweis: Live geht es hier zur echten Kasse (z. B. Shopify oder Stripe).</DemoNote>
|
||||
</div>
|
||||
</div>
|
||||
) : lines.length === 0 ? (
|
||||
<p className="grid flex-1 place-items-center text-t-muted">Dein Warenkorb ist leer.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex-1 divide-y divide-t-fg/10 overflow-y-auto px-5">
|
||||
{lines.map((line) => {
|
||||
const image = product.variants.find((v) => v.name === line.variant)?.image ?? ""
|
||||
return (
|
||||
<li key={line.variant} className="flex items-center gap-4 py-4">
|
||||
<div className="relative size-16 overflow-hidden rounded-xl bg-t-surface"><TImage src={image} sizes="64px" /></div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">{product.name}</p>
|
||||
<p className="text-sm text-t-muted">{line.variant} · {line.quantity} ×</p>
|
||||
</div>
|
||||
<p className="font-semibold">{euro.format(line.quantity * price)}</p>
|
||||
<button type="button" aria-label="Entfernen" onClick={() => onChange(lines.filter((l) => l !== line))} className="cursor-pointer text-t-muted hover:text-t-fg"><XIcon className="size-4" /></button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="border-t border-t-fg/10 p-5">
|
||||
<p className="flex justify-between text-lg font-semibold">Zwischensumme <span>{euro.format(total)}</span></p>
|
||||
<p className="mt-1 text-sm text-t-muted">inkl. MwSt. · kostenloser Versand</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOrdered(true)
|
||||
onChange([])
|
||||
}}
|
||||
className="mt-5 w-full cursor-pointer rounded-full bg-t-accent py-4 font-semibold text-t-accent-fg"
|
||||
>
|
||||
Zur Kasse
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
66
modules/templates/catalog/speisekarte/definition.ts
Normal file
66
modules/templates/catalog/speisekarte/definition.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||
|
||||
const defaults = {
|
||||
brand: {
|
||||
name: "Trattoria Sole",
|
||||
table: "Tisch 12",
|
||||
note: "Bestellung bitte beim Service · Allergene auf Nachfrage",
|
||||
wifi: "WLAN: Sole-Gast · Passwort: pasta2026",
|
||||
},
|
||||
special: {
|
||||
title: "Empfehlung des Tages",
|
||||
name: "Risotto al Tartufo",
|
||||
text: "Carnaroli-Reis, frischer schwarzer Trüffel, gereifter Parmesan",
|
||||
price: "24,50",
|
||||
image: unsplash("1476224203421-9ac39bcb3327", 1000),
|
||||
},
|
||||
menu: {
|
||||
items: [
|
||||
{ category: "Antipasti", name: "Bruschetta della Casa", description: "Geröstetes Brot, Tomaten, Basilikum, Knoblauch", price: "7,50", tags: "vegan", allergens: "A", image: unsplash("1504674900247-0877df9cc836", 400) },
|
||||
{ category: "Antipasti", name: "Burrata", description: "Burrata, Erbstomaten, Basilikumöl", price: "12,90", tags: "vegetarisch, glutenfrei", allergens: "G", image: "" },
|
||||
{ category: "Antipasti", name: "Insalata Sole", description: "Blattsalate, Avocado, Granatapfel, Zitronendressing", price: "9,50", tags: "vegan, glutenfrei", allergens: "", image: unsplash("1540189549336-e6e99c3679fe", 400) },
|
||||
{ category: "Pasta", name: "Spaghetti Aglio e Olio", description: "Knoblauch, Chili, Petersilie, Olivenöl", price: "12,50", tags: "vegan, scharf", allergens: "A", image: "" },
|
||||
{ category: "Pasta", name: "Tagliatelle al Ragù", description: "Hausgemachte Tagliatelle, sechs Stunden geschmortes Ragù", price: "16,90", tags: "", allergens: "A, C, I", image: unsplash("1476224203421-9ac39bcb3327", 400) },
|
||||
{ category: "Pasta", name: "Ravioli Ricotta e Spinaci", description: "Salbeibutter, Parmesan", price: "15,50", tags: "vegetarisch", allergens: "A, C, G", image: "" },
|
||||
{ category: "Pizza", name: "Margherita", description: "Tomate, Fior di Latte, Basilikum", price: "10,50", tags: "vegetarisch", allergens: "A, G", image: unsplash("1565299624946-b28f40a0ae38", 400) },
|
||||
{ category: "Pizza", name: "Diavola", description: "Tomate, Mozzarella, scharfe Salami, Chili", price: "13,50", tags: "scharf", allergens: "A, G", image: "" },
|
||||
{ category: "Secondi", name: "Tagliata di Manzo", description: "Rinderhüfte, Rucola, Parmesan, Rosmarinkartoffeln", price: "26,90", tags: "glutenfrei", allergens: "G", image: unsplash("1600891964092-4316c288032e", 400) },
|
||||
{ category: "Dolci", name: "Tiramisù", description: "Nach dem Rezept der Nonna", price: "7,50", tags: "vegetarisch", allergens: "A, C, G", image: unsplash("1578985545062-69928b1d9587", 400) },
|
||||
{ category: "Getränke", name: "Hauswein, 0,2 l", description: "Montepulciano d'Abruzzo oder Pinot Grigio", price: "6,50", tags: "vegan", allergens: "O", image: unsplash("1510812431401-41d2bd2722f3", 400) },
|
||||
{ category: "Getränke", name: "Espresso", description: "Aus unserer Siebträgermaschine", price: "2,60", tags: "vegan", allergens: "", image: "" },
|
||||
],
|
||||
},
|
||||
legend: {
|
||||
text: "A Gluten · C Eier · G Milch · H Schalenfrüchte · I Sellerie · O Sulfite",
|
||||
},
|
||||
}
|
||||
|
||||
export const speisekarte = defineTemplate({
|
||||
key: "speisekarte",
|
||||
name: "QR-Karte",
|
||||
industry: "Digitale Speisekarte (QR-Code)",
|
||||
description: "Für den QR-Code auf dem Tisch: mitlaufende Kategorien, Suche, Filter für vegan, vegetarisch und glutenfrei, Allergene zum Aufklappen und „Service rufen“.",
|
||||
cover: unsplash("1565299624946-b28f40a0ae38", 1200),
|
||||
samples: {
|
||||
inquiry: "Hallo, wir möchten am Freitag mit 14 Personen zum Geburtstag kommen, drei davon essen vegan und eine Person glutenfrei. Können Sie uns einen Tisch reservieren und vorab sagen, was wir bestellen können? Danke, Laura",
|
||||
topic: "Neu auf der Karte: Risotto al Tartufo",
|
||||
},
|
||||
palettes: [
|
||||
{ key: "creme", label: "Creme", bg: "#fbf7f0", fg: "#2b2118", muted: "#7a6a5a", surface: "#f1e9dc", accent: "#b5562b" },
|
||||
{ key: "schiefer", label: "Schiefer", dark: true, bg: "#121417", fg: "#f1ece4", muted: "#9a958c", surface: "#1c1f23", accent: "#e0a458" },
|
||||
{ key: "olive", label: "Olive", bg: "#f5f6ef", fg: "#26301f", muted: "#6c7563", surface: "#e6e9da", accent: "#6b7f2a" },
|
||||
],
|
||||
fonts: [
|
||||
{ key: "trattoria", label: "Lora & Inter" },
|
||||
{ key: "bistro", label: "Zilla Slab & Mulish" },
|
||||
],
|
||||
sections: describeSections(defaults, {
|
||||
brand: "Restaurant & Tisch",
|
||||
special: { label: "Empfehlung", optional: true },
|
||||
menu: { label: "Speisen & Getränke", items: { items: "Gericht" } },
|
||||
legend: { label: "Allergene", optional: true },
|
||||
}),
|
||||
defaults,
|
||||
})
|
||||
|
||||
export type SpeisekarteData = typeof speisekarte.defaults
|
||||
12
modules/templates/catalog/speisekarte/fonts.ts
Normal file
12
modules/templates/catalog/speisekarte/fonts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Lora, Mulish, Zilla_Slab } from "next/font/google"
|
||||
import type { FontPair } from "../../lib/types"
|
||||
|
||||
// preload: false – siehe restaurant/fonts.ts
|
||||
const lora = Lora({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-karte-lora", preload: false })
|
||||
const zilla = Zilla_Slab({ subsets: ["latin"], weight: ["500", "600", "700"], variable: "--font-karte-zilla", preload: false })
|
||||
const mulish = Mulish({ subsets: ["latin"], variable: "--font-karte-mulish", preload: false })
|
||||
|
||||
export const fonts: Record<string, FontPair> = {
|
||||
trattoria: { className: lora.variable, display: "var(--font-karte-lora)", body: "var(--font-inter)" },
|
||||
bistro: { className: `${zilla.variable} ${mulish.variable}`, display: "var(--font-karte-zilla)", body: "var(--font-karte-mulish)" },
|
||||
}
|
||||
237
modules/templates/catalog/speisekarte/template.tsx
Normal file
237
modules/templates/catalog/speisekarte/template.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { BellRingIcon, FlameIcon, InfoIcon, LeafIcon, SearchIcon, WheatOffIcon, WifiIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateProps } from "../../lib/types"
|
||||
import { DemoNote } from "../shared/demo"
|
||||
import { splitList } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
import { ease } from "../shared/motion"
|
||||
import { TemplateShell } from "../shared/shell"
|
||||
import { speisekarte, type SpeisekarteData } from "./definition"
|
||||
import { fonts } from "./fonts"
|
||||
|
||||
type Dish = SpeisekarteData["menu"]["items"][number]
|
||||
|
||||
const column = "mx-auto w-full max-w-2xl px-4"
|
||||
const heading = "font-t-display font-semibold tracking-[-0.01em]"
|
||||
const FILTERS = [
|
||||
{ key: "vegetarisch", label: "Vegetarisch", icon: LeafIcon },
|
||||
{ key: "vegan", label: "Vegan", icon: LeafIcon },
|
||||
{ key: "glutenfrei", label: "Glutenfrei", icon: WheatOffIcon },
|
||||
] as const
|
||||
|
||||
const slug = (text: string) => `kat-${text.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`
|
||||
const tagsOf = (dish: Dish) => splitList(dish.tags).map((tag) => tag.toLowerCase())
|
||||
|
||||
export function SpeisekarteTemplate({ content }: TemplateProps<SpeisekarteData>) {
|
||||
const { brand, special, menu, legend } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
const [query, setQuery] = useState("")
|
||||
const [filters, setFilters] = useState<string[]>([])
|
||||
const [called, setCalled] = useState(false)
|
||||
|
||||
const matches = (dish: Dish) => {
|
||||
const tags = tagsOf(dish)
|
||||
// vegan zählt auch als vegetarisch
|
||||
const fits = filters.every((filter) => tags.includes(filter) || (filter === "vegetarisch" && tags.includes("vegan")))
|
||||
const text = `${dish.name} ${dish.description}`.toLowerCase()
|
||||
return fits && (!query.trim() || text.includes(query.trim().toLowerCase()))
|
||||
}
|
||||
const categories = [...new Set(menu.items.map((dish) => dish.category))]
|
||||
const visible = categories.map((category) => ({ category, dishes: menu.items.filter((dish) => dish.category === category && matches(dish)) })).filter((group) => group.dishes.length)
|
||||
|
||||
useEffect(() => {
|
||||
if (!called) return
|
||||
const timer = setTimeout(() => setCalled(false), 3500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [called])
|
||||
|
||||
return (
|
||||
<TemplateShell def={speisekarte} content={content} fonts={fonts[content.fonts] ?? fonts.trattoria} className="bg-t-surface pb-24 [--t-safe-bottom:4.5rem]">
|
||||
<header className="bg-t-bg">
|
||||
<div className={cn(column, "pt-10 pb-6 text-center")}>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="inline-block rounded-full bg-t-accent px-3 py-1 text-xs font-bold text-t-accent-fg">{brand.table}</motion.p>
|
||||
<motion.h1 initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className={cn(heading, "mt-3 text-4xl")}>
|
||||
{brand.name}
|
||||
</motion.h1>
|
||||
<p className="mt-2 text-sm text-t-muted">{brand.note}</p>
|
||||
<p className="mt-1 flex items-center justify-center gap-1.5 text-xs text-t-muted"><WifiIcon className="size-3.5" /> {brand.wifi}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs categories={visible.map((group) => group.category)} query={query} onQuery={setQuery} filters={filters} onFilters={setFilters} />
|
||||
|
||||
{show("special") && (
|
||||
<section data-order="special" className={cn(column, "mt-5")}>
|
||||
<motion.article initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease, delay: 0.2 }} className="overflow-hidden rounded-3xl bg-t-bg shadow-sm">
|
||||
<div className="relative aspect-[16/8]">
|
||||
<TImage src={special.image} alt={special.name} eager sizes="672px" />
|
||||
<span className="absolute top-3 left-3 rounded-full bg-t-accent px-3 py-1 text-xs font-bold text-t-accent-fg">{special.title}</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 p-5">
|
||||
<div>
|
||||
<h2 className={cn(heading, "text-2xl")}>{special.name}</h2>
|
||||
<p className="mt-1 text-sm text-t-muted">{special.text}</p>
|
||||
</div>
|
||||
<p className={cn(heading, "text-xl text-t-accent")}>{special.price} €</p>
|
||||
</div>
|
||||
</motion.article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={cn(column, "mt-5 space-y-8")}>
|
||||
{visible.length === 0 && <p className="py-16 text-center text-t-muted">Nichts gefunden – probier einen anderen Filter.</p>}
|
||||
{visible.map((group) => (
|
||||
<div key={group.category} id={slug(group.category)} className="scroll-mt-32">
|
||||
<h2 className={cn(heading, "mb-3 px-1 text-2xl")}>{group.category}</h2>
|
||||
<ul className="divide-y divide-t-fg/5 overflow-hidden rounded-3xl bg-t-bg">
|
||||
{group.dishes.map((dish) => (
|
||||
<DishRow key={dish.name} dish={dish} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{show("legend") && (
|
||||
<section data-order="legend" className={cn(column, "mt-8")}>
|
||||
<p className="flex items-start gap-2 rounded-2xl bg-t-bg p-4 text-xs leading-relaxed text-t-muted">
|
||||
<InfoIcon className="mt-0.5 size-4 shrink-0" /> {legend.text}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-t-fg/10 bg-t-bg/95 p-3 backdrop-blur">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalled(true)}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-t-fg py-3.5 font-semibold text-t-bg transition-transform active:scale-[0.98]"
|
||||
>
|
||||
<BellRingIcon className="size-4" /> Service an {brand.table} rufen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{called && (
|
||||
<motion.div
|
||||
role="status"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="fixed inset-x-4 top-4 z-[70] mx-auto max-w-sm rounded-2xl bg-t-accent px-5 py-4 text-center font-semibold text-t-accent-fg shadow-xl"
|
||||
>
|
||||
Kommt sofort! Der Service ist auf dem Weg zu {brand.table}.
|
||||
<DemoNote as="span" className="mt-1 block text-xs font-normal opacity-80">Demo: Live erscheint der Ruf auf dem Tablet des Teams.</DemoNote>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Tabs({ categories, query, onQuery, filters, onFilters }: {
|
||||
categories: string[]
|
||||
query: string
|
||||
onQuery: (query: string) => void
|
||||
filters: string[]
|
||||
onFilters: (filters: string[]) => void
|
||||
}) {
|
||||
const [active, setActive] = useState(categories[0] ?? "")
|
||||
const key = categories.join("|")
|
||||
|
||||
// Die gerade sichtbare Kategorie hervorheben
|
||||
useEffect(() => {
|
||||
const sections = key.split("|").map((category) => document.getElementById(slug(category))).filter((el): el is HTMLElement => el !== null)
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]
|
||||
if (visible) setActive(visible.target.id)
|
||||
},
|
||||
{ rootMargin: "-140px 0px -60% 0px" }
|
||||
)
|
||||
sections.forEach((section) => observer.observe(section))
|
||||
return () => observer.disconnect()
|
||||
}, [key])
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-30 border-b border-t-fg/10 bg-t-bg/95 backdrop-blur">
|
||||
<div className={cn(column, "space-y-3 py-3")}>
|
||||
<label className="flex items-center gap-2 rounded-full bg-t-surface px-4 py-2.5">
|
||||
<SearchIcon className="size-4 text-t-muted" />
|
||||
<input value={query} onChange={(e) => onQuery(e.target.value)} placeholder="Gericht suchen …" aria-label="Gericht suchen" className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-t-muted" />
|
||||
</label>
|
||||
<div className="flex gap-2 overflow-x-auto [scrollbar-width:none]">
|
||||
{FILTERS.map((filter) => {
|
||||
const on = filters.includes(filter.key)
|
||||
return (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onFilters(on ? filters.filter((f) => f !== filter.key) : [...filters, filter.key])}
|
||||
className={cn("flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors", on ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15")}
|
||||
>
|
||||
<filter.icon className="size-3.5" /> {filter.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<nav className="-mx-1 flex gap-1 overflow-x-auto [scrollbar-width:none]">
|
||||
{categories.map((category) => {
|
||||
const isActive = active === slug(category)
|
||||
return (
|
||||
<a key={category} href={`#${slug(category)}`} className={cn("relative shrink-0 rounded-full px-4 py-2 text-sm font-semibold", isActive ? "text-t-accent-fg" : "text-t-muted")}>
|
||||
{isActive && <motion.span layoutId="karte-tab" className="absolute inset-0 rounded-full bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
|
||||
<span className="relative">{category}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DishRow({ dish }: { dish: Dish }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const tags = tagsOf(dish)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex w-full cursor-pointer gap-4 p-4 text-left">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{dish.name}</p>
|
||||
<p className="mt-0.5 text-sm text-t-muted">{dish.description}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="flex items-center gap-1 rounded-full bg-t-surface px-2 py-0.5 text-[11px] font-semibold capitalize">
|
||||
{tag === "scharf" ? <FlameIcon className="size-3 text-t-accent" /> : tag === "glutenfrei" ? <WheatOffIcon className="size-3" /> : <LeafIcon className="size-3 text-emerald-600" />}
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className={cn(heading, "mt-2 text-lg text-t-accent")}>{dish.price} €</p>
|
||||
</div>
|
||||
{dish.image && (
|
||||
<div className="relative size-24 shrink-0 overflow-hidden rounded-2xl">
|
||||
<TImage src={dish.image} alt={dish.name} sizes="96px" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.p initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden px-4 text-xs text-t-muted">
|
||||
<span className="block pb-4">Allergene: {dish.allergens || "keine kennzeichnungspflichtigen"}</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
105
modules/templates/catalog/verein/definition.ts
Normal file
105
modules/templates/catalog/verein/definition.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
|
||||
|
||||
const defaults = {
|
||||
brand: { name: "FC Nordstern", short: "FCN", founded: "1921", city: "Bremen", email: "info@fc-nordstern.de" },
|
||||
hero: {
|
||||
title: "Mehr als\nein *Verein.*",
|
||||
text: "Seit 1921 spielen, feiern und halten wir zusammen – mit 14 Teams, 620 Mitgliedern und einem großen Herz für den Bremer Norden.",
|
||||
cta: "Mitglied werden",
|
||||
},
|
||||
match: {
|
||||
label: "Nächstes Heimspiel",
|
||||
league: "Bremen-Liga · 11. Spieltag",
|
||||
away: "SV Blau-Weiß Lilienthal",
|
||||
awayShort: "BWL",
|
||||
date: "2026-10-24T15:00",
|
||||
dateLabel: "Sa, 24. Oktober · 15:00 Uhr",
|
||||
venue: "Sportpark am Deich",
|
||||
image: unsplash("1522778119026-d647f0596c20", 1400),
|
||||
},
|
||||
results: {
|
||||
title: "Letzte *Spiele*",
|
||||
items: [
|
||||
{ date: "17.10.", home: "TuS Komet", away: "FC Nordstern", score: "1:3" },
|
||||
{ date: "10.10.", home: "FC Nordstern", away: "SC Borgfeld", score: "2:2" },
|
||||
{ date: "03.10.", home: "FC Nordstern", away: "ATSV Habenhausen", score: "4:0" },
|
||||
{ date: "26.09.", home: "SV Hemelingen", away: "FC Nordstern", score: "1:0" },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: "Tabelle",
|
||||
items: [
|
||||
{ team: "SC Borgfeld", games: "10", points: "24" },
|
||||
{ team: "FC Nordstern", games: "10", points: "22" },
|
||||
{ team: "TuS Komet", games: "10", points: "19" },
|
||||
{ team: "SV Blau-Weiß Lilienthal", games: "10", points: "17" },
|
||||
{ team: "ATSV Habenhausen", games: "10", points: "14" },
|
||||
{ team: "SV Hemelingen", games: "10", points: "12" },
|
||||
],
|
||||
},
|
||||
teams: {
|
||||
title: "Unsere *Teams*",
|
||||
items: [
|
||||
{ name: "1. Herren", group: "Senioren", training: "Di & Do · 19:00 Uhr", image: unsplash("1517466787929-bc90951d0974", 900) },
|
||||
{ name: "Damen", group: "Senioren", training: "Mo & Mi · 19:30 Uhr", image: unsplash("1560272564-c83b66b1ad12", 900) },
|
||||
{ name: "Alte Herren", group: "Senioren", training: "Fr · 19:00 Uhr", image: unsplash("1574629810360-7efbbe195018", 900) },
|
||||
{ name: "A-Jugend", group: "Jugend", training: "Mo & Do · 18:00 Uhr", image: unsplash("1543326727-cf6c39e8f84c", 900) },
|
||||
{ name: "D-Jugend", group: "Jugend", training: "Di & Do · 17:00 Uhr", image: unsplash("1575361204480-aadea25e6e68", 900) },
|
||||
{ name: "Bambini", group: "Jugend", training: "Sa · 10:00 Uhr", image: unsplash("1579952363873-27f3bade9f55", 900) },
|
||||
],
|
||||
},
|
||||
news: {
|
||||
title: "Neuigkeiten",
|
||||
items: [
|
||||
{ date: "18.10.2026", title: "Derbysieg! 3:1 bei TuS Komet", text: "Mit einem Doppelpack von Jannik Lorenz holt die Erste drei verdiente Punkte.", image: unsplash("1560272564-c83b66b1ad12", 900) },
|
||||
{ date: "12.10.2026", title: "Neue Flutlichtanlage eingeweiht", text: "Dank unserer Partner trainieren jetzt alle Teams auch im Winter unter LED-Licht.", image: unsplash("1431324155629-1a6deb1dec8d", 900) },
|
||||
{ date: "05.10.2026", title: "Fußballcamp in den Herbstferien", text: "Fünf Tage Training, Spaß und Mittagessen für Kinder von 6 bis 12 Jahren.", image: unsplash("1575361204480-aadea25e6e68", 900) },
|
||||
],
|
||||
},
|
||||
sponsors: { title: "Unsere Partner", items: [{ name: "Hansebäcker" }, { name: "Autohaus Weser" }, { name: "Nordlicht Energie" }, { name: "Brauhaus Deichkrone" }, { name: "Elektro Möller" }] },
|
||||
membership: {
|
||||
title: "Werde Teil\nder *Familie.*",
|
||||
text: "Ob auf dem Platz, an der Seitenlinie oder beim Vereinsfest – bei uns ist jede:r willkommen.",
|
||||
items: [
|
||||
{ name: "Kinder & Jugend", price: "8", text: "bis 18 Jahre", highlight: "" },
|
||||
{ name: "Erwachsene", price: "14", text: "aktiv oder passiv", highlight: "" },
|
||||
{ name: "Familie", price: "25", text: "alle im Haushalt", highlight: "ja" },
|
||||
{ name: "Fördermitglied", price: "5", text: "ohne Spielbetrieb", highlight: "" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const verein = defineTemplate({
|
||||
key: "verein",
|
||||
name: "Anstoß",
|
||||
industry: "Sportverein & Verein",
|
||||
description: "Laut und gemeinschaftlich: Spieltagskarte mit Countdown, Ergebnisse und Tabelle, Teams mit Trainingszeiten, News, Sponsoren-Laufband und Mitgliedsantrag.",
|
||||
cover: unsplash("1522778119026-d647f0596c20", 1200),
|
||||
samples: {
|
||||
inquiry: "Hallo, mein Sohn (7) möchte gern Fußball spielen. Gibt es noch Platz in der F-Jugend, wann ist Training, und kann er erst einmal zum Probetraining kommen? Viele Grüße, Nicole Brandt",
|
||||
topic: "Heimspiel-Vorschau: Derby gegen Lilienthal am Samstag",
|
||||
},
|
||||
palettes: [
|
||||
{ key: "blaurot", label: "Blau-Rot", bg: "#f3f5f9", fg: "#0b1f4b", muted: "#56607a", surface: "#e1e6f0", accent: "#d81e2c" },
|
||||
{ key: "gruenweiss", label: "Grün-Weiß", bg: "#f4f8f4", fg: "#0e3b1f", muted: "#567060", surface: "#dde9df", accent: "#0e8a3e" },
|
||||
{ key: "schwarzgelb", label: "Schwarz-Gelb", dark: true, bg: "#0d0d0d", fg: "#f5f5f0", muted: "#8f8f88", surface: "#1a1a1a", accent: "#ffd400" },
|
||||
],
|
||||
fonts: [
|
||||
{ key: "stadion", label: "Teko & Roboto Condensed" },
|
||||
{ key: "kurve", label: "Bowlby One & Rubik" },
|
||||
],
|
||||
sections: describeSections(defaults, {
|
||||
brand: "Verein",
|
||||
hero: "Startbereich",
|
||||
match: "Nächstes Spiel",
|
||||
results: { label: "Ergebnisse", optional: true, items: { items: "Spiel" } },
|
||||
table: { label: "Tabelle", optional: true, items: { items: "Platz" } },
|
||||
teams: { label: "Teams", optional: true, items: { items: "Team" } },
|
||||
news: { label: "Neuigkeiten", optional: true, items: { items: "Beitrag" } },
|
||||
sponsors: { label: "Partner", optional: true, items: { items: "Partner" } },
|
||||
membership: { label: "Mitgliedschaft", items: { items: "Beitrag" } },
|
||||
}),
|
||||
defaults,
|
||||
})
|
||||
|
||||
export type VereinData = typeof verein.defaults
|
||||
13
modules/templates/catalog/verein/fonts.ts
Normal file
13
modules/templates/catalog/verein/fonts.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Bowlby_One, Roboto_Condensed, Rubik, Teko } from "next/font/google"
|
||||
import type { FontPair } from "../../lib/types"
|
||||
|
||||
// preload: false – siehe restaurant/fonts.ts
|
||||
const teko = Teko({ subsets: ["latin"], variable: "--font-verein-teko", preload: false })
|
||||
const robotoCondensed = Roboto_Condensed({ subsets: ["latin"], variable: "--font-verein-roboto", preload: false })
|
||||
const bowlby = Bowlby_One({ subsets: ["latin"], weight: "400", variable: "--font-verein-bowlby", preload: false })
|
||||
const rubik = Rubik({ subsets: ["latin"], variable: "--font-verein-rubik", preload: false })
|
||||
|
||||
export const fonts: Record<string, FontPair> = {
|
||||
stadion: { className: `${teko.variable} ${robotoCondensed.variable}`, display: "var(--font-verein-teko)", body: "var(--font-verein-roboto)" },
|
||||
kurve: { className: `${bowlby.variable} ${rubik.variable}`, display: "var(--font-verein-bowlby)", body: "var(--font-verein-rubik)" },
|
||||
}
|
||||
313
modules/templates/catalog/verein/template.tsx
Normal file
313
modules/templates/catalog/verein/template.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import { ArrowRightIcon, CalendarIcon, ClockIcon, MapPinIcon, TrophyIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateProps } from "../../lib/types"
|
||||
import { DemoForm } from "../shared/demo-form"
|
||||
import { TField, TSelect } from "../shared/fields"
|
||||
import { useCountdown } from "../shared/hooks"
|
||||
import { TImage } from "../shared/media"
|
||||
import { Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
||||
import { TemplateShell } from "../shared/shell"
|
||||
import { verein, type VereinData } from "./definition"
|
||||
import { fonts } from "./fonts"
|
||||
|
||||
const em = "text-t-accent"
|
||||
const container = "mx-auto w-full max-w-[1320px] px-5 md:px-8"
|
||||
const display = "font-t-display uppercase [line-height:0.9]"
|
||||
const stripes = "bg-[repeating-linear-gradient(-45deg,transparent_0,transparent_14px,color-mix(in_oklab,var(--t-fg)_5%,transparent)_14px,color-mix(in_oklab,var(--t-fg)_5%,transparent)_28px)]"
|
||||
const ALL = "Alle"
|
||||
|
||||
function Crest({ short, className, muted }: { short: string; className?: string; muted?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 64 74" aria-hidden className={className}>
|
||||
<path d="M32 2 L60 10 V36 C60 54 47 66 32 72 C17 66 4 54 4 36 V10 Z" className={muted ? "fill-t-fg/15" : "fill-t-accent"} />
|
||||
<path d="M32 8 L54 14 V36 C54 50 44 60 32 65 C20 60 10 50 10 36 V14 Z" fill="none" className={muted ? "stroke-t-fg/30" : "stroke-t-accent-fg/60"} strokeWidth="2" />
|
||||
<text x="32" y="44" textAnchor="middle" className={cn("font-t-display font-bold", muted ? "fill-t-fg" : "fill-t-accent-fg")} style={{ fontSize: short.length > 3 ? 14 : 18 }}>
|
||||
{short}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Countdown({ date }: { date: string }) {
|
||||
const time = useCountdown(date)
|
||||
const parts: [number | undefined, string][] = [
|
||||
[time?.days, "Tage"],
|
||||
[time?.hours, "Std"],
|
||||
[time?.minutes, "Min"],
|
||||
[time?.seconds, "Sek"],
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{parts.map(([value, label]) => (
|
||||
<div key={label} className="rounded-xl bg-black/50 py-2 text-center backdrop-blur">
|
||||
<p className={cn(display, "text-4xl tabular-nums")}>{value === undefined ? "--" : String(value).padStart(2, "0")}</p>
|
||||
<p className="text-[10px] uppercase tracking-widest opacity-70">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VereinTemplate({ content }: TemplateProps<VereinData>) {
|
||||
const { brand, hero, match, results, table, teams, news, sponsors, membership } = content.data
|
||||
const show = (key: string) => !content.hidden.includes(key)
|
||||
|
||||
return (
|
||||
<TemplateShell def={verein} content={content} fonts={fonts[content.fonts] ?? fonts.stadion}>
|
||||
<Nav brand={brand} cta={hero.cta} show={show} />
|
||||
|
||||
<section id="top" className={cn("relative overflow-hidden pt-28 pb-16 md:pt-36", stripes)}>
|
||||
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-[1fr_1.05fr]")}>
|
||||
<div>
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className="mb-5 flex items-center gap-2 font-semibold uppercase tracking-widest text-t-accent">
|
||||
<TrophyIcon className="size-4" /> Seit {brand.founded} · {brand.city}
|
||||
</motion.p>
|
||||
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(display, "text-[clamp(3.75rem,10vw,9rem)]")} />
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.7 }}>
|
||||
<p className="mt-6 max-w-md text-lg text-t-muted">{hero.text}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a href="#mitglied" className="inline-flex items-center gap-2 bg-t-accent px-7 py-4 font-bold text-t-accent-fg uppercase">{hero.cta} <ArrowRightIcon className="size-4" /></a>
|
||||
{show("teams") && <a href="#teams" className="border-2 border-t-fg px-7 py-4 font-bold uppercase">Probetraining</a>}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 40, rotate: 2 }} animate={{ opacity: 1, y: 0, rotate: 0 }} transition={{ duration: 1, ease, delay: 0.3 }} className="relative overflow-hidden rounded-3xl text-white shadow-2xl">
|
||||
<TImage src={match.image} eager sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/60 to-black/85" />
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="flex items-center justify-between text-sm font-semibold uppercase tracking-wider">
|
||||
<span className="rounded-full bg-t-accent px-3 py-1 text-t-accent-fg">{match.label}</span>
|
||||
<span className="opacity-80">{match.league}</span>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-[1fr_auto_1fr] items-center gap-4 text-center">
|
||||
<div>
|
||||
<Crest short={brand.short} className="mx-auto h-24 w-20" />
|
||||
<p className={cn(display, "mt-3 text-2xl")}>{brand.name}</p>
|
||||
</div>
|
||||
<span className={cn(display, "text-5xl opacity-80")}>vs</span>
|
||||
<div>
|
||||
<svg viewBox="0 0 64 74" aria-hidden className="mx-auto h-24 w-20">
|
||||
<path d="M32 2 L60 10 V36 C60 54 47 66 32 72 C17 66 4 54 4 36 V10 Z" className="fill-white/20" />
|
||||
<text x="32" y="44" textAnchor="middle" className="fill-white font-t-display font-bold" style={{ fontSize: 18 }}>{match.awayShort}</text>
|
||||
</svg>
|
||||
<p className={cn(display, "mt-3 text-2xl")}>{match.away}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8"><Countdown date={match.date} /></div>
|
||||
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2 text-sm opacity-90">
|
||||
<span className="flex items-center gap-2"><CalendarIcon className="size-4" /> {match.dateLabel}</span>
|
||||
<span className="flex items-center gap-2"><MapPinIcon className="size-4" /> {match.venue}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show("results") && (
|
||||
<section data-order="results" className={cn(container, "py-20")}>
|
||||
<RevealWords text={results.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")} />
|
||||
<div className="mt-8 grid gap-3 md:grid-cols-2">
|
||||
{results.items.map((game, i) => {
|
||||
const [home, away] = game.score.split(":").map((v) => Number(v))
|
||||
const ours = game.home === brand.name ? home - away : away - home
|
||||
const outcome = Number.isNaN(ours) ? "" : ours > 0 ? "S" : ours < 0 ? "N" : "U"
|
||||
return (
|
||||
<Reveal key={i} delay={(i % 2) * 0.06}>
|
||||
<div className="flex items-center gap-4 rounded-2xl bg-t-surface p-4">
|
||||
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg font-bold", outcome === "S" ? "bg-emerald-600 text-white" : outcome === "N" ? "bg-t-accent text-t-accent-fg" : "bg-t-fg/15")}>{outcome}</span>
|
||||
<span className="w-14 shrink-0 text-sm text-t-muted">{game.date}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{game.home} – {game.away}</span>
|
||||
<span className={cn(display, "text-3xl tabular-nums")}>{game.score}</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("table") && (
|
||||
<section data-order="table" className={cn(container, "pb-20")}>
|
||||
<h2 className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")}>{table.title}</h2>
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-t-fg/10">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-t-fg text-sm text-t-bg uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Pl.</th>
|
||||
<th className="px-4 py-3">Verein</th>
|
||||
<th className="px-4 py-3 text-right">Sp.</th>
|
||||
<th className="px-4 py-3 text-right">Pkt.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.items.map((row, i) => {
|
||||
const ours = row.team === brand.name
|
||||
return (
|
||||
<motion.tr key={i} initial={{ opacity: 0, x: -10 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.05 }} className={cn("border-t border-t-fg/10", ours && "bg-t-accent font-bold text-t-accent-fg")}>
|
||||
<td className={cn(display, "px-4 py-3 text-2xl")}>{i + 1}</td>
|
||||
<td className="px-4 py-3">{row.team}</td>
|
||||
<td className="px-4 py-3 text-right">{row.games}</td>
|
||||
<td className={cn(display, "px-4 py-3 text-right text-2xl")}>{row.points}</td>
|
||||
</motion.tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("teams") && <Teams data-order="teams" teams={teams} />}
|
||||
|
||||
{show("news") && (
|
||||
<section data-order="news" className={cn(container, "py-20")}>
|
||||
<RevealWords text={news.title} className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")} />
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-3">
|
||||
{news.items.map((post, i) => (
|
||||
<Reveal key={i} delay={i * 0.08}>
|
||||
<article className="group h-full overflow-hidden rounded-2xl bg-t-surface">
|
||||
<div className="relative aspect-[16/10] overflow-hidden">
|
||||
<TImage src={post.image} alt={post.title} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-t-muted">{post.date}</p>
|
||||
<h3 className={cn(display, "mt-1 text-3xl")}>{post.title}</h3>
|
||||
<p className="mt-2 text-sm text-t-muted">{post.text}</p>
|
||||
</div>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{show("sponsors") && (
|
||||
<section data-order="sponsors" className="border-y border-t-fg/10 py-8">
|
||||
<p className="mb-4 text-center text-sm font-semibold uppercase tracking-widest text-t-muted">{sponsors.title}</p>
|
||||
<Marquee duration={28}>
|
||||
{sponsors.items.map((sponsor, i) => (
|
||||
<span key={i} className={cn(display, "px-10 text-4xl text-t-fg/45")}>{sponsor.name}</span>
|
||||
))}
|
||||
</Marquee>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section id="mitglied" className={cn("py-20 md:py-28", stripes)}>
|
||||
<div className={cn(container, "grid gap-12 lg:grid-cols-2")}>
|
||||
<div>
|
||||
<RevealWords text={membership.title} emClassName={em} className={cn(display, "text-[clamp(3rem,7vw,6rem)]")} />
|
||||
<p className="mt-5 max-w-md text-lg text-t-muted">{membership.text}</p>
|
||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
||||
{membership.items.map((tier, i) => (
|
||||
<div key={i} className={cn("rounded-2xl p-5", tier.highlight.trim() ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
|
||||
<p className="font-bold">{tier.name}</p>
|
||||
<p className={cn(display, "mt-2 text-5xl")}>{tier.price} €</p>
|
||||
<p className="text-sm opacity-75">pro Monat · {tier.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<DemoForm
|
||||
className="grid gap-4 rounded-3xl bg-t-bg p-6 shadow-xl sm:grid-cols-2 md:p-8"
|
||||
success={<p className={cn(display, "py-10 text-5xl")}>Willkommen <span className={em}>im Team!</span></p>}
|
||||
>
|
||||
<p className={cn(display, "text-4xl sm:col-span-2")}>Mitgliedsantrag</p>
|
||||
<TField label="Vor- und Nachname" name="name" required autoComplete="name" />
|
||||
<TField label="Geburtsdatum" name="birthday" type="date" required />
|
||||
<TSelect label="Beitrag" name="tier" options={membership.items.map((t) => t.name)} />
|
||||
<TSelect label="Abteilung" name="team" options={[...teams.items.map((t) => t.name), "Noch offen"]} />
|
||||
<TField label="E-Mail" name="email" type="email" required autoComplete="email" className="sm:col-span-2" />
|
||||
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Antrag absenden</button>
|
||||
</DemoForm>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="bg-t-fg text-t-bg">
|
||||
<div className={cn(container, "flex flex-col items-center gap-4 py-10 text-sm md:flex-row md:justify-between")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Crest short={brand.short} className="h-10 w-9" />
|
||||
<p>{brand.name} von {brand.founded} e. V. · {brand.email}</p>
|
||||
</div>
|
||||
<div className="flex gap-6 opacity-70">
|
||||
<a href="#">Impressum</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
<a href="#">Satzung</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</TemplateShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Nav({ brand, cta, show }: { brand: VereinData["brand"]; cta: string; show: (key: string) => boolean }) {
|
||||
const { scrolled, hidden } = useScrollNav()
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
animate={{ y: hidden ? "-100%" : "0%" }}
|
||||
transition={{ duration: 0.45, ease }}
|
||||
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled && "bg-t-bg/90 shadow-sm backdrop-blur-xl")}
|
||||
>
|
||||
<nav className={cn(container, "flex h-20 items-center justify-between gap-4")}>
|
||||
<a href="#top" className="flex items-center gap-3">
|
||||
<Crest short={brand.short} className="h-12 w-11" />
|
||||
<span className="leading-none">
|
||||
<span className={cn(display, "block text-3xl")}>{brand.name}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-t-muted">seit {brand.founded}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div className="hidden gap-7 font-semibold uppercase md:flex">
|
||||
{show("results") && <a href="#top" className="hover:text-t-accent">Spiele</a>}
|
||||
{show("teams") && <a href="#teams" className="hover:text-t-accent">Teams</a>}
|
||||
<a href="#mitglied" className="hover:text-t-accent">Mitglied werden</a>
|
||||
</div>
|
||||
<a href="#mitglied" className="bg-t-accent px-5 py-2.5 text-sm font-bold text-t-accent-fg uppercase">{cta}</a>
|
||||
</nav>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
|
||||
function Teams({ teams }: { teams: VereinData["teams"] }) {
|
||||
const groups = [ALL, ...new Set(teams.items.map((team) => team.group))]
|
||||
const [group, setGroup] = useState(ALL)
|
||||
const visible = teams.items.filter((team) => group === ALL || team.group === group)
|
||||
|
||||
return (
|
||||
<section id="teams" className="bg-t-surface py-20 md:py-28">
|
||||
<div className={container}>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<RevealWords text={teams.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")} />
|
||||
<div className="flex gap-2">
|
||||
{groups.map((option) => (
|
||||
<button key={option} type="button" aria-pressed={group === option} onClick={() => setGroup(option)} className={cn("cursor-pointer px-5 py-2.5 font-bold uppercase transition-colors", group === option ? "bg-t-accent text-t-accent-fg" : "bg-t-bg hover:bg-t-fg/10")}>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div layout className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visible.map((team) => (
|
||||
<motion.article layout key={team.name} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} className="group relative aspect-[4/3] overflow-hidden rounded-2xl text-white">
|
||||
<TImage src={team.image} alt={team.name} sizes="(min-width: 1024px) 33vw, 50vw" className="transition-transform duration-700 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest opacity-80">{team.group}</p>
|
||||
<h3 className={cn(display, "text-4xl")}>{team.name}</h3>
|
||||
<p className="mt-1 flex items-center gap-2 text-sm"><ClockIcon className="size-4" /> {team.training}</p>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
393
modules/templates/export/project.ts
Normal file
393
modules/templates/export/project.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import "server-only"
|
||||
import { statSync } from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { strToU8, zipSync, type Zippable } from "fflate"
|
||||
import portal from "@/package.json"
|
||||
import { brandName } from "../lib/content"
|
||||
import { getTemplate } from "../lib/registry"
|
||||
import { themeColors } from "../lib/theme"
|
||||
import type { TemplateContent, TemplateDefinition } from "../lib/types"
|
||||
|
||||
// Exportiert eine Variante als eigenständiges Next.js-Projekt: Die Quelltexte der Vorlage werden ab template.tsx
|
||||
// über ihre Imports eingesammelt (nichts muss doppelt gepflegt werden), Inhalte landen in content/site.json.
|
||||
// Die Dateien liest der Server zur Laufzeit – siehe outputFileTracingIncludes in next.config.ts. Alle Pfade beginnen mit
|
||||
// einem festen Ordner, sonst bezieht Turbopack beim Build das ganze Projekt in die Server-Dateien ein.
|
||||
|
||||
const SOURCE = "modules/templates/"
|
||||
const SOURCE_DIR = path.join(process.cwd(), "modules/templates")
|
||||
const UTILS_FILE = path.join(process.cwd(), "lib/utils.ts")
|
||||
// Eigene Bilder der Varianten liegen unter /pictures/templates/<kunde>/
|
||||
const IMAGE_PREFIX = "/pictures/templates/"
|
||||
const IMAGE_DIR = path.join(process.cwd(), "public/pictures/templates")
|
||||
const TARGET = "site/"
|
||||
// Bündeln alle Vorlagen bzw. Schriften – im Export entstehen sie neu, nur mit dem, was die Variante nutzt
|
||||
const REGISTRY = `${SOURCE}lib/registry.ts`
|
||||
const FONT_LIBRARY = `${SOURCE}catalog/font-library.ts`
|
||||
const SPECIFIER = /\bfrom\s+"([^"]+)"|\bimport\s*\(?\s*"([^"]+)"/g
|
||||
const DEV_DEPENDENCIES = ["@tailwindcss/postcss", "@types/node", "@types/react", "@types/react-dom", "tailwindcss", "typescript"]
|
||||
|
||||
const versions: Record<string, string> = { ...portal.dependencies, ...portal.devDependencies }
|
||||
|
||||
// Nur die Vorlagen-Bausteine und cn() gehören ins Projekt – KI, Export und sonstiger Portal-Code nicht (null)
|
||||
function diskPath(file: string) {
|
||||
if (file === "lib/utils.ts") return UTILS_FILE
|
||||
if (file.startsWith(SOURCE) && !file.startsWith(`${SOURCE}ai/`) && !file.startsWith(`${SOURCE}export/`)) {
|
||||
return path.join(SOURCE_DIR, file.slice(SOURCE.length))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function exportPath(file: string) {
|
||||
return file.startsWith(SOURCE) ? TARGET + file.slice(SOURCE.length) : file
|
||||
}
|
||||
|
||||
function isFile(absolute: string | null) {
|
||||
if (!absolute) return false
|
||||
try {
|
||||
return statSync(absolute).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Import ohne Endung ("./motion", "@/lib/utils") → erlaubte Datei auf der Platte
|
||||
function resolveModule(base: string) {
|
||||
const candidates = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"].map((extension) => base + extension)
|
||||
const file = candidates.find((candidate) => isFile(diskPath(candidate)))
|
||||
if (file) return file
|
||||
throw new Error(candidates.some((candidate) => diskPath(candidate))
|
||||
? `Export: ${base} nicht gefunden`
|
||||
: `Export: ${base} gehört nicht in ein eigenständiges Projekt`)
|
||||
}
|
||||
|
||||
async function readSource(file: string) {
|
||||
const absolute = diskPath(file)
|
||||
if (!absolute) throw new Error(`Export: ${file} gehört nicht in ein eigenständiges Projekt`)
|
||||
return readFile(absolute, "utf8")
|
||||
}
|
||||
|
||||
function packageName(specifier: string) {
|
||||
const parts = specifier.split("/")
|
||||
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
|
||||
}
|
||||
|
||||
async function collectSources(entries: string[], generated: Record<string, string>) {
|
||||
const files: Record<string, string> = {}
|
||||
const packages = new Set(["next", "react", "react-dom"])
|
||||
const queue = [...entries]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const file = queue.pop()!
|
||||
const target = exportPath(file)
|
||||
if (target in files) continue
|
||||
|
||||
const source = generated[file] ?? (await readSource(file))
|
||||
files[target] = source
|
||||
|
||||
for (const match of source.matchAll(SPECIFIER)) {
|
||||
const specifier = match[1] ?? match[2]
|
||||
if (specifier.startsWith(".")) queue.push(resolveModule(path.posix.join(path.posix.dirname(file), specifier)))
|
||||
else if (specifier.startsWith("@/")) queue.push(resolveModule(specifier.slice(2)))
|
||||
else packages.add(packageName(specifier))
|
||||
}
|
||||
}
|
||||
|
||||
return { files, packages }
|
||||
}
|
||||
|
||||
function registrySource(keys: string[]) {
|
||||
return [
|
||||
...keys.map((key) => `import { ${key} } from "../catalog/${key}/definition"`),
|
||||
`import type { TemplateDefinition } from "./types"`,
|
||||
"",
|
||||
"// Beim Export erzeugt: nur die Vorlagen, deren Inhalte bzw. Paletten diese Seite nutzt",
|
||||
`export const templates: TemplateDefinition[] = [${keys.join(", ")}]`,
|
||||
"",
|
||||
"export function getTemplate(key: string) {",
|
||||
" return templates.find((template) => template.key === key)",
|
||||
"}",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function fontLibrarySource(keys: string[]) {
|
||||
return [
|
||||
`import type { FontPair } from "../lib/types"`,
|
||||
...keys.map((key) => `import { fonts as ${key} } from "./${key}/fonts"`),
|
||||
"",
|
||||
"// Beim Export erzeugt: Schriftpaare anderer Vorlagen, die diese Seite nutzt (\"vorlage:schlüssel\")",
|
||||
`const library: Record<string, Record<string, FontPair>> = ${keys.length > 0 ? `{ ${keys.join(", ")} }` : "{}"}`,
|
||||
"",
|
||||
"export function libraryFont(key: string): FontPair | undefined {",
|
||||
` const [template, pair] = key.split(":")`,
|
||||
" return library[template]?.[pair]",
|
||||
"}",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// "vorlage:schlüssel" aus der Bibliothek → Schlüssel der fremden Vorlage
|
||||
function libraryTemplate(value: string) {
|
||||
const [key] = value.split(":")
|
||||
return value.includes(":") && getTemplate(key) ? [key] : []
|
||||
}
|
||||
|
||||
// Eigene Bilder, die in den Inhalten vorkommen (Pfad relativ zu IMAGE_DIR)
|
||||
function localImages(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
if (!value.startsWith(IMAGE_PREFIX)) return []
|
||||
const file = path.posix.normalize(value.split(/[?#]/)[0].slice(IMAGE_PREFIX.length))
|
||||
return !file.startsWith("..") && isFile(path.join(IMAGE_DIR, file)) ? [file] : []
|
||||
}
|
||||
if (Array.isArray(value)) return value.flatMap(localImages)
|
||||
if (value && typeof value === "object") return Object.values(value).flatMap(localImages)
|
||||
return []
|
||||
}
|
||||
|
||||
export function exportSlug(name: string, fallback: string) {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.slice(0, 60)
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return slug || fallback
|
||||
}
|
||||
|
||||
const plain = (text: string) => text.replace(/\*/g, "").replace(/\s+/g, " ").trim()
|
||||
|
||||
export async function buildProject(def: TemplateDefinition, content: TemplateContent, variantName: string) {
|
||||
const slug = exportSlug(variantName, def.key)
|
||||
const component = `${def.key[0].toUpperCase()}${def.key.slice(1)}Template`
|
||||
const templateFile = `${SOURCE}catalog/${def.key}/template.tsx`
|
||||
|
||||
const { files, packages } = await collectSources(
|
||||
[templateFile, `${SOURCE}catalog/${def.key}/definition.ts`, `${SOURCE}lib/content.ts`],
|
||||
{
|
||||
[REGISTRY]: registrySource([...new Set([def.key, ...libraryTemplate(content.palette)])]),
|
||||
[FONT_LIBRARY]: fontLibrarySource(libraryTemplate(content.fonts)),
|
||||
}
|
||||
)
|
||||
if (!files[exportPath(templateFile)].includes(`export function ${component}(`)) {
|
||||
throw new Error(`Export: ${component} fehlt in ${templateFile}`)
|
||||
}
|
||||
|
||||
const pick = (names: string[]) =>
|
||||
Object.fromEntries(
|
||||
names.toSorted().map((name) => {
|
||||
if (!versions[name]) throw new Error(`Export: Version für ${name} unbekannt`)
|
||||
return [name, versions[name]]
|
||||
})
|
||||
)
|
||||
|
||||
const title = brandName(content) ?? def.name
|
||||
const hero = content.data.hero
|
||||
const description = hero && typeof hero.text === "string" ? plain(hero.text) : ""
|
||||
const theme = themeColors(def, content)
|
||||
const unsplash = JSON.stringify(content.data).includes("images.unsplash.com")
|
||||
// Ohne KI-Einstellungen: Chat & Co. brauchen das Portal-Backend
|
||||
const site = {
|
||||
palette: content.palette,
|
||||
fonts: content.fonts,
|
||||
colors: content.colors,
|
||||
hidden: content.hidden,
|
||||
order: content.order,
|
||||
style: content.style,
|
||||
data: content.data,
|
||||
}
|
||||
|
||||
const project: Record<string, string> = {
|
||||
...files,
|
||||
"package.json": `${JSON.stringify(
|
||||
{
|
||||
name: slug,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
scripts: { dev: "next dev", build: "next build", start: "next start" },
|
||||
dependencies: pick([...packages]),
|
||||
devDependencies: pick(DEV_DEPENDENCIES),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"content/site.json": `${JSON.stringify(site, null, 2)}\n`,
|
||||
"app/page.tsx": [
|
||||
`import site from "@/content/site.json"`,
|
||||
`import { ${def.key} } from "@/site/catalog/${def.key}/definition"`,
|
||||
`import { ${component} } from "@/site/catalog/${def.key}/template"`,
|
||||
`import { resolveContent } from "@/site/lib/content"`,
|
||||
"",
|
||||
"// Texte, Bilder, Farben und Reihenfolge der Sektionen stehen in content/site.json",
|
||||
"export default function Home() {",
|
||||
` return <${component} content={resolveContent(${def.key}, site)} />`,
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"app/layout.tsx": [
|
||||
`import type { Metadata } from "next"`,
|
||||
`import "./globals.css"`,
|
||||
"",
|
||||
"export const metadata: Metadata = {",
|
||||
` title: ${JSON.stringify(title)},`,
|
||||
...(description ? [` description: ${JSON.stringify(description)},`] : []),
|
||||
"}",
|
||||
"",
|
||||
"export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {",
|
||||
" return (",
|
||||
` <html lang="de">`,
|
||||
" <body>{children}</body>",
|
||||
" </html>",
|
||||
" )",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"app/globals.css": `@import "tailwindcss";
|
||||
|
||||
/* Farben und Schriften der Vorlage – site/catalog/shared/shell.tsx setzt die Variablen (bg-t-accent, font-t-display, …) */
|
||||
@theme inline {
|
||||
--color-t-bg: var(--t-bg);
|
||||
--color-t-fg: var(--t-fg);
|
||||
--color-t-muted: var(--t-muted);
|
||||
--color-t-surface: var(--t-surface);
|
||||
--color-t-accent: var(--t-accent);
|
||||
--color-t-accent-fg: var(--t-accent-fg);
|
||||
--font-t-display: var(--t-display);
|
||||
--font-t-body: var(--t-body);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background: ${theme.bg};
|
||||
color-scheme: ${theme.dark ? "dark" : "light"};
|
||||
}
|
||||
* {
|
||||
border-color: color-mix(in oklab, var(--t-fg, currentColor) 12%, transparent);
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--t-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS zoomt beim Fokussieren in Felder mit < 16px Schrift */
|
||||
@media (pointer: coarse) {
|
||||
input:not([type="checkbox"], [type="radio"], [type="file"]), select, textarea {
|
||||
font-size: max(16px, 1em);
|
||||
}
|
||||
}
|
||||
`,
|
||||
"next.config.ts": `import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
// Platzhalterbilder; Bild-Links anderer Seiten lädt site/catalog/shared/media.tsx unoptimiert
|
||||
remotePatterns: [{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" }],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
`,
|
||||
"postcss.config.mjs": `const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
"tsconfig.json": `${JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
target: "ES2017",
|
||||
lib: ["dom", "dom.iterable", "esnext"],
|
||||
allowJs: true,
|
||||
skipLibCheck: true,
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
esModuleInterop: true,
|
||||
module: "esnext",
|
||||
moduleResolution: "bundler",
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
jsx: "react-jsx",
|
||||
incremental: true,
|
||||
plugins: [{ name: "next" }],
|
||||
paths: { "@/*": ["./*"] },
|
||||
},
|
||||
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||
exclude: ["node_modules"],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
".gitignore": ["node_modules", ".next", "out", "next-env.d.ts", "*.tsbuildinfo", ".env*", ".DS_Store", ""].join("\n"),
|
||||
"README.md": [
|
||||
`# ${title}`,
|
||||
"",
|
||||
`Eigenständiges Next.js-Projekt, exportiert aus dem Since3-Portal (Vorlage „${def.name}“, Variante „${variantName}“).`,
|
||||
"",
|
||||
"## Starten",
|
||||
"",
|
||||
"```bash",
|
||||
"npm install",
|
||||
"npm run dev # http://localhost:3000",
|
||||
"npm run build && npm start",
|
||||
"```",
|
||||
"",
|
||||
"## Inhalte bearbeiten",
|
||||
"",
|
||||
"Alle Texte, Bilder, Farben und die Reihenfolge der Sektionen stehen in `content/site.json`.",
|
||||
"",
|
||||
"- `*Wort*` hebt ein Wort hervor, `\\n` erzeugt einen Zeilenumbruch.",
|
||||
"- `hidden` blendet optionale Sektionen aus, `order` legt ihre Reihenfolge fest.",
|
||||
"- `palette` und `fonts` wählen Farbpalette und Schriftpaar (siehe `definition.ts`), `colors` überschreibt einzelne Farben (`#rrggbb`).",
|
||||
"- `style.images` (original, mono, warm, soft, contrast) und `style.motion` (full, subtle) steuern Bildstil und Animationen.",
|
||||
"",
|
||||
"## Aufbau",
|
||||
"",
|
||||
"| Pfad | Inhalt |",
|
||||
"| --- | --- |",
|
||||
"| `app/` | Seite, Layout und globale Styles |",
|
||||
"| `content/site.json` | Inhalte der Seite |",
|
||||
`| \`site/catalog/${def.key}/\` | Die Vorlage: \`template.tsx\` (Aufbau), \`definition.ts\` (Standardinhalte, Paletten), \`fonts.ts\` (Schriften) |`,
|
||||
"| `site/catalog/shared/` | Gemeinsame Bausteine: Animationen, Bilder, Formularfelder |",
|
||||
"| `site/lib/` | Typen und Inhalts-Logik |",
|
||||
"| `public/` | Eigene Bilder |",
|
||||
"",
|
||||
"## Vor dem Livegang",
|
||||
"",
|
||||
"- [ ] **Formulare anbinden:** Sie zeigen nach dem Absenden nur eine Bestätigung und senden noch nichts (`site/catalog/shared/demo-form.tsx` und Formulare in `template.tsx`), z. B. per Server Action mit E-Mail-Versand.",
|
||||
...(unsplash ? ["- [ ] **Platzhalterbilder ersetzen:** Bilder von images.unsplash.com durch eigene Fotos ersetzen."] : []),
|
||||
"- [ ] **Impressum und Datenschutzerklärung** anlegen und im Footer verlinken.",
|
||||
"- [ ] **Titel und Beschreibung** für Suchmaschinen in `app/layout.tsx` prüfen.",
|
||||
"",
|
||||
"KI-Funktionen aus der Demo (Chat, Telefon) sind nicht enthalten – sie laufen über das Since3-Portal.",
|
||||
"",
|
||||
"## Deployment",
|
||||
"",
|
||||
"Läuft auf jedem Hosting mit Node.js-Unterstützung für Next.js, z. B. Vercel: Repository verbinden und deployen.",
|
||||
"",
|
||||
].join("\n"),
|
||||
}
|
||||
|
||||
const zip: Zippable = {}
|
||||
for (const [file, source] of Object.entries(project)) zip[`${slug}/${file}`] = strToU8(source)
|
||||
for (const image of new Set(localImages(content.data))) {
|
||||
zip[`${slug}/public${IMAGE_PREFIX}${image}`] = [new Uint8Array(await readFile(path.join(IMAGE_DIR, image))), { level: 0 }]
|
||||
}
|
||||
|
||||
return { fileName: `${slug}.zip`, zip: zipSync(zip, { level: 6 }) }
|
||||
}
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import type { CSSProperties } from "react"
|
||||
import type { AiSettings, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition } from "./types"
|
||||
import type { AiSettings, ColorOverrides, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition, TemplateStyle } from "./types"
|
||||
|
||||
const MAX_TEXT = 4000
|
||||
const MAX_ITEMS = 40
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i
|
||||
const LIBRARY_KEY = /^[a-z0-9]+:[a-z0-9]+$/
|
||||
export const IMAGE_STYLES: TemplateStyle["images"][] = ["original", "mono", "warm", "soft", "contrast"]
|
||||
|
||||
function libraryKey(value: unknown, own: { key: string }[]) {
|
||||
if (typeof value !== "string") return null
|
||||
return own.some((item) => item.key === value) || LIBRARY_KEY.test(value) ? value : null
|
||||
}
|
||||
|
||||
export const RICH_HINT = "Enter = neue Zeile, *Wort* = hervorgehoben"
|
||||
|
||||
|
|
@ -33,11 +39,23 @@ const FIELD_LABELS: Record<string, string> = {
|
|||
discount: "Rabatt bei jährlicher Zahlung (%)", button: "Button", dateLabel: "Datum (Anzeige)", date: "Startzeit (JJJJ-MM-TTTHH:MM)",
|
||||
before: "Beschriftung vorher", after: "Beschriftung nachher", closing: "Schlusssatz", signature: "Unterschrift", book: "Buchtitel",
|
||||
factor: "Ersparnis-Faktor (0 bis 1)", min: "Minimum (€)", max: "Maximum (€)", sqm: "Wohnfläche (m²)", instagram: "Instagram",
|
||||
km: "Kilometerstand", power: "Leistung", gearbox: "Getriebe", payload: "Nutzlast / zGG", condition: "Zustand", rate: "Zinssatz (% p. a.)",
|
||||
color: "Farbe (#rrggbb)", x: "Position waagerecht (%)", y: "Position senkrecht (%)", grade: "Pflegegrad", amount: "Betrag (€)",
|
||||
emergency: "Hinweis in der Notruf-Leiste", areas: "Einzugsgebiet (PLZ-Anfänge, mit Komma)", strip: "Bilder", variants: "Farbvarianten",
|
||||
sticker: "Sticker", stickerSecondary: "Zweiter Sticker", status: "Status (z. B. fast weg, ausverkauft)", handle: "Social-Handle",
|
||||
offer: "Hinweisleiste", oldPrice: "Alter Preis", breakdown: "Verteilung 5 bis 1 Sterne in % (mit Komma)", box: "Lieferumfang (eine pro Zeile)",
|
||||
city: "Stadt", distance: "Entfernung", score: "Bewertungsnote", guests: "Gäste", size: "Größe", subtitle: "Unterzeile", avatar: "Profilbild",
|
||||
bio: "Kurzbeschreibung", url: "Link", tag: "Kennzeichnung", tags: "Merkmale (z. B. vegan, scharf, glutenfrei)", allergens: "Allergene",
|
||||
table: "Tischnummer", wifi: "WLAN-Hinweis", short: "Kürzel (Wappen)", founded: "Gründungsjahr", away: "Gastmannschaft", awayShort: "Kürzel Gast",
|
||||
venue: "Spielort", league: "Liga / Spieltag", home: "Heim", team: "Verein", games: "Spiele", points: "Punkte", group: "Gruppe",
|
||||
training: "Training",
|
||||
logo: "Logo (Bild-Link; leer = Name als Text)", link: "Externer Link (z. B. mobile.de)", linkLabel: "Text für den Link",
|
||||
values: "Werte (mit Komma trennen)", steps: "Schritte (mit Komma trennen)",
|
||||
items: "Einträge", plans: "Tarife", members: "Personen", questions: "Fragen", milestones: "Meilensteine", facts: "Kennzahlen",
|
||||
}
|
||||
|
||||
const TEXTAREA_KEYS = new Set(["title", "text", "lead", "quote", "description", "answer", "features", "tasks", "bullets"])
|
||||
const IMAGE_KEY = /^(image|src|photo|portrait)/
|
||||
const IMAGE_KEY = /^(image|src|photo|portrait|avatar|logo)/
|
||||
|
||||
function humanize(key: string) {
|
||||
const spaced = key.replace(/([A-Z])/g, " $1").toLowerCase()
|
||||
|
|
@ -111,12 +129,30 @@ export function resolveContent<D extends TemplateData>(def: TemplateDefinition<D
|
|||
const optional = new Set(def.sections.filter((section) => section.optional).map((section) => section.key))
|
||||
const hidden = Array.isArray(source.hidden) ? source.hidden.filter((key): key is string => typeof key === "string" && optional.has(key)) : []
|
||||
const ai = isRecord(source.ai) ? source.ai : {}
|
||||
const colors = isRecord(source.colors) ? source.colors : {}
|
||||
const style = isRecord(source.style) ? source.style : {}
|
||||
const optionalKeys = def.sections.filter((section) => section.optional).map((section) => section.key)
|
||||
const savedOrder = Array.isArray(source.order) ? source.order.filter((key): key is string => typeof key === "string" && optional.has(key)) : []
|
||||
const hex = (value: unknown) => (typeof value === "string" && HEX_COLOR.test(value) ? value : "")
|
||||
|
||||
return {
|
||||
palette: def.palettes.find((palette) => palette.key === source.palette)?.key ?? def.palettes[0].key,
|
||||
fonts: def.fonts.find((pair) => pair.key === source.fonts)?.key ?? def.fonts[0].key,
|
||||
accent: typeof source.accent === "string" && HEX_COLOR.test(source.accent) ? source.accent : "",
|
||||
// Eigene Schlüssel der Vorlage oder "vorlage:schlüssel" aus der Bibliothek (aufgelöst in lib/theme.ts)
|
||||
palette: libraryKey(source.palette, def.palettes) ?? def.palettes[0].key,
|
||||
fonts: libraryKey(source.fonts, def.fonts) ?? def.fonts[0].key,
|
||||
colors: {
|
||||
bg: hex(colors.bg),
|
||||
fg: hex(colors.fg),
|
||||
muted: hex(colors.muted),
|
||||
surface: hex(colors.surface),
|
||||
// Früher gab es nur `accent` auf oberster Ebene
|
||||
accent: hex(colors.accent) || hex(source.accent),
|
||||
} satisfies ColorOverrides,
|
||||
hidden: [...new Set(hidden)],
|
||||
order: [...new Set([...savedOrder, ...optionalKeys])],
|
||||
style: {
|
||||
images: IMAGE_STYLES.includes(style.images as TemplateStyle["images"]) ? (style.images as TemplateStyle["images"]) : "original",
|
||||
motion: style.motion === "subtle" ? "subtle" : "full",
|
||||
} satisfies TemplateStyle,
|
||||
ai: {
|
||||
chat: ai.chat === true,
|
||||
phone: ai.phone === true,
|
||||
|
|
@ -132,36 +168,6 @@ export function resolveContent<D extends TemplateData>(def: TemplateDefinition<D
|
|||
}
|
||||
}
|
||||
|
||||
// Schwarz oder Weiß, je nachdem was auf der Akzentfarbe besser lesbar ist (WCAG-Luminanz)
|
||||
function readableOn(hex: string) {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||
const channel = parseInt(hex.slice(i, i + 2), 16) / 255
|
||||
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? "#111111" : "#ffffff"
|
||||
}
|
||||
|
||||
export function accentColors(def: TemplateDefinition, content: TemplateContent) {
|
||||
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||||
const accent = content.accent || palette.accent
|
||||
return { accent, accentFg: readableOn(accent) }
|
||||
}
|
||||
|
||||
export function themeStyle(def: TemplateDefinition, content: TemplateContent): CSSProperties {
|
||||
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||||
const accent = content.accent || palette.accent
|
||||
|
||||
return {
|
||||
"--t-bg": palette.bg,
|
||||
"--t-fg": palette.fg,
|
||||
"--t-muted": palette.muted,
|
||||
"--t-surface": palette.surface,
|
||||
"--t-accent": accent,
|
||||
"--t-accent-fg": readableOn(accent),
|
||||
colorScheme: palette.dark ? "dark" : "light",
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
export function brandName(content: TemplateContent) {
|
||||
const name = content.data.brand?.name
|
||||
return typeof name === "string" && name.trim() ? name.trim() : null
|
||||
|
|
|
|||
|
|
@ -10,9 +10,19 @@ import { karriere } from "../catalog/karriere/definition"
|
|||
import { praxis } from "../catalog/praxis/definition"
|
||||
import { restaurant } from "../catalog/restaurant/definition"
|
||||
import { saas } from "../catalog/saas/definition"
|
||||
import { nutzfahrzeuge } from "../catalog/nutzfahrzeuge/definition"
|
||||
import { hotel } from "../catalog/hotel/definition"
|
||||
import { cafe } from "../catalog/cafe/definition"
|
||||
import { fotograf } from "../catalog/fotograf/definition"
|
||||
import { pflege } from "../catalog/pflege/definition"
|
||||
import { shop } from "../catalog/shop/definition"
|
||||
import { agentur } from "../catalog/agentur/definition"
|
||||
import { verein } from "../catalog/verein/definition"
|
||||
import { linkinbio } from "../catalog/linkinbio/definition"
|
||||
import { speisekarte } from "../catalog/speisekarte/definition"
|
||||
import type { TemplateDefinition } from "./types"
|
||||
|
||||
// Reihenfolge = Reihenfolge im Admin: erst Branchen, dann Seiten-Typen für bestimmte Situationen
|
||||
// Reihenfolge = Reihenfolge im Admin
|
||||
export const templates: TemplateDefinition[] = [
|
||||
restaurant,
|
||||
handwerk,
|
||||
|
|
@ -26,6 +36,16 @@ export const templates: TemplateDefinition[] = [
|
|||
kampagne,
|
||||
event,
|
||||
coach,
|
||||
nutzfahrzeuge,
|
||||
hotel,
|
||||
cafe,
|
||||
fotograf,
|
||||
pflege,
|
||||
shop,
|
||||
agentur,
|
||||
verein,
|
||||
linkinbio,
|
||||
speisekarte,
|
||||
]
|
||||
|
||||
export function getTemplate(key: string) {
|
||||
|
|
|
|||
64
modules/templates/lib/theme.ts
Normal file
64
modules/templates/lib/theme.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { CSSProperties } from "react"
|
||||
import { getTemplate, templates } from "./registry"
|
||||
import type { Palette, TemplateContent, TemplateDefinition } from "./types"
|
||||
|
||||
// Bibliothek: Jede Variante darf Paletten und Schriftpaare aller Vorlagen nutzen ("vorlage:schlüssel")
|
||||
export const paletteLibrary = templates.flatMap((template) =>
|
||||
template.palettes.map((palette) => ({ key: `${template.key}:${palette.key}`, template: template.name, palette }))
|
||||
)
|
||||
|
||||
export const fontLibrary = templates.flatMap((template) =>
|
||||
template.fonts.map((pair) => ({ key: `${template.key}:${pair.key}`, template: template.name, label: pair.label }))
|
||||
)
|
||||
|
||||
export function resolvePalette(def: TemplateDefinition, key: string): Palette {
|
||||
const own = def.palettes.find((palette) => palette.key === key)
|
||||
if (own) return own
|
||||
const [templateKey, paletteKey] = key.split(":")
|
||||
return getTemplate(templateKey)?.palettes.find((palette) => palette.key === paletteKey) ?? def.palettes[0]
|
||||
}
|
||||
|
||||
function luminance(hex: string) {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||
const channel = parseInt(hex.slice(i, i + 2), 16) / 255
|
||||
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
// Schwarz oder Weiß, je nachdem was auf der Farbe besser lesbar ist (WCAG-Luminanz)
|
||||
const readableOn = (hex: string) => (luminance(hex) > 0.179 ? "#111111" : "#ffffff")
|
||||
|
||||
export function themeColors(def: TemplateDefinition, content: TemplateContent) {
|
||||
const palette = resolvePalette(def, content.palette)
|
||||
const { colors } = content
|
||||
const bg = colors.bg || palette.bg
|
||||
|
||||
return {
|
||||
bg,
|
||||
fg: colors.fg || palette.fg,
|
||||
muted: colors.muted || palette.muted,
|
||||
surface: colors.surface || palette.surface,
|
||||
accent: colors.accent || palette.accent,
|
||||
dark: colors.bg ? luminance(bg) < 0.2 : Boolean(palette.dark),
|
||||
}
|
||||
}
|
||||
|
||||
export function accentColors(def: TemplateDefinition, content: TemplateContent) {
|
||||
const { accent } = themeColors(def, content)
|
||||
return { accent, accentFg: readableOn(accent) }
|
||||
}
|
||||
|
||||
export function themeStyle(def: TemplateDefinition, content: TemplateContent): CSSProperties {
|
||||
const theme = themeColors(def, content)
|
||||
|
||||
return {
|
||||
"--t-bg": theme.bg,
|
||||
"--t-fg": theme.fg,
|
||||
"--t-muted": theme.muted,
|
||||
"--t-surface": theme.surface,
|
||||
"--t-accent": theme.accent,
|
||||
"--t-accent-fg": readableOn(theme.accent),
|
||||
colorScheme: theme.dark ? "dark" : "light",
|
||||
} as CSSProperties
|
||||
}
|
||||
|
|
@ -69,12 +69,22 @@ export type AiSettings = {
|
|||
knowledge: string
|
||||
}
|
||||
|
||||
// Eigene Farben (#rrggbb) überschreiben die Palette; leer = Farbe der Palette
|
||||
export type ColorOverrides = Record<"bg" | "fg" | "muted" | "surface" | "accent", string>
|
||||
|
||||
export type TemplateStyle = {
|
||||
images: "original" | "mono" | "warm" | "soft" | "contrast"
|
||||
motion: "full" | "subtle"
|
||||
}
|
||||
|
||||
export type TemplateContent<D extends TemplateData = TemplateData> = {
|
||||
palette: string
|
||||
fonts: string
|
||||
// Eigene Akzentfarbe (#rrggbb), leer = Akzent der Palette
|
||||
accent: string
|
||||
colors: ColorOverrides
|
||||
hidden: string[]
|
||||
// Reihenfolge der optionalen Sektionen
|
||||
order: string[]
|
||||
style: TemplateStyle
|
||||
ai: AiSettings
|
||||
data: D
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,34 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TemplateContent, TemplateDefinition } from "../../../lib/types"
|
||||
import { fontLibrary, paletteLibrary, themeColors } from "../../../lib/theme"
|
||||
import type { ColorOverrides, Palette, TemplateContent, TemplateDefinition, TemplateStyle } from "../../../lib/types"
|
||||
import { Toggle } from "./toggle"
|
||||
|
||||
type ThemePatch = Partial<Pick<TemplateContent, "palette" | "fonts" | "accent" | "hidden">>
|
||||
type ThemePatch = Partial<Pick<TemplateContent, "palette" | "fonts" | "colors" | "hidden" | "order" | "style">>
|
||||
|
||||
const colorFields: { key: keyof ColorOverrides; label: string }[] = [
|
||||
{ key: "bg", label: "Hintergrund" },
|
||||
{ key: "fg", label: "Text" },
|
||||
{ key: "muted", label: "Nebentext" },
|
||||
{ key: "surface", label: "Flächen" },
|
||||
{ key: "accent", label: "Akzent" },
|
||||
]
|
||||
|
||||
const imageOptions: { key: TemplateStyle["images"]; label: string }[] = [
|
||||
{ key: "original", label: "Original" },
|
||||
{ key: "mono", label: "Schwarzweiß" },
|
||||
{ key: "warm", label: "Warm" },
|
||||
{ key: "soft", label: "Sanft" },
|
||||
{ key: "contrast", label: "Kontrast" },
|
||||
]
|
||||
|
||||
const choice = (active: boolean) =>
|
||||
cn("cursor-pointer rounded-lg border text-left text-sm transition-shadow", active ? "ring-2 ring-ring" : "hover:border-foreground/30")
|
||||
|
||||
function Heading({ title, text }: { title: string; text?: string }) {
|
||||
return (
|
||||
|
|
@ -16,13 +39,51 @@ function Heading({ title, text }: { title: string; text?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function DesignPanel({ def, content, onChange }: {
|
||||
def: TemplateDefinition
|
||||
content: TemplateContent
|
||||
onChange: (patch: ThemePatch) => void
|
||||
}) {
|
||||
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||||
const accent = content.accent || palette.accent
|
||||
function Swatch({ palette, active, label, onClick, small }: { palette: Palette; active: boolean; label: string; onClick: () => void; small?: boolean }) {
|
||||
return (
|
||||
<button type="button" title={label} aria-pressed={active} onClick={onClick} className={cn(choice(active), "overflow-hidden")}>
|
||||
<div className={cn("flex items-end gap-1 p-2", small ? "h-9" : "h-14")} style={{ background: palette.bg }}>
|
||||
<span className={cn("rounded-full", small ? "size-3" : "size-4")} style={{ background: palette.fg }} />
|
||||
<span className={cn("rounded-full", small ? "size-3" : "size-4")} style={{ background: palette.accent }} />
|
||||
</div>
|
||||
{!small && <p className="px-2 py-1.5 text-xs">{palette.label}</p>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function More({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Collapsible className="mt-2">
|
||||
<CollapsibleTrigger className="flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground [&[data-panel-open]>svg]:rotate-180">
|
||||
{label} <ChevronDownIcon className="size-3.5 transition-transform" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-3">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesignPanel({ def, content, onChange }: { def: TemplateDefinition; content: TemplateContent; onChange: (patch: ThemePatch) => void }) {
|
||||
const theme = themeColors(def, content)
|
||||
const ownPalettes = new Set(def.palettes.map((p) => `${def.key}:${p.key}`))
|
||||
const ownFonts = new Set(def.fonts.map((f) => `${def.key}:${f.key}`))
|
||||
const customized = Object.values(content.colors).some(Boolean)
|
||||
const optional = content.order
|
||||
.map((key) => def.sections.find((section) => section.key === key))
|
||||
.filter((section) => section !== undefined)
|
||||
|
||||
function setPalette(key: string) {
|
||||
// Neue Palette: eigene Farben zurücksetzen, sonst sieht man den Wechsel nicht
|
||||
onChange({ palette: key, colors: { bg: "", fg: "", muted: "", surface: "", accent: "" } })
|
||||
}
|
||||
|
||||
function move(index: number, offset: number) {
|
||||
const keys = optional.map((section) => section.key)
|
||||
const [key] = keys.splice(index, 1)
|
||||
keys.splice(index + offset, 0, key)
|
||||
onChange({ order: keys })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
|
|
@ -30,80 +91,129 @@ export function DesignPanel({ def, content, onChange }: {
|
|||
<Heading title="Farbpalette" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{def.palettes.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
aria-pressed={p.key === content.palette}
|
||||
onClick={() => onChange({ palette: p.key })}
|
||||
className={cn(
|
||||
"cursor-pointer overflow-hidden rounded-lg border text-left transition-shadow",
|
||||
p.key === content.palette ? "ring-2 ring-ring" : "hover:border-foreground/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-end gap-1 p-2" style={{ background: p.bg }}>
|
||||
<span className="size-4 rounded-full" style={{ background: p.fg }} />
|
||||
<span className="size-4 rounded-full" style={{ background: p.accent }} />
|
||||
</div>
|
||||
<p className="px-2 py-1.5 text-xs">{p.label}</p>
|
||||
</button>
|
||||
<Swatch key={p.key} palette={p} label={p.label} active={content.palette === p.key} onClick={() => setPalette(p.key)} />
|
||||
))}
|
||||
</div>
|
||||
<More label={`Paletten aus anderen Vorlagen (${paletteLibrary.length - def.palettes.length})`}>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{paletteLibrary
|
||||
.filter((entry) => !ownPalettes.has(entry.key))
|
||||
.map((entry) => (
|
||||
<Swatch
|
||||
key={entry.key}
|
||||
small
|
||||
palette={entry.palette}
|
||||
label={`${entry.palette.label} · ${entry.template}`}
|
||||
active={content.palette === entry.key}
|
||||
onClick={() => setPalette(entry.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</More>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading title="Akzentfarbe" text="Zum Beispiel die Markenfarbe des Kunden. Textfarbe darauf wird automatisch gewählt." />
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={accent}
|
||||
aria-label="Akzentfarbe"
|
||||
onChange={(event) => onChange({ accent: event.target.value })}
|
||||
className="h-10 w-14 cursor-pointer rounded-md border bg-transparent p-1"
|
||||
/>
|
||||
<code className="font-mono text-xs uppercase">{accent}</code>
|
||||
{content.accent && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onChange({ accent: "" })}>
|
||||
Palette verwenden
|
||||
</Button>
|
||||
)}
|
||||
<Heading title="Eigene Farben" text="Zum Beispiel die Markenfarben des Kunden. Textfarbe auf dem Akzent wird automatisch gewählt." />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colorFields.map((field) => (
|
||||
<label key={field.key} className="flex flex-col items-center gap-1.5 text-center text-[11px] text-muted-foreground">
|
||||
<input
|
||||
type="color"
|
||||
value={content.colors[field.key] || theme[field.key]}
|
||||
onChange={(event) => onChange({ colors: { ...content.colors, [field.key]: event.target.value } })}
|
||||
className={cn("h-10 w-full cursor-pointer rounded-md border bg-transparent p-1", content.colors[field.key] && "ring-2 ring-ring")}
|
||||
/>
|
||||
{field.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{customized && (
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={() => onChange({ colors: { bg: "", fg: "", muted: "", surface: "", accent: "" } })}>
|
||||
Farben der Palette verwenden
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading title="Schriften" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{def.fonts.map((pair) => (
|
||||
<button
|
||||
key={pair.key}
|
||||
type="button"
|
||||
aria-pressed={pair.key === content.fonts}
|
||||
onClick={() => onChange({ fonts: pair.key })}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg border px-3 py-2.5 text-left text-sm transition-shadow",
|
||||
pair.key === content.fonts ? "ring-2 ring-ring" : "hover:border-foreground/30"
|
||||
)}
|
||||
>
|
||||
<button key={pair.key} type="button" aria-pressed={pair.key === content.fonts} onClick={() => onChange({ fonts: pair.key })} className={cn(choice(pair.key === content.fonts), "px-3 py-2.5")}>
|
||||
{pair.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<More label={`Schriften aus anderen Vorlagen (${fontLibrary.length - def.fonts.length})`}>
|
||||
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto pr-1">
|
||||
{fontLibrary
|
||||
.filter((entry) => !ownFonts.has(entry.key))
|
||||
.map((entry) => (
|
||||
<button key={entry.key} type="button" aria-pressed={content.fonts === entry.key} onClick={() => onChange({ fonts: entry.key })} className={cn(choice(content.fonts === entry.key), "px-3 py-2")}>
|
||||
{entry.label} <span className="text-xs text-muted-foreground">· {entry.template}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</More>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading title="Bildstil" text="Gilt für alle Bilder der Seite." />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{imageOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
aria-pressed={content.style.images === option.key}
|
||||
onClick={() => onChange({ style: { ...content.style, images: option.key } })}
|
||||
className={cn(choice(content.style.images === option.key), "px-3 py-1.5")}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading title="Sektionen" text="Ausgeblendete Sektionen behalten ihre Inhalte." />
|
||||
<div className="flex flex-col divide-y rounded-lg border">
|
||||
{def.sections.filter((section) => section.optional).map((section) => (
|
||||
<Toggle
|
||||
key={section.key}
|
||||
checked={!content.hidden.includes(section.key)}
|
||||
onChange={(visible) =>
|
||||
onChange({
|
||||
hidden: visible ? content.hidden.filter((key) => key !== section.key) : [...content.hidden, section.key],
|
||||
})
|
||||
}
|
||||
<Heading title="Bewegung" text="Dezent: nur sanftes Einblenden, keine Bewegungseffekte." />
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(["full", "subtle"] as const).map((motion) => (
|
||||
<button
|
||||
key={motion}
|
||||
type="button"
|
||||
aria-pressed={content.style.motion === motion}
|
||||
onClick={() => onChange({ style: { ...content.style, motion } })}
|
||||
className={cn(choice(content.style.motion === motion), "px-3 py-2")}
|
||||
>
|
||||
{section.label}
|
||||
</Toggle>
|
||||
{motion === "full" ? "Voll" : "Dezent"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading title="Sektionen" text="Reihenfolge per Pfeil ändern. Ausgeblendete Sektionen behalten ihre Inhalte." />
|
||||
<div className="flex flex-col divide-y rounded-lg border">
|
||||
{optional.map((section, index) => (
|
||||
<div key={section.key} className="flex items-center gap-1 pl-1">
|
||||
<div className="flex flex-col">
|
||||
<button type="button" aria-label={`${section.label} nach oben`} disabled={index === 0} onClick={() => move(index, -1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" aria-label={`${section.label} nach unten`} disabled={index === optional.length - 1} onClick={() => move(index, 1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Toggle
|
||||
checked={!content.hidden.includes(section.key)}
|
||||
onChange={(visible) =>
|
||||
onChange({ hidden: visible ? content.hidden.filter((key) => key !== section.key) : [...content.hidden, section.key] })
|
||||
}
|
||||
>
|
||||
{section.label}
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState, useSyncExternalStore, useTransition } from "react"
|
||||
import { CopyPlusIcon, ExternalLinkIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||
import { CopyPlusIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { CopyField } from "@/modules/admin/ui/components/copy-field"
|
||||
import { deleteVariant, duplicateVariant, updateShare } from "../../../actions/variants"
|
||||
|
|
@ -34,6 +34,7 @@ export function SharePanel({ variant, clients, clientId, onClientChange, dirty }
|
|||
// -1 = bestehendes Ablaufdatum beibehalten
|
||||
const [expiry, setExpiry] = useState<number>(variant.shareExpiresAt ? -1 : 0)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
|
|
@ -57,6 +58,29 @@ export function SharePanel({ variant, clients, clientId, onClientChange, dirty }
|
|||
})
|
||||
}
|
||||
|
||||
async function exportProject() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${variant.id}/export`)
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
setError(body?.error ?? "Export fehlgeschlagen.")
|
||||
return
|
||||
}
|
||||
const fileName = /filename="([^"]+)"/.exec(response.headers.get("Content-Disposition") ?? "")?.[1] ?? "website.zip"
|
||||
const link = document.createElement("a")
|
||||
link.href = URL.createObjectURL(await response.blob())
|
||||
link.download = fileName
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError("Export fehlgeschlagen.")
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const url = share.token ? `${origin}/demo/${share.token}` : ""
|
||||
|
||||
return (
|
||||
|
|
@ -120,6 +144,17 @@ export function SharePanel({ variant, clients, clientId, onClientChange, dirty }
|
|||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading
|
||||
title="Als Next.js-Projekt exportieren"
|
||||
text="Eigenständiges Projekt (ZIP) mit Vorlage, Inhalten und eigenen Bildern – ohne Entwurfs-Hinweis und KI-Funktionen. Die README listet, was vor dem Livegang noch fehlt."
|
||||
/>
|
||||
<Button variant="outline" className="w-full" disabled={exporting} onClick={exportProject}>
|
||||
<DownloadIcon /> {exporting ? "Wird erstellt …" : "ZIP herunterladen"}
|
||||
</Button>
|
||||
{dirty && <p className="mt-2 text-xs text-muted-foreground">Exportiert wird der gespeicherte Stand.</p>}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading title="Variante" />
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
|
|||
88
modules/templates/ui/components/template-grid.tsx
Normal file
88
modules/templates/ui/components/template-grid.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { EyeIcon, SearchIcon } from "lucide-react"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { templates } from "../../lib/registry"
|
||||
import { CreateVariantButton } from "./create-variant-button"
|
||||
|
||||
// Seiten-Typen für bestimmte Situationen, alles andere sind Branchen-Webseiten
|
||||
const PAGE_TYPES = new Set(["karriere", "kampagne", "event", "shop", "linkinbio", "speisekarte"])
|
||||
const GROUPS = ["Alle", "Branchen", "Seiten-Typen"] as const
|
||||
|
||||
export function TemplateGrid() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [group, setGroup] = useState<(typeof GROUPS)[number]>("Alle")
|
||||
const term = query.trim().toLowerCase()
|
||||
const visible = templates.filter((template) => {
|
||||
const inGroup = group === "Alle" || (group === "Seiten-Typen") === PAGE_TYPES.has(template.key)
|
||||
const text = `${template.name} ${template.industry} ${template.description}`.toLowerCase()
|
||||
return inGroup && (!term || text.includes(term))
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-1 rounded-lg border p-1">
|
||||
{GROUPS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={group === option}
|
||||
onClick={() => setGroup(option)}
|
||||
className={cn("cursor-pointer rounded-md px-3 py-1.5 text-sm transition-colors", group === option ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="relative sm:w-72">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Branche suchen, z. B. Auto" aria-label="Vorlagen durchsuchen" className="pl-9" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{visible.map((template) => (
|
||||
<article key={template.key} className="group flex flex-col overflow-hidden rounded-xl border bg-card">
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
<Image
|
||||
src={template.cover}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1280px) 25vw, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
|
||||
<span className="absolute top-3 left-3 rounded-full bg-black/50 px-2.5 py-1 text-xs text-white backdrop-blur">{template.industry}</span>
|
||||
<div className="absolute bottom-3 left-3 flex gap-1.5">
|
||||
{template.palettes.map((palette) => (
|
||||
<span
|
||||
key={palette.key}
|
||||
title={palette.label}
|
||||
className="size-4 rounded-full ring-2 ring-white/70"
|
||||
style={{ background: `linear-gradient(135deg, ${palette.bg} 50%, ${palette.accent} 50%)` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 p-4">
|
||||
<h2 className="font-semibold">{template.name}</h2>
|
||||
<p className="flex-1 text-sm text-muted-foreground">{template.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<a href={`/preview/${template.key}`} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<EyeIcon /> Ansehen
|
||||
</a>
|
||||
<CreateVariantButton templateKey={template.key} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{visible.length === 0 && <p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">Keine passende Vorlage gefunden.</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import type { ComponentType } from "react"
|
||||
import { AiAccessProvider } from "../../ai/access-context"
|
||||
import type { AiAccess } from "../../ai/demos"
|
||||
import { ChatWidget } from "../../ai/ui/chat-widget"
|
||||
import { BeautyTemplate } from "../../catalog/beauty/template"
|
||||
import { CoachTemplate } from "../../catalog/coach/template"
|
||||
import { EventTemplate } from "../../catalog/event/template"
|
||||
|
|
@ -15,6 +16,18 @@ import { KarriereTemplate } from "../../catalog/karriere/template"
|
|||
import { PraxisTemplate } from "../../catalog/praxis/template"
|
||||
import { RestaurantTemplate } from "../../catalog/restaurant/template"
|
||||
import { SaasTemplate } from "../../catalog/saas/template"
|
||||
import { NutzfahrzeugeTemplate } from "../../catalog/nutzfahrzeuge/template"
|
||||
import { HotelTemplate } from "../../catalog/hotel/template"
|
||||
import { CafeTemplate } from "../../catalog/cafe/template"
|
||||
import { FotografTemplate } from "../../catalog/fotograf/template"
|
||||
import { PflegeTemplate } from "../../catalog/pflege/template"
|
||||
import { ShopTemplate } from "../../catalog/shop/template"
|
||||
import { AgenturTemplate } from "../../catalog/agentur/template"
|
||||
import { VereinTemplate } from "../../catalog/verein/template"
|
||||
import { LinkinbioTemplate } from "../../catalog/linkinbio/template"
|
||||
import { SpeisekarteTemplate } from "../../catalog/speisekarte/template"
|
||||
import { DemoContext } from "../../catalog/shared/demo"
|
||||
import { DemoBadge } from "../../catalog/shared/demo-badge"
|
||||
import type { TemplateContent } from "../../lib/types"
|
||||
|
||||
type TemplateComponent = ComponentType<{ content: TemplateContent }>
|
||||
|
|
@ -32,6 +45,16 @@ const components: Record<string, TemplateComponent> = {
|
|||
kampagne: KampagneTemplate as TemplateComponent,
|
||||
event: EventTemplate as TemplateComponent,
|
||||
coach: CoachTemplate as TemplateComponent,
|
||||
nutzfahrzeuge: NutzfahrzeugeTemplate as TemplateComponent,
|
||||
hotel: HotelTemplate as TemplateComponent,
|
||||
cafe: CafeTemplate as TemplateComponent,
|
||||
fotograf: FotografTemplate as TemplateComponent,
|
||||
pflege: PflegeTemplate as TemplateComponent,
|
||||
shop: ShopTemplate as TemplateComponent,
|
||||
agentur: AgenturTemplate as TemplateComponent,
|
||||
verein: VereinTemplate as TemplateComponent,
|
||||
linkinbio: LinkinbioTemplate as TemplateComponent,
|
||||
speisekarte: SpeisekarteTemplate as TemplateComponent,
|
||||
}
|
||||
|
||||
export function TemplateRenderer({ templateKey, content, access = null }: {
|
||||
|
|
@ -44,7 +67,10 @@ export function TemplateRenderer({ templateKey, content, access = null }: {
|
|||
|
||||
return (
|
||||
<AiAccessProvider access={access}>
|
||||
<Template content={content} />
|
||||
{/* Entwurfs-Hinweis und KI-Chat rendert die TemplateShell; ohne diesen Kontext (Export) entfallen sie */}
|
||||
<DemoContext value={{ overlay: <><DemoBadge content={content} /><ChatWidget content={content} /></> }}>
|
||||
<Template content={content} />
|
||||
</DemoContext>
|
||||
</AiAccessProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { EyeIcon } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { getClients } from "@/modules/admin/queries/clients"
|
||||
import { dateFormat, shareState, type ShareState } from "../../lib/preview"
|
||||
import { getTemplate, templates } from "../../lib/registry"
|
||||
import { getTemplate } from "../../lib/registry"
|
||||
import { getVariants } from "../../queries/variants"
|
||||
import { CreateVariantButton } from "../components/create-variant-button"
|
||||
import { TemplateGrid } from "../components/template-grid"
|
||||
|
||||
const shareBadge: Record<ShareState, { label: string; variant: "success" | "warning" | "neutral" }> = {
|
||||
shared: { label: "Geteilt", variant: "success" },
|
||||
|
|
@ -25,54 +22,11 @@ const TemplatesView = async () => {
|
|||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold md:text-3xl">Vorlagen</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Demo-Webseiten für Pitches: Vorlage wählen, für den Kunden anpassen und als Link teilen.
|
||||
Demo-Webseiten für Pitches: Vorlage wählen, für den Kunden anpassen (Inhalte, Farben, Schriften, Reihenfolge) und als Link teilen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{templates.map((template) => (
|
||||
<article key={template.key} className="group flex flex-col overflow-hidden rounded-xl border bg-card">
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
<Image
|
||||
src={template.cover}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1280px) 25vw, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
|
||||
<span className="absolute top-3 left-3 rounded-full bg-black/50 px-2.5 py-1 text-xs text-white backdrop-blur">
|
||||
{template.industry}
|
||||
</span>
|
||||
<div className="absolute bottom-3 left-3 flex gap-1.5">
|
||||
{template.palettes.map((palette) => (
|
||||
<span
|
||||
key={palette.key}
|
||||
title={palette.label}
|
||||
className="size-4 rounded-full ring-2 ring-white/70"
|
||||
style={{ background: `linear-gradient(135deg, ${palette.bg} 50%, ${palette.accent} 50%)` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 p-4">
|
||||
<h2 className="font-semibold">{template.name}</h2>
|
||||
<p className="flex-1 text-sm text-muted-foreground">{template.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<a
|
||||
href={`/preview/${template.key}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
<EyeIcon /> Ansehen
|
||||
</a>
|
||||
<CreateVariantButton templateKey={template.key} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<TemplateGrid />
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold">Varianten</h2>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ const nextConfig: NextConfig = {
|
|||
// Fonts und Logo werden zur Laufzeit per fs gelesen und müssen mit ins Deployment.
|
||||
outputFileTracingIncludes: {
|
||||
"/api/invoices/*": ["./modules/shared/pdf/assets/**/*"],
|
||||
// Der Next.js-Export liest die Quelltexte der Vorlagen und eigene Bilder (modules/templates/export/project.ts)
|
||||
"/api/templates/**": ["./modules/templates/**/*.{ts,tsx}", "./lib/utils.ts", "./public/pictures/templates/**/*"],
|
||||
},
|
||||
images: {
|
||||
// Platzhalterbilder der Vorlagen. Ohne `search`, weil Unsplash-Links Parameter (w, q, fit) tragen.
|
||||
|
|
|
|||
BIN
public/pictures/templates/boeing-trucks-vans/kleinbus.jpg
Normal file
BIN
public/pictures/templates/boeing-trucks-vans/kleinbus.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
BIN
public/pictures/templates/boeing-trucks-vans/logo.png
Normal file
BIN
public/pictures/templates/boeing-trucks-vans/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 550 KiB |
Loading…
Add table
Add a link
Reference in a new issue