added bunch of stuff

This commit is contained in:
Rajpreet0 2026-10-05 08:23:02 +02:00
parent dfcbf1d536
commit 46a600d3c3
91 changed files with 11270 additions and 1 deletions

View file

@ -4,6 +4,7 @@ import {
InfoIcon,
KeyIcon,
LayoutDashboardIcon,
LayoutTemplateIcon,
LucideIcon,
SettingsIcon,
UserRoundIcon,
@ -28,6 +29,11 @@ export const adminMenu: MenuItem[] = [
url: "/admin/clients",
icon: UsersRoundIcon
},
{
title: "Vorlagen",
url: "/admin/templates",
icon: LayoutTemplateIcon
},
{
title: "Settings",
url: "/admin/settings",

View file

@ -0,0 +1,151 @@
"use server"
import { randomBytes } from "node:crypto"
import * as z from "zod"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { createClient } from "@/lib/supabase/server"
import { verifySession } from "@/lib/supabase/dal"
import { resolveContent } from "../lib/content"
import { SHARE_EXPIRY_OPTIONS } from "../lib/preview"
import { getTemplate } from "../lib/registry"
export type VariantActionResult = { error: string } | { success: true }
export type ShareResult = { error: string } | { token: string | null; expiresAt: string | null }
const IdSchema = z.string().uuid()
const SaveSchema = z.object({
name: z.string().trim().min(1, { error: "Bitte einen Namen für die Variante eingeben." }).max(120),
clientId: z.string().uuid().nullable(),
content: z.unknown(),
})
const ShareSchema = z.object({
enabled: z.boolean(),
expiresInDays: z.number().refine((days) => SHARE_EXPIRY_OPTIONS.some((option) => option.days === days)),
regenerate: z.boolean().optional(),
})
async function requireAdmin() {
const session = await verifySession()
return session.isAdmin ? session : null
}
function firstIssue(error: z.ZodError) {
return error.issues[0]?.message ?? "Ungültige Eingabe."
}
// Nicht erratbar, URL-tauglich: 24 Byte ergeben 32 Zeichen
function newShareToken() {
return randomBytes(24).toString("base64url")
}
function revalidateTemplates() {
revalidatePath("/admin/templates", "layout")
}
export async function createVariant(templateKey: string): Promise<{ error: string } | undefined> {
if (!(await requireAdmin())) return { error: "Keine Berechtigung." }
const def = getTemplate(templateKey)
if (!def) return { error: "Vorlage nicht gefunden." }
const supabase = await createClient()
const { data, error } = await supabase
.from("template_variants")
.insert({ template_key: def.key, name: `${def.name} – Entwurf`, content: resolveContent(def, {}) })
.select("id")
.single()
if (error || !data) return { error: "Variante konnte nicht angelegt werden." }
revalidateTemplates()
redirect(`/admin/templates/${data.id}`)
}
export async function saveVariant(id: string, input: z.input<typeof SaveSchema>): Promise<VariantActionResult> {
if (!(await requireAdmin())) return { error: "Keine Berechtigung." }
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." }
const parsed = SaveSchema.safeParse(input)
if (!parsed.success) return { error: firstIssue(parsed.error) }
const supabase = await createClient()
const { data: current } = await supabase.from("template_variants").select("template_key").eq("id", id).maybeSingle()
const def = current ? getTemplate(current.template_key) : undefined
if (!def) return { error: "Variante nicht gefunden." }
const { error } = await supabase
.from("template_variants")
.update({
name: parsed.data.name,
client_id: parsed.data.clientId,
content: resolveContent(def, parsed.data.content),
})
.eq("id", id)
if (error) return { error: "Änderungen konnten nicht gespeichert werden." }
revalidateTemplates()
return { success: true }
}
export async function updateShare(id: string, input: z.input<typeof ShareSchema>): Promise<ShareResult> {
if (!(await requireAdmin())) return { error: "Keine Berechtigung." }
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." }
const parsed = ShareSchema.safeParse(input)
if (!parsed.success) return { error: "Ungültige Freigabe-Einstellung." }
const { enabled, expiresInDays, regenerate } = parsed.data
const supabase = await createClient()
const { data: current } = await supabase.from("template_variants").select("share_token").eq("id", id).maybeSingle()
if (!current) return { error: "Variante nicht gefunden." }
const token = enabled ? (regenerate || !current.share_token ? newShareToken() : current.share_token) : null
const expiresAt = enabled && expiresInDays > 0 ? new Date(Date.now() + expiresInDays * 86_400_000).toISOString() : null
const { error } = await supabase
.from("template_variants")
.update({ share_token: token, share_expires_at: expiresAt })
.eq("id", id)
if (error) return { error: "Freigabe konnte nicht gespeichert werden." }
revalidateTemplates()
return { token, expiresAt }
}
export async function duplicateVariant(id: string): Promise<{ error: string } | undefined> {
if (!(await requireAdmin())) return { error: "Keine Berechtigung." }
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." }
const supabase = await createClient()
const { data: source } = await supabase
.from("template_variants")
.select("name, template_key, client_id, content")
.eq("id", id)
.maybeSingle()
if (!source) return { error: "Variante nicht gefunden." }
// Der Freigabe-Link wird bewusst nicht mitkopiert
const { data, error } = await supabase
.from("template_variants")
.insert({ ...source, name: `${source.name} (Kopie)`.slice(0, 120) })
.select("id")
.single()
if (error || !data) return { error: "Variante konnte nicht dupliziert werden." }
revalidateTemplates()
redirect(`/admin/templates/${data.id}`)
}
export async function deleteVariant(id: string): Promise<VariantActionResult> {
if (!(await requireAdmin())) return { error: "Keine Berechtigung." }
if (!IdSchema.safeParse(id).success) return { error: "Ungültige Variante." }
const supabase = await createClient()
const { error } = await supabase.from("template_variants").delete().eq("id", id)
if (error) return { error: "Variante konnte nicht gelöscht werden." }
revalidateTemplates()
return { success: true }
}

View file

@ -0,0 +1,15 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import type { AiAccess } from "./demos"
const AiAccessContext = createContext<AiAccess | null>(null)
// Ohne Zugang (Grundvorlage ohne Variante) bleiben Chat und KI-Demos aus
export function AiAccessProvider({ access, children }: { access: AiAccess | null; children: ReactNode }) {
return <AiAccessContext value={access}>{children}</AiAccessContext>
}
export function useAiAccess() {
return useContext(AiAccessContext)
}

View file

@ -0,0 +1,37 @@
import { accentColors } from "../lib/content"
import type { TemplateContent, TemplateDefinition } from "../lib/types"
import { AI_DEMO_SLUGS, AI_DEMOS, type AiDemoSlug } from "./demos"
import { businessName, collectImages } from "./knowledge"
export type DemoInfo = {
brand: string
industry: string
accent: string
accentFg: string
assistantName: string
greeting: string
images: string[]
samples: { inquiry: string; topic: string }
siteHref: string
demos: AiDemoSlug[]
}
// Serialisierbare Daten für die KI-Demo-Seiten; Admins sehen alle Demos, Kunden nur freigeschaltete
export function demoInfo(def: TemplateDefinition, content: TemplateContent, options: { siteHref: string; all: boolean }): DemoInfo {
const brand = businessName(def, content)
const name = content.ai.name.trim()
return {
brand,
industry: def.industry,
...accentColors(def, content),
assistantName: name || "KI-Assistent",
greeting:
content.ai.greeting.trim() ||
(name ? `Guten Tag, hier ist ${name} von ${brand}. Wie kann ich Ihnen helfen?` : `Guten Tag, Sie sprechen mit dem digitalen Assistenten von ${brand}. Wie kann ich Ihnen helfen?`),
images: collectImages(def, content),
samples: def.samples,
siteHref: options.siteHref,
demos: options.all ? AI_DEMO_SLUGS : AI_DEMO_SLUGS.filter((slug) => content.ai[AI_DEMOS[slug].feature]),
}
}

View file

@ -0,0 +1,23 @@
// Ohne zod, damit Vorlagen-Seiten (Badge, Chat) schlank bleiben
// Admins greifen über die Varianten-ID zu, Kunden über den Freigabe-Token
export type AiAccess = { token: string } | { variantId: string }
// KI-Demos als eigene Seiten; `feature` ist der Schalter in AiSettings
export const AI_DEMOS = {
telefon: { feature: "phone", label: "KI-Telefon" },
assistent: { feature: "assistant", label: "Angebots-Assistent" },
content: { feature: "content", label: "Content-Generator" },
} as const
export type AiDemoSlug = keyof typeof AI_DEMOS
export const AI_DEMO_SLUGS = Object.keys(AI_DEMOS) as AiDemoSlug[]
export function isDemoSlug(value: string): value is AiDemoSlug {
return value in AI_DEMOS
}
export function demoHref(access: AiAccess, slug: AiDemoSlug) {
return "token" in access ? `/demo/${access.token}/${slug}` : `/ki/${access.variantId}/${slug}`
}

View file

@ -0,0 +1,60 @@
import { brandName } from "../lib/content"
import type { TemplateContent, TemplateDefinition } from "../lib/types"
const clean = (value: string) => value.replace(/\*/g, "").replace(/\s*\n\s*/g, " · ").trim()
export function businessName(def: TemplateDefinition, content: TemplateContent) {
return brandName(content) ?? def.name
}
// Alles, was auf der Webseite steht, als Wissen für die KI – Bilder ausgenommen, ausgeblendete Sektionen auch
export function buildKnowledge(def: TemplateDefinition, content: TemplateContent) {
const parts = [`Betrieb: ${businessName(def, content)}`, `Branche: ${def.industry}`]
for (const section of def.sections) {
const data = content.data[section.key]
if (!data || content.hidden.includes(section.key)) continue
const lines: string[] = []
for (const field of section.fields) {
const value = data[field.key]
if (field.type === "list") {
if (!Array.isArray(value)) continue
const items = value
.map((item) =>
field.fields
.filter((sub) => sub.type !== "image" && item[sub.key]?.trim())
.map((sub) => `${sub.label}: ${clean(item[sub.key])}`)
.join("; ")
)
.filter(Boolean)
if (items.length) lines.push(`${field.label}:\n${items.map((item) => `- ${item}`).join("\n")}`)
} else if (field.type !== "image" && typeof value === "string" && value.trim()) {
lines.push(`${field.label}: ${clean(value)}`)
}
}
if (lines.length) parts.push(`\n## ${section.label}\n${lines.join("\n")}`)
}
if (content.ai.knowledge.trim()) parts.push(`\n## Zusatzwissen\n${content.ai.knowledge.trim()}`)
return parts.join("\n")
}
// Bild-Links der Variante, z. B. für die Post-Vorschauen im Content-Generator
export function collectImages(def: TemplateDefinition, content: TemplateContent) {
const images: string[] = []
for (const section of def.sections) {
const data = content.data[section.key]
if (!data) continue
for (const field of section.fields) {
const value = data[field.key]
if (field.type === "image" && typeof value === "string") images.push(value)
if (field.type === "list" && Array.isArray(value)) {
for (const sub of field.fields.filter((f) => f.type === "image")) {
images.push(...value.map((item) => item[sub.key] ?? ""))
}
}
}
}
return [...new Set(images.filter((src) => src.startsWith("http")))]
}

View file

@ -0,0 +1,100 @@
import "server-only"
import type Anthropic from "@anthropic-ai/sdk"
import { buildKnowledge, businessName } from "./knowledge"
import { CHANNELS, type Channel, type Tone } from "./schemas"
import type { AiContext } from "./server"
function facts(ctx: AiContext) {
return {
brand: businessName(ctx.def, ctx.content),
industry: ctx.def.industry,
knowledge: `<wissen>\n${buildKnowledge(ctx.def, ctx.content)}\n</wissen>`,
}
}
// Datum in einem eigenen Block nach dem gecachten Wissen, damit der Cache den Tag überlebt
export function nowNote() {
const now = new Intl.DateTimeFormat("de-DE", { dateStyle: "full", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date())
return `Aktuelles Datum und Uhrzeit (Europe/Berlin): ${now}.`
}
export const BOOKING_TOOL: Anthropic.Beta.BetaTool = {
name: "anfrage_erfassen",
description:
"Erfasst eine Termin-, Reservierungs-, Rückruf- oder Angebotsanfrage und leitet sie an das Team weiter. Erst aufrufen, wenn Name, Kontakt und das Anliegen mit den nötigen Details (z. B. Wunschtermin, Personenzahl) feststehen.",
eager_input_streaming: true,
input_schema: {
type: "object",
properties: {
art: { type: "string", description: "Art der Anfrage, z. B. Reservierung, Termin, Rückruf, Angebot" },
name: { type: "string", description: "Name der anfragenden Person" },
kontakt: { type: "string", description: "Telefonnummer oder E-Mail-Adresse" },
datum: { type: "string", description: "Wunschdatum, wie genannt" },
uhrzeit: { type: "string", description: "Wunschuhrzeit, wie genannt" },
personen: { type: "string", description: "Anzahl Personen, falls relevant" },
anliegen: { type: "string", description: "Kurze Zusammenfassung des Anliegens" },
},
required: ["art", "name", "kontakt"],
},
}
export function chatSystem(ctx: AiContext, mode: "chat" | "phone") {
const { brand, industry, knowledge } = facts(ctx)
const name = ctx.content.ai.name.trim() || "der digitale Assistent"
const intro =
mode === "phone"
? `Du bist ${name} und nimmst Anrufe für ${brand} (${industry}) entgegen. Deine Antworten werden vorgelesen.
- Sprich natürlich und knapp: höchstens zwei kurze Sätze pro Antwort.
- Keine Listen, Abkürzungen, Sonderzeichen, Emojis oder Links. Uhrzeiten, Daten und Preise so, wie man sie ausspricht.`
: `Du bist ${name}, der KI-Assistent auf der Webseite von ${brand} (${industry}).
- Antworte freundlich und auf den Punkt, meist in zwei bis vier Sätzen, als reiner Text ohne Markdown.`
return `${intro}
- Antworte in der Sprache der Person, standardmäßig Deutsch in der Sie-Form.
- Stütze dich auf das Wissen unten. Fehlt eine Information, sag das offen und biete an, eine Anfrage aufzunehmen, oder nenne die Kontaktdaten.
- Möchte jemand einen Termin, eine Reservierung, einen Rückruf oder ein Angebot, sammle die nötigen Angaben im Gespräch – immer nur eine Frage auf einmal – und rufe dann anfrage_erfassen auf. Bestätige danach, dass die Anfrage weitergeleitet wurde und sich das Team meldet. Sage nie zu, dass etwas fest gebucht ist, und erfinde keine freien Termine.
- Bei Themen ohne Bezug zum Betrieb hilfst du kurz und lenkst freundlich zurück.
${knowledge}`
}
export function emailSystem(ctx: AiContext) {
const { brand, industry, knowledge } = facts(ctx)
return `Du beantwortest für ${brand} (${industry}) eine Kundenanfrage per E-Mail.
- Herzlich, kompetent und konkret: Geh auf jede Frage der Anfrage ein, in der Sie-Form (außer die Anfrage duzt).
- Nutze Leistungen, Preise, Zeiten und Kontaktdaten nur aus dem Wissen unten. Wo Angaben fehlen, stelle eine passende Rückfrage statt zu raten, und schlage einen nächsten Schritt vor (Termin, Rückruf, Angebot).
- Format: erste Zeile „Betreff: …“, dann eine Leerzeile, dann die E-Mail mit Anrede, Text und Grußformel im Namen des Teams von ${brand}. Reiner Text, kein Markdown.
${knowledge}`
}
export function quoteSystem(ctx: AiContext) {
const { brand, industry, knowledge } = facts(ctx)
return `Du erstellst für ${brand} (${industry}) aus einer Kundenanfrage einen Angebotsentwurf, den das Team vor dem Versand prüft.
- Leite sinnvolle Positionen aus der Anfrage ab, jeweils mit Menge, Einheit und Einzelpreis netto in Euro.
- Nutze Preise aus dem Wissen, wenn vorhanden. Sonst schätze realistische Preise für Deutschland und weise in den Hinweisen darauf hin, dass es Schätzwerte sind.
- Erfinde keine Details über die anfragende Person. Was für ein verbindliches Angebot noch fehlt, gehört in „rueckfragen“ (das sieht nur das Team).
- Anrede, Einleitung und Abschluss im Ton von ${brand}, in der Sie-Form.
${knowledge}`
}
const CHANNEL_RULES: Record<Channel, string> = {
instagram: "starker erster Satz als hook, Text 60–140 Wörter mit Absätzen, sparsam Emojis, 5–8 passende Hashtags ohne #-Zeichen",
facebook: "persönlicher Ton, Text 40–110 Wörter, 0–3 Hashtags ohne #-Zeichen",
linkedin: "professionell mit echtem Mehrwert, Text 80–170 Wörter, 3 Hashtags ohne #-Zeichen",
google: "hook ist der Anzeigentitel mit höchstens 30 Zeichen, text die Beschreibung mit höchstens 90 Zeichen, keine Hashtags",
}
export function contentSystem(ctx: AiContext, channel: Channel, tone: Tone) {
const { brand, industry, knowledge } = facts(ctx)
return `Du schreibst Social-Media- und Werbetexte für ${brand} (${industry}).
Erstelle drei klar unterschiedliche Varianten für ${CHANNELS[channel]} (Regeln: ${CHANNEL_RULES[channel]}). Tonalität: ${tone}. Jede Variante hat einen Call-to-Action und eine kurze Bildidee. Nutze nur Fakten aus dem Wissen unten.
${knowledge}`
}

View file

@ -0,0 +1,77 @@
import * as z from "zod"
export const AccessSchema = z.union([
z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{20,64}$/) }),
z.object({ variantId: z.string().uuid() }),
])
export type AiAccess = z.infer<typeof AccessSchema>
export const ChatRequestSchema = z.object({
access: AccessSchema,
mode: z.enum(["chat", "phone"]),
messages: z
.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().trim().min(1).max(2000) }))
.min(1)
.max(40),
})
export const CHANNELS = { instagram: "Instagram", facebook: "Facebook", linkedin: "LinkedIn", google: "Google Ads" } as const
export type Channel = keyof typeof CHANNELS
export const TONES = ["Locker", "Professionell", "Emotional", "Witzig"] as const
export type Tone = (typeof TONES)[number]
export const GenerateRequestSchema = z.discriminatedUnion("task", [
z.object({ task: z.literal("email"), access: AccessSchema, inquiry: z.string().trim().min(10).max(4000) }),
z.object({ task: z.literal("quote"), access: AccessSchema, inquiry: z.string().trim().min(10).max(4000) }),
z.object({
task: z.literal("content"),
access: AccessSchema,
topic: z.string().trim().min(3).max(500),
channel: z.enum(["instagram", "facebook", "linkedin", "google"]),
tone: z.enum(TONES),
}),
])
export const BookingSchema = z.object({
art: z.string(),
name: z.string(),
kontakt: z.string(),
datum: z.string().optional(),
uhrzeit: z.string().optional(),
personen: z.string().optional(),
anliegen: z.string().optional(),
})
export type Booking = z.infer<typeof BookingSchema>
export const QuoteSchema = z.object({
betreff: z.string(),
anrede: z.string(),
einleitung: z.string(),
positionen: z.array(
z.object({ titel: z.string(), beschreibung: z.string(), menge: z.number(), einheit: z.string(), einzelpreis: z.number() })
),
hinweise: z.array(z.string()),
rueckfragen: z.array(z.string()),
gueltig_tage: z.number(),
abschluss: z.string(),
})
export type Quote = z.infer<typeof QuoteSchema>
export const ContentSchema = z.object({
varianten: z.array(
z.object({ hook: z.string(), text: z.string(), hashtags: z.array(z.string()), cta: z.string(), bildidee: z.string() })
),
})
export type ContentResult = z.infer<typeof ContentSchema>
// Zeilenweise JSON (NDJSON) von /api/ai/chat und /api/ai/generate (E-Mail)
export type AiEvent =
| { type: "text"; text: string }
| { type: "booking"; data: Booking }
| { type: "error"; message: string }
| { type: "done" }

View file

@ -0,0 +1,76 @@
import "server-only"
import Anthropic from "@anthropic-ai/sdk"
import { getProfile } from "@/lib/supabase/dal"
import { createAdminClient } from "@/lib/supabase/admin"
import { createClient } from "@/lib/supabase/server"
import { resolveContent } from "../lib/content"
import { getTemplate } from "../lib/registry"
import type { AiSettings, TemplateContent, TemplateDefinition } from "../lib/types"
import type { AiAccess } from "./schemas"
export const AI_MODEL = "claude-opus-5-5"
// Lehnt das Modell eine Anfrage aus Sicherheitsgründen ab, übernimmt serverseitig automatisch ein Ersatzmodell
export const FALLBACK = { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" as const }
// KI-Anfragen pro geteiltem Link und Tag (Kostenbremse für öffentliche Demos)
const DAILY_LIMIT = Number(process.env.AI_DAILY_LIMIT ?? 150)
let client: Anthropic | null = null
export function getAnthropic() {
client ??= new Anthropic()
return client
}
type AiFeature = keyof Pick<AiSettings, "chat" | "phone" | "assistant" | "content">
export type AiContext = { def: TemplateDefinition; content: TemplateContent }
type AccessResult = AiContext | { error: string; status: number }
// Admins testen über die Varianten-ID (ohne Limit), Kunden über den Freigabe-Link (nur freigeschaltete Demos, mit Tageslimit)
export async function resolveAiAccess(access: AiAccess, feature: AiFeature): Promise<AccessResult> {
if ("variantId" in access) {
const profile = await getProfile()
if (profile?.role !== "admin") return { error: "Keine Berechtigung.", status: 403 }
const supabase = await createClient()
const { data } = await supabase.from("template_variants").select("template_key, content").eq("id", access.variantId).maybeSingle()
const def = data ? getTemplate(data.template_key) : undefined
if (!data || !def) return { error: "Variante nicht gefunden.", status: 404 }
return { def, content: resolveContent(def, data.content) }
}
const admin = createAdminClient()
const { data } = await admin
.from("template_variants")
.select("id, template_key, content, share_expires_at")
.eq("share_token", access.token)
.maybeSingle()
const def = data ? getTemplate(data.template_key) : undefined
const expired = data?.share_expires_at ? new Date(data.share_expires_at).getTime() <= Date.now() : false
if (!data || !def || expired) return { error: "Dieser Link ist nicht mehr gültig.", status: 404 }
const content = resolveContent(def, data.content)
if (!content.ai[feature]) return { error: "Diese KI-Demo ist für den Link nicht freigeschaltet.", status: 403 }
const { data: allowed } = await admin.rpc("consume_ai_quota", { p_variant_id: data.id, p_limit: DAILY_LIMIT })
if (allowed !== true) return { error: "Das Tageslimit für diese Demo ist erreicht. Bitte morgen erneut testen.", status: 429 }
return { def, content }
}
export function aiErrorMessage(error: unknown) {
if (error instanceof Anthropic.AuthenticationError || error instanceof Anthropic.PermissionDeniedError) {
return "Die KI ist noch nicht eingerichtet – bitte den API-Schlüssel prüfen."
}
if (error instanceof Anthropic.RateLimitError) return "Gerade sind sehr viele Anfragen unterwegs. Bitte gleich noch einmal versuchen."
if (error instanceof Anthropic.APIError) return "Die KI ist gerade nicht erreichbar. Bitte später erneut versuchen."
if (error instanceof Anthropic.AnthropicError) return "Die KI ist noch nicht eingerichtet: ANTHROPIC_API_KEY in der .env eintragen."
return "Etwas ist schiefgelaufen. Bitte erneut versuchen."
}
export function ndjsonResponse(body: ReadableStream<Uint8Array>) {
return new Response(body, { headers: { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store" } })
}

View file

@ -0,0 +1,87 @@
"use client"
import type { CSSProperties } from "react"
import { ArrowLeftIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DemoInfo } from "../demo-info"
import { AI_DEMOS, demoHref, type AiAccess, type AiDemoSlug } from "../demos"
import { AssistantDemo } from "./assistant-demo"
import { ContentDemo } from "./content-demo"
import { PhoneDemo } from "./phone-demo"
const intro: Record<AiDemoSlug, (brand: string) => { title: string; text: string }> = {
telefon: (brand) => ({
title: "Ihr KI-Telefonassistent",
text: `Nimmt Anrufe an, beantwortet Fragen rund um ${brand} und notiert Terminwünsche – rund um die Uhr. Auf „Anrufen“ klicken und einfach lossprechen.`,
}),
assistent: () => ({
title: "Angebots- & E-Mail-Assistent",
text: "Aus einer Kundenanfrage wird in Sekunden eine passende Antwort oder ein Angebotsentwurf – auf Basis Ihrer Leistungen und Preise.",
}),
content: (brand) => ({
title: "Content-Generator",
text: `Posts und Anzeigen für ${brand} in Ihrem Ton – für Instagram, Facebook, LinkedIn und Google Ads.`,
}),
}
// Gemeinsamer Rahmen der KI-Demos: dunkle Produktoberfläche in der Akzentfarbe des Kunden
export function AiDemo({ tool, access, info }: { tool: AiDemoSlug; access: AiAccess; info: DemoInfo }) {
const { title, text } = intro[tool](info.brand)
return (
<div
style={{ "--t-accent": info.accent, "--t-accent-fg": info.accentFg } as CSSProperties}
className="relative min-h-dvh overflow-x-clip bg-[#09090b] font-sans text-zinc-100 [color-scheme:dark]"
>
<div
aria-hidden
className="pointer-events-none fixed inset-0 bg-[radial-gradient(70rem_40rem_at_80%_-10%,color-mix(in_oklab,var(--t-accent)_22%,transparent),transparent_70%)]"
/>
<header className="relative mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-5 py-5">
<a href={info.siteHref} className="group flex items-center gap-3">
<span className="grid size-10 place-items-center rounded-xl bg-t-accent text-lg font-semibold text-t-accent-fg">
{info.brand.charAt(0).toUpperCase()}
</span>
<span>
<span className="block font-semibold">{info.brand}</span>
<span className="flex items-center gap-1 text-xs text-zinc-400 group-hover:text-zinc-200">
<ArrowLeftIcon className="size-3" /> Zur Webseite
</span>
</span>
</a>
<nav aria-label="KI-Demos" className="flex flex-wrap gap-1 rounded-full border border-white/10 bg-white/5 p-1 text-sm">
{info.demos.map((slug) => (
<a
key={slug}
href={demoHref(access, slug)}
aria-current={slug === tool ? "page" : undefined}
className={cn(
"rounded-full px-4 py-2 transition-colors",
slug === tool ? "bg-white font-medium text-zinc-900" : "text-zinc-300 hover:text-white"
)}
>
{AI_DEMOS[slug].label}
</a>
))}
</nav>
</header>
<main className="relative mx-auto max-w-6xl px-5 pt-6 pb-16">
<div className="mb-10 max-w-2xl">
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-300">
<span className="size-1.5 rounded-full bg-t-accent" /> KI-Demo für {info.brand}
</p>
<h1 className="text-3xl font-semibold tracking-tight md:text-5xl">{title}</h1>
<p className="mt-4 text-zinc-400 md:text-lg">{text}</p>
</div>
{tool === "telefon" && <PhoneDemo access={access} info={info} />}
{tool === "assistent" && <AssistantDemo access={access} info={info} />}
{tool === "content" && <ContentDemo access={access} info={info} />}
</main>
<footer className="relative mx-auto max-w-6xl px-5 pb-8 text-xs text-zinc-500">
Demo von Since3 · Antworten entstehen live per KI und können Fehler enthalten. Es wird nichts versendet oder gebucht.
</footer>
</div>
)
}

View file

@ -0,0 +1,284 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import { CheckIcon, CopyIcon, FileTextIcon, LoaderCircleIcon, MailIcon, RotateCcwIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DemoInfo } from "../demo-info"
import type { AiAccess } from "../demos"
import type { Quote } from "../schemas"
import { readNdjson } from "./use-ai-chat"
type Mode = "email" | "quote"
type QuoteView = Quote & { number: string; date: Date }
const steps: Record<Mode, string[]> = {
email: ["Anfrage verstehen", "Passende Infos heraussuchen", "Antwort formulieren"],
quote: ["Anfrage analysieren", "Positionen ableiten", "Preise kalkulieren", "Angebot gestalten"],
}
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
const day = new Intl.DateTimeFormat("de-DE", { dateStyle: "long" })
function quoteNumber(text: string, year: number) {
let hash = 0
for (const char of text) hash = (hash * 31 + char.charCodeAt(0)) | 0
return `A-${year}-${(Math.abs(hash) % 9000) + 1000}`
}
export function AssistantDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
const [inquiry, setInquiry] = useState(info.samples.inquiry)
const [mode, setMode] = useState<Mode>("email")
const [email, setEmail] = useState("")
const [quote, setQuote] = useState<QuoteView | null>(null)
const [pending, setPending] = useState(false)
const [step, setStep] = useState(0)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!pending) return
const timer = setInterval(() => setStep((s) => s + 1), 1600)
return () => clearInterval(timer)
}, [pending])
async function run(next: Mode) {
setMode(next)
setPending(true)
setStep(0)
setError(null)
try {
if (next === "email") {
setEmail("")
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task: "email", access, inquiry }),
})
await readNdjson(response, (event) => {
if (event.type === "error") throw new Error(event.message)
if (event.type === "text") setEmail((text) => text + event.text)
})
} else {
setQuote(null)
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task: "quote", access, inquiry }),
})
const data = (await response.json().catch(() => null)) as { result?: Quote; error?: string } | null
if (!response.ok || !data?.result) throw new Error(data?.error ?? "Die KI ist gerade nicht erreichbar.")
const today = new Date()
setQuote({ ...data.result, number: quoteNumber(inquiry, today.getFullYear()), date: today })
}
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Die KI ist gerade nicht erreichbar.")
} finally {
setPending(false)
}
}
async function copy(text: string) {
await navigator.clipboard.writeText(text).catch(() => undefined)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const subjectLine = email.match(/^Betreff:\s*(.*)$/m)
const subject = subjectLine?.[1]?.trim() ?? ""
const body = subject ? email.replace(/^Betreff:.*\n?/m, "").replace(/^\n+/, "") : email
const showLoader = pending && (mode === "quote" || !email)
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<section className="flex flex-col rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="font-medium">Kundenanfrage</h2>
<button
type="button"
onClick={() => setInquiry(info.samples.inquiry)}
className="flex cursor-pointer items-center gap-1.5 text-xs text-zinc-400 hover:text-white"
>
<RotateCcwIcon className="size-3.5" /> Beispiel
</button>
</div>
<p className="mt-1 text-sm text-zinc-500">Eigene Anfrage einfügen oder das Beispiel nutzen.</p>
<textarea
value={inquiry}
onChange={(event) => setInquiry(event.target.value)}
rows={10}
aria-label="Kundenanfrage"
className="mt-4 w-full flex-1 resize-none rounded-2xl border border-white/10 bg-black/30 p-4 text-sm leading-relaxed outline-none focus:border-t-accent"
/>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
disabled={pending || inquiry.trim().length < 10}
onClick={() => run("email")}
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-t-accent px-4 py-3 text-sm font-medium text-t-accent-fg transition-opacity disabled:opacity-40"
>
<MailIcon className="size-4" /> Antwort schreiben
</button>
<button
type="button"
disabled={pending || inquiry.trim().length < 10}
onClick={() => run("quote")}
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-white/15 px-4 py-3 text-sm font-medium transition-colors hover:border-white/40 disabled:opacity-40"
>
<FileTextIcon className="size-4" /> Angebot erstellen
</button>
</div>
</section>
<section className="min-h-[34rem] rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
{error && <p className="mb-4 rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</p>}
{showLoader ? (
<ol className="mx-auto mt-16 max-w-xs space-y-3">
{steps[mode].map((label, i) => {
const current = Math.min(step, steps[mode].length - 1)
return (
<li key={label} className={cn("flex items-center gap-3 text-sm transition-colors", i <= current ? "text-zinc-100" : "text-zinc-600")}>
{i < current ? (
<CheckIcon className="size-4 text-t-accent" />
) : i === current ? (
<LoaderCircleIcon className="size-4 animate-spin text-t-accent" />
) : (
<span className="size-4 rounded-full border border-zinc-700" />
)}
{label}
</li>
)
})}
</ol>
) : mode === "email" && email ? (
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="overflow-hidden rounded-2xl bg-white text-zinc-900">
<header className="space-y-1 border-b border-zinc-200 px-6 py-4 text-sm">
<p><span className="text-zinc-500">Von:</span> Team {info.brand}</p>
<p><span className="text-zinc-500">Betreff:</span> <span className="font-medium">{subject || "…"}</span></p>
</header>
<div className="whitespace-pre-wrap px-6 py-5 text-[15px] leading-relaxed">
{body}
{pending && <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-900 align-middle" />}
</div>
{!pending && (
<footer className="flex justify-end border-t border-zinc-200 px-6 py-3">
<CopyButton copied={copied} onClick={() => copy(email)} />
</footer>
)}
</motion.article>
) : mode === "quote" && quote ? (
<QuoteDocument quote={quote} brand={info.brand} copied={copied} onCopy={copy} />
) : (
<div className="grid h-full min-h-[28rem] place-items-center text-center text-sm text-zinc-500">
<p>Wählen Sie links „Antwort schreiben“ oder „Angebot erstellen“.<br />Das Ergebnis erscheint hier – in wenigen Sekunden.</p>
</div>
)}
</section>
</div>
)
}
function CopyButton({ copied, onClick }: { copied: boolean; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex cursor-pointer items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-100">
{copied ? <CheckIcon className="size-4 text-emerald-600" /> : <CopyIcon className="size-4" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
)
}
function QuoteDocument({ quote, brand, copied, onCopy }: { quote: QuoteView; brand: string; copied: boolean; onCopy: (text: string) => void }) {
const net = quote.positionen.reduce((sum, item) => sum + item.menge * item.einzelpreis, 0)
const vat = net * 0.19
const validUntil = new Date(quote.date.getTime() + Math.max(1, quote.gueltig_tage) * 86_400_000)
const plain = [
`Angebot ${quote.number} – ${quote.betreff}`,
"",
quote.anrede,
quote.einleitung,
"",
...quote.positionen.map((item, i) => `${i + 1}. ${item.titel} – ${item.menge} ${item.einheit} × ${euro.format(item.einzelpreis)} = ${euro.format(item.menge * item.einzelpreis)}`),
"",
`Netto: ${euro.format(net)} · MwSt. 19 %: ${euro.format(vat)} · Gesamt: ${euro.format(net + vat)}`,
...quote.hinweise.map((note) => `– ${note}`),
"",
quote.abschluss,
].join("\n")
return (
<div className="space-y-4">
<AnimatePresence>
{quote.rueckfragen.length > 0 && (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 text-sm text-amber-100">
<p className="font-medium">Vor dem Versand klären (nur intern)</p>
<ul className="mt-2 list-disc space-y-1 pl-5 text-amber-100/80">
{quote.rueckfragen.map((question) => (
<li key={question}>{question}</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl bg-white p-6 text-zinc-900 md:p-8">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-200 pb-6">
<div>
<p className="text-xl font-semibold">{brand}</p>
<p className="text-sm text-zinc-500">Angebot {quote.number}</p>
</div>
<p className="text-sm text-zinc-500">{day.format(quote.date)}</p>
</header>
<h3 className="mt-6 text-lg font-semibold">{quote.betreff}</h3>
<p className="mt-4 text-sm">{quote.anrede}</p>
<p className="mt-2 text-sm leading-relaxed text-zinc-700">{quote.einleitung}</p>
<div className="mt-6 overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b border-zinc-200 text-left text-xs uppercase tracking-wider text-zinc-500">
<th className="py-2 pr-2 font-medium">Pos.</th>
<th className="py-2 pr-2 font-medium">Leistung</th>
<th className="py-2 pr-2 text-right font-medium">Menge</th>
<th className="py-2 pr-2 text-right font-medium">Einzelpreis</th>
<th className="py-2 text-right font-medium">Gesamt</th>
</tr>
</thead>
<tbody>
{quote.positionen.map((item, i) => (
<tr key={i} className="border-b border-zinc-100 align-top">
<td className="py-3 pr-2 text-zinc-500">{i + 1}</td>
<td className="py-3 pr-2">
<p className="font-medium">{item.titel}</p>
<p className="text-xs text-zinc-500">{item.beschreibung}</p>
</td>
<td className="py-3 pr-2 text-right whitespace-nowrap">{item.menge.toLocaleString("de-DE")} {item.einheit}</td>
<td className="py-3 pr-2 text-right whitespace-nowrap">{euro.format(item.einzelpreis)}</td>
<td className="py-3 text-right font-medium whitespace-nowrap">{euro.format(item.menge * item.einzelpreis)}</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="mt-4 ml-auto w-full max-w-xs space-y-1 text-sm">
<div className="flex justify-between"><dt className="text-zinc-500">Netto</dt><dd>{euro.format(net)}</dd></div>
<div className="flex justify-between"><dt className="text-zinc-500">MwSt. 19 %</dt><dd>{euro.format(vat)}</dd></div>
<div className="flex justify-between border-t border-zinc-200 pt-2 text-base font-semibold"><dt>Gesamt</dt><dd>{euro.format(net + vat)}</dd></div>
</dl>
{quote.hinweise.length > 0 && (
<ul className="mt-6 space-y-1 text-xs text-zinc-500">
{quote.hinweise.map((note) => (
<li key={note}>– {note}</li>
))}
</ul>
)}
<p className="mt-6 text-sm leading-relaxed text-zinc-700">{quote.abschluss}</p>
<footer className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 pt-4 text-xs text-zinc-500">
<span>Gültig bis {day.format(validUntil)}</span>
<CopyButton copied={copied} onClick={() => onCopy(plain)} />
</footer>
</motion.article>
</div>
)
}

View file

@ -0,0 +1,33 @@
import { Fragment } from "react"
import { CircleCheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Booking } from "../schemas"
export function BookingCard({ data, className }: { data: Booking; className?: string }) {
const rows = [
["Anliegen", data.anliegen],
["Datum", data.datum],
["Uhrzeit", data.uhrzeit],
["Personen", data.personen],
["Name", data.name],
["Kontakt", data.kontakt],
].filter((row): row is [string, string] => Boolean(row[1]?.trim()))
return (
<div className={cn("rounded-2xl border p-4 text-sm", className)}>
<p className="flex items-center gap-2 font-medium">
<CircleCheckIcon className="size-4 shrink-0" />
{data.art || "Anfrage"} erfasst
</p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
{rows.map(([label, value]) => (
<Fragment key={label}>
<dt className="opacity-60">{label}</dt>
<dd className="break-words">{value}</dd>
</Fragment>
))}
</dl>
<p className="mt-3 text-xs opacity-60">Demo: Live landet die Anfrage direkt im Postfach oder Kalender des Teams.</p>
</div>
)
}

View file

@ -0,0 +1,192 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ArrowUpIcon, MessageCircleIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { brandName } from "../../lib/content"
import type { TemplateContent } from "../../lib/types"
import { useAiAccess } from "../access-context"
import { BookingCard } from "./booking-card"
import { useAiChat } from "./use-ai-chat"
const ease = [0.22, 1, 0.36, 1] as const
const QUICK_REPLIES = ["Wie sind Ihre Öffnungszeiten?", "Ich möchte einen Termin vereinbaren", "Was bieten Sie an?"]
// Chat-Assistent unten rechts; nutzt Palette und Schriften der Vorlage (bg-t-*, font-t-*)
export function ChatWidget({ content }: { content: TemplateContent }) {
const access = useAiAccess()
const chat = useAiChat({ access, mode: "chat" })
const [open, setOpen] = useState(false)
const [teaser, setTeaser] = useState(false)
const [draft, setDraft] = useState("")
const listRef = useRef<HTMLDivElement>(null)
const enabled = Boolean(access && content.ai.chat)
const name = content.ai.name.trim() || "Assistent"
const brand = brandName(content)
const greeting = content.ai.greeting.trim() || `Hallo! Ich bin ${name}${brand ? ` von ${brand}` : ""}. Wie kann ich Ihnen helfen?`
useEffect(() => {
if (!enabled) return
const timer = setTimeout(() => setTeaser(true), 4000)
return () => clearTimeout(timer)
}, [enabled])
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" })
}, [chat.messages, open])
if (!enabled) return null
const last = chat.messages[chat.messages.length - 1]
const typing = chat.pending && last?.role === "assistant" && last.parts.length === 0
function submit(text: string) {
if (!text.trim() || chat.pending) return
setDraft("")
void chat.send(text)
}
return (
<div className="fixed right-4 bottom-[calc(1rem+var(--t-safe-bottom,0px))] z-[70] flex flex-col items-end gap-3 font-t-body">
<AnimatePresence>
{open && (
<motion.section
aria-label={`Chat mit ${name}`}
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.96 }}
transition={{ duration: 0.35, ease }}
className="flex h-[min(560px,calc(100dvh-7rem))] w-[min(380px,calc(100vw-2rem))] origin-bottom-right flex-col overflow-hidden rounded-3xl border border-t-fg/10 bg-t-bg text-t-fg shadow-[0_30px_80px_-20px_rgba(0,0,0,0.45)]"
>
<header className="flex items-center gap-3 border-b border-t-fg/10 px-4 py-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-t-accent font-semibold text-t-accent-fg">
{name.charAt(0).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{name}</p>
<p className="flex items-center gap-1.5 text-xs text-t-muted">
<span className="size-1.5 rounded-full bg-emerald-500" /> KI-Assistent · antwortet sofort
</p>
</div>
<button type="button" aria-label="Chat schließen" onClick={() => setOpen(false)} className="grid size-9 cursor-pointer place-items-center rounded-full hover:bg-t-fg/5">
<XIcon className="size-4" />
</button>
</header>
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">{greeting}</Bubble>
{chat.messages.map((message, i) => (
<div key={i} className="space-y-2">
{message.parts.map((part, j) =>
part.type === "booking" ? (
<BookingCard key={j} data={part.data} className="border-t-accent/40 bg-t-surface" />
) : (
<Bubble key={j} role={message.role}>{part.text}</Bubble>
)
)}
</div>
))}
{typing && (
<div className="flex w-fit gap-1 rounded-2xl rounded-bl-md bg-t-surface px-4 py-3">
{[0, 1, 2].map((dot) => (
<motion.span
key={dot}
className="size-1.5 rounded-full bg-t-muted"
animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.8, repeat: Infinity, delay: dot * 0.15 }}
/>
))}
</div>
)}
{chat.error && <p className="text-sm text-red-500">{chat.error}</p>}
{chat.messages.length === 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{QUICK_REPLIES.map((reply) => (
<button
key={reply}
type="button"
onClick={() => submit(reply)}
className="cursor-pointer rounded-full border border-t-fg/15 px-3 py-1.5 text-left text-xs transition-colors hover:border-t-accent hover:text-t-accent"
>
{reply}
</button>
))}
</div>
)}
</div>
<form
className="flex items-center gap-2 border-t border-t-fg/10 p-3"
onSubmit={(event) => {
event.preventDefault()
submit(draft)
}}
>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Nachricht schreiben …"
aria-label="Nachricht"
maxLength={1500}
className="min-w-0 flex-1 rounded-full bg-t-surface px-4 py-2.5 text-sm outline-none placeholder:text-t-muted focus:ring-2 focus:ring-t-accent/40"
/>
<button
type="submit"
aria-label="Senden"
disabled={!draft.trim() || chat.pending}
className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg transition-opacity disabled:opacity-40"
>
<ArrowUpIcon className="size-4" />
</button>
</form>
<p className="pb-2 text-center text-[10px] text-t-muted">KI-Demo · Antworten können Fehler enthalten</p>
</motion.section>
)}
</AnimatePresence>
<AnimatePresence>
{teaser && !open && (
<motion.button
type="button"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
onClick={() => setOpen(true)}
className="max-w-60 cursor-pointer rounded-2xl rounded-br-md bg-t-bg px-4 py-3 text-left text-sm text-t-fg shadow-xl ring-1 ring-t-fg/10"
>
Fragen? Ich antworte sofort – auch auf Terminwünsche.
</motion.button>
)}
</AnimatePresence>
<motion.button
type="button"
aria-label={open ? "Chat schließen" : `Chat mit ${name} öffnen`}
onClick={() => {
setOpen(!open)
setTeaser(false)
}}
whileHover={{ scale: 1.06 }}
whileTap={{ scale: 0.94 }}
className="grid size-14 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-[0_12px_40px_-8px_var(--t-accent)]"
>
{open ? <XIcon className="size-5" /> : <MessageCircleIcon className="size-6" />}
</motion.button>
</div>
)
}
function Bubble({ role, children }: { role: "user" | "assistant"; children: string }) {
return (
<p
className={cn(
"w-fit max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
role === "user" ? "ml-auto rounded-br-md bg-t-accent text-t-accent-fg" : "rounded-bl-md bg-t-surface"
)}
>
{children}
</p>
)
}

View file

@ -0,0 +1,219 @@
"use client"
import { motion } from "motion/react"
import { useState } from "react"
import { BookmarkIcon, CheckIcon, CopyIcon, GlobeIcon, HeartIcon, MessageCircleIcon, SendIcon, SparklesIcon, ThumbsUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "../../catalog/shared/media"
import type { DemoInfo } from "../demo-info"
import type { AiAccess } from "../demos"
import { CHANNELS, TONES, type Channel, type ContentResult, type Tone } from "../schemas"
type Variant = ContentResult["varianten"][number]
function slug(brand: string) {
return brand.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/[^a-z0-9]/g, "")
}
const tags = (variant: Variant) => variant.hashtags.map((tag) => `#${tag.replace(/^#/, "")}`).join(" ")
export function ContentDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
const [channel, setChannel] = useState<Channel>("instagram")
const [topic, setTopic] = useState(info.samples.topic)
const [tone, setTone] = useState<Tone>("Locker")
const [result, setResult] = useState<{ channel: Channel; items: Variant[] } | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
async function run() {
setPending(true)
setError(null)
try {
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task: "content", access, topic, channel, tone }),
})
const data = (await response.json().catch(() => null)) as { result?: ContentResult; error?: string } | null
if (!response.ok || !data?.result) throw new Error(data?.error ?? "Die KI ist gerade nicht erreichbar.")
setResult({ channel, items: data.result.varianten.slice(0, 3) })
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Die KI ist gerade nicht erreichbar.")
} finally {
setPending(false)
}
}
const handle = slug(info.brand) || "ihrbetrieb"
const image = (i: number) => info.images[(i * 2 + 1) % Math.max(info.images.length, 1)] ?? ""
return (
<div className="space-y-8">
<section className="rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-5 lg:grid-cols-[1fr_auto] lg:items-end">
<label className="block">
<span className="text-sm font-medium">Worum geht es?</span>
<input
value={topic}
onChange={(event) => setTopic(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm outline-none focus:border-t-accent"
/>
</label>
<button
type="button"
onClick={run}
disabled={pending || topic.trim().length < 3}
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-t-accent px-6 py-3 text-sm font-medium text-t-accent-fg transition-opacity disabled:opacity-40"
>
<SparklesIcon className={cn("size-4", pending && "animate-spin")} />
{pending ? "Wird erstellt …" : "Drei Varianten erstellen"}
</button>
</div>
<div className="mt-5 flex flex-wrap gap-6">
<Segmented label="Kanal" value={channel} options={Object.entries(CHANNELS) as [Channel, string][]} onChange={setChannel} />
<Segmented label="Tonalität" value={tone} options={TONES.map((t) => [t, t] as [Tone, string])} onChange={setTone} />
</div>
</section>
{error && <p className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</p>}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{pending
? [0, 1, 2].map((i) => <div key={i} className="h-[30rem] animate-pulse rounded-2xl bg-white/[0.06]" />)
: result?.items.map((variant, i) => (
<motion.div key={`${result.channel}-${i}`} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}>
<Mockup channel={result.channel} variant={variant} brand={info.brand} handle={handle} image={image(i)} />
</motion.div>
))}
</div>
{!pending && !result && (
<p className="text-center text-sm text-zinc-500">Kanal und Tonalität wählen, Thema anpassen – fertig sind drei Entwürfe als echte Vorschau.</p>
)}
</div>
)
}
function Segmented<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: [T, string][]; onChange: (value: T) => void }) {
return (
<div>
<p className="mb-2 text-xs text-zinc-400">{label}</p>
<div className="flex flex-wrap gap-1 rounded-xl border border-white/10 bg-black/30 p-1">
{options.map(([key, text]) => (
<button
key={key}
type="button"
aria-pressed={key === value}
onClick={() => onChange(key)}
className={cn("cursor-pointer rounded-lg px-3 py-1.5 text-sm transition-colors", key === value ? "bg-white text-zinc-900" : "text-zinc-300 hover:text-white")}
>
{text}
</button>
))}
</div>
</div>
)
}
function Avatar({ brand, ring }: { brand: string; ring?: boolean }) {
return (
<span className={cn("grid size-9 shrink-0 place-items-center rounded-full bg-t-accent text-sm font-semibold text-t-accent-fg", ring && "ring-2 ring-pink-500 ring-offset-2")}>
{brand.charAt(0).toUpperCase()}
</span>
)
}
function Mockup({ channel, variant, brand, handle, image }: { channel: Channel; variant: Variant; brand: string; handle: string; image: string }) {
const [copied, setCopied] = useState(false)
const text = [variant.hook, variant.text, tags(variant)].filter(Boolean).join("\n\n")
async function copy() {
await navigator.clipboard.writeText(channel === "google" ? `${variant.hook}\n${variant.text}` : text).catch(() => undefined)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<article className="flex h-full flex-col overflow-hidden rounded-2xl bg-white text-zinc-900 shadow-xl">
{channel === "google" ? (
<div className="flex-1 p-5">
<p className="text-xs font-semibold">Gesponsert</p>
<div className="mt-2 flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-full bg-zinc-100 text-xs font-semibold">{brand.charAt(0)}</span>
<div className="text-xs leading-tight">
<p>{brand}</p>
<p className="text-zinc-500">www.{handle}.de</p>
</div>
</div>
<p className="mt-3 text-xl leading-snug text-[#1a0dab]">{variant.hook}</p>
<p className="mt-1 text-sm text-zinc-600">{variant.text}</p>
<p className="mt-4 text-xs text-zinc-400">{variant.hook.length}/30 · {variant.text.length}/90 Zeichen</p>
</div>
) : (
<>
<header className="flex items-center gap-3 px-4 py-3">
<Avatar brand={brand} ring={channel === "instagram"} />
<div className="min-w-0 text-sm leading-tight">
<p className="truncate font-semibold">{channel === "instagram" ? handle : brand}</p>
<p className="flex items-center gap-1 text-xs text-zinc-500">
{channel === "linkedin" ? "Unternehmensseite · 2.814 Follower" : channel === "facebook" ? <>Gesponsert · <GlobeIcon className="size-3" /></> : "Gesponsert"}
</p>
</div>
</header>
{channel !== "instagram" && <PostText variant={variant} className="px-4 pb-3" />}
<div className={cn("relative bg-zinc-100", channel === "instagram" ? "aspect-square" : "aspect-[1.91/1]")}>
<TImage src={image} sizes="(min-width: 1280px) 30vw, (min-width: 768px) 45vw, 100vw" />
</div>
{channel === "instagram" ? (
<div className="px-4 py-3">
<div className="flex items-center gap-4">
<HeartIcon className="size-6" />
<MessageCircleIcon className="size-6" />
<SendIcon className="size-6" />
<BookmarkIcon className="ml-auto size-6" />
</div>
<p className="mt-2 text-sm font-semibold">Gefällt 1.284 Mal</p>
<p className="mt-1 text-sm">
<span className="font-semibold">{handle}</span> <PostText variant={variant} inline />
</p>
</div>
) : (
<div className="flex items-center gap-6 border-t border-zinc-100 px-4 py-2.5 text-sm text-zinc-500">
<span className="flex items-center gap-1.5"><ThumbsUpIcon className="size-4" /> Gefällt mir</span>
<span className="flex items-center gap-1.5"><MessageCircleIcon className="size-4" /> Kommentieren</span>
</div>
)}
</>
)}
<footer className="flex items-center justify-between gap-3 border-t border-zinc-100 bg-zinc-50 px-4 py-2.5 text-xs text-zinc-500">
<span className="line-clamp-1">Bildidee: {variant.bildidee}</span>
<button type="button" onClick={copy} className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-zinc-700 hover:bg-zinc-200">
{copied ? <CheckIcon className="size-3.5 text-emerald-600" /> : <CopyIcon className="size-3.5" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
</footer>
</article>
)
}
function PostText({ variant, inline, className }: { variant: Variant; inline?: boolean; className?: string }) {
const Tag = inline ? "span" : "div"
return (
<Tag className={cn("whitespace-pre-line text-sm leading-relaxed", className)}>
<span className="font-medium">{variant.hook}</span>
{"\n"}
{variant.text}
{variant.hashtags.length > 0 && (
<>
{"\n"}
<span className="text-[#00376b]">{tags(variant)}</span>
</>
)}
{variant.cta && (
<>
{"\n"}
<span className="font-medium">→ {variant.cta}</span>
</>
)}
</Tag>
)
}

View file

@ -0,0 +1,294 @@
"use client"
import { motion } from "motion/react"
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import { ArrowUpIcon, PhoneIcon, PhoneOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DemoInfo } from "../demo-info"
import type { AiAccess } from "../demos"
import { BookingCard } from "./booking-card"
import { useAiChat } from "./use-ai-chat"
// Web Speech API: nicht in allen TS-DOM-Typen enthalten, daher minimal selbst beschrieben
type RecognitionEvent = { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }
type Recognition = {
lang: string
interimResults: boolean
continuous: boolean
start(): void
abort(): void
onresult: ((event: RecognitionEvent) => void) | null
onerror: ((event: { error: string }) => void) | null
onend: (() => void) | null
}
type RecognitionCtor = new () => Recognition
function recognitionCtor(): RecognitionCtor | null {
if (typeof window === "undefined") return null
const speech = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor }
return speech.SpeechRecognition ?? speech.webkitSpeechRecognition ?? null
}
const subscribe = () => () => {}
const EXAMPLES = ["Haben Sie heute geöffnet?", "Ich möchte einen Termin vereinbaren", "Was kostet das bei Ihnen?"]
type CallState = "idle" | "speaking" | "listening" | "thinking" | "ended"
const statusLabel: Record<CallState, string> = {
idle: "Bereit für Ihren Anruf",
speaking: "Spricht …",
listening: "Hört zu …",
thinking: "Denkt nach …",
ended: "Anruf beendet",
}
export function PhoneDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
const [call, setCall] = useState<CallState>("idle")
const [heard, setHeard] = useState("")
const [draft, setDraft] = useState("")
const [seconds, setSeconds] = useState(0)
const [micError, setMicError] = useState<string | null>(null)
const active = useRef(false)
const voice = useRef<SpeechSynthesisVoice | null>(null)
const recognition = useRef<Recognition | null>(null)
const canListen = useSyncExternalStore(subscribe, () => recognitionCtor() !== null, () => true)
const chat = useAiChat({ access, mode: "phone", onReply: speak, onError: () => listen() })
const live = call !== "idle" && call !== "ended"
useEffect(() => {
const synth = typeof window !== "undefined" ? window.speechSynthesis : undefined
const pick = () => {
const german = (synth?.getVoices() ?? []).filter((v) => v.lang.toLowerCase().startsWith("de"))
voice.current = german.find((v) => /google|anna|petra|helena|vicki|marlene|katja/i.test(v.name)) ?? german[0] ?? null
}
pick()
synth?.addEventListener("voiceschanged", pick)
return () => {
synth?.removeEventListener("voiceschanged", pick)
active.current = false
recognition.current?.abort()
synth?.cancel()
}
}, [])
useEffect(() => {
if (!live) return
const timer = setInterval(() => setSeconds((s) => s + 1), 1000)
return () => clearInterval(timer)
}, [live])
function speak(text: string) {
if (!active.current) return
const synth = window.speechSynthesis
if (!text || !synth) return listen()
synth.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = "de-DE"
utterance.rate = 1.04
if (voice.current) utterance.voice = voice.current
utterance.onend = () => listen()
setCall("speaking")
synth.speak(utterance)
}
function listen() {
if (!active.current) return
setHeard("")
const Ctor = recognitionCtor()
if (!Ctor || micError) {
setCall("listening")
return
}
const rec = new Ctor()
rec.lang = "de-DE"
rec.interimResults = true
rec.continuous = false
let finalText = ""
let blocked = false
rec.onresult = (event) => {
let interim = ""
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) finalText += result[0].transcript
else interim += result[0].transcript
}
setHeard(`${finalText} ${interim}`.trim())
}
rec.onerror = (event) => {
if (event.error === "not-allowed" || event.error === "service-not-allowed") {
blocked = true
setMicError("Kein Mikrofonzugriff – bitte erlauben oder unten tippen.")
}
}
rec.onend = () => {
recognition.current = null
if (!active.current) return
if (finalText.trim()) {
setHeard("")
setCall("thinking")
void chat.send(finalText)
} else if (blocked) {
setCall("listening")
} else {
listen()
}
}
recognition.current = rec
setCall("listening")
rec.start()
}
function start() {
active.current = true
setSeconds(0)
setMicError(null)
chat.reset()
speak(info.greeting)
}
function hangUp() {
active.current = false
recognition.current?.abort()
window.speechSynthesis?.cancel()
setCall("ended")
setHeard("")
}
function sendText(text: string) {
if (!text.trim() || chat.pending) return
if (!active.current) {
active.current = true
setSeconds(0)
}
recognition.current?.abort()
setDraft("")
setCall("thinking")
void chat.send(text)
}
const time = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
<section className="flex flex-col items-center rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center">
<p className="text-xs uppercase tracking-[0.25em] text-zinc-400">{live ? "Verbunden" : "KI-Telefon"}</p>
<div className="relative mt-10 grid size-52 place-items-center">
{[0, 1, 2].map((ring) => (
<motion.span
key={ring}
className="absolute inset-0 rounded-full border border-t-accent"
animate={call === "speaking" ? { scale: [1, 1.45], opacity: [0.5, 0] } : { scale: 1, opacity: live ? 0.25 : 0.1 }}
transition={call === "speaking" ? { duration: 1.8, repeat: Infinity, delay: ring * 0.6, ease: "easeOut" } : { duration: 0.4 }}
/>
))}
<div className="relative grid size-36 place-items-center rounded-full bg-t-accent text-5xl font-semibold text-t-accent-fg shadow-[0_0_90px_-10px_var(--t-accent)]">
{info.brand.charAt(0).toUpperCase()}
</div>
</div>
<p className="mt-8 text-xl font-semibold">{info.assistantName}</p>
<p className="text-sm text-zinc-400">{info.brand}</p>
<p className="mt-4 flex h-6 items-center gap-2 text-sm text-t-accent">
{statusLabel[call]}
{live && <span className="font-mono text-zinc-400">{time}</span>}
</p>
<div className="mt-3 flex h-8 items-end gap-1" aria-hidden>
{call === "listening" &&
[0, 1, 2, 3, 4].map((bar) => (
<motion.span
key={bar}
className="w-1.5 rounded-full bg-t-accent"
animate={{ height: [8, 26, 12, 30, 8] }}
transition={{ duration: 1.1, repeat: Infinity, delay: bar * 0.12 }}
/>
))}
</div>
<div className="mt-6">
{live ? (
<button type="button" onClick={hangUp} aria-label="Auflegen" className="grid size-16 cursor-pointer place-items-center rounded-full bg-red-500 text-white transition-transform hover:scale-105">
<PhoneOffIcon className="size-6" />
</button>
) : (
<button type="button" onClick={start} aria-label="Anrufen" className="relative grid size-16 cursor-pointer place-items-center rounded-full bg-emerald-500 text-white transition-transform hover:scale-105">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/40" />
<PhoneIcon className="relative size-6" />
</button>
)}
</div>
<p className="mt-6 max-w-xs text-xs text-zinc-500">
{micError ??
(canListen
? "Nach dem Anrufen den Mikrofonzugriff erlauben und einfach sprechen. Ton einschalten!"
: "Dieser Browser kann keine Sprache erkennen – tippen Sie rechts (Chrome, Edge oder Safari empfohlen).")}
</p>
</section>
<section className="flex min-h-[32rem] flex-col rounded-[2rem] border border-white/10 bg-white/[0.03]">
<header className="border-b border-white/10 px-6 py-4 text-sm font-medium">Live-Mitschrift</header>
<div className="flex-1 space-y-3 overflow-y-auto px-6 py-5">
{call === "idle" && chat.messages.length === 0 && (
<p className="text-sm text-zinc-500">Hier erscheint das Gespräch, sobald Sie anrufen oder eine Frage tippen.</p>
)}
{call !== "idle" && <Line role="assistant" text={info.greeting} />}
{chat.messages.map((message, i) =>
message.parts.map((part, j) =>
part.type === "booking" ? (
<BookingCard key={`${i}-${j}`} data={part.data} className="border-t-accent/40 bg-white/5" />
) : (
<Line key={`${i}-${j}`} role={message.role} text={part.text} />
)
)
)}
{heard && <p className="text-sm italic text-zinc-400">„{heard} …“</p>}
{chat.error && <p className="text-sm text-red-400">{chat.error}</p>}
</div>
<div className="space-y-3 border-t border-white/10 p-4">
<div className="flex flex-wrap gap-2">
{EXAMPLES.map((example) => (
<button
key={example}
type="button"
onClick={() => sendText(example)}
className="cursor-pointer rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:border-t-accent hover:text-white"
>
{example}
</button>
))}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
sendText(draft)
}}
>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Oder hier tippen …"
aria-label="Nachricht an den Assistenten"
className="min-w-0 flex-1 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm outline-none focus:border-t-accent"
/>
<button type="submit" aria-label="Senden" disabled={!draft.trim() || chat.pending} className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg disabled:opacity-40">
<ArrowUpIcon className="size-4" />
</button>
</form>
</div>
</section>
</div>
)
}
function Line({ role, text }: { role: "user" | "assistant"; text: string }) {
return (
<div className={cn("flex", role === "user" && "justify-end")}>
<p className={cn("max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed", role === "user" ? "bg-t-accent text-t-accent-fg" : "bg-white/[0.06]")}>
<span className="mb-0.5 block text-[10px] uppercase tracking-wider opacity-60">{role === "user" ? "Anrufer:in" : "Assistent"}</span>
{text}
</p>
</div>
)
}

View file

@ -0,0 +1,120 @@
"use client"
import { useRef, useState } from "react"
import type { AiAccess } from "../demos"
import type { AiEvent, Booking } from "../schemas"
export type ChatPart = { type: "text"; text: string } | { type: "booking"; data: Booking }
export type ChatMessage = { role: "user" | "assistant"; parts: ChatPart[] }
const FALLBACK_ERROR = "Die KI ist gerade nicht erreichbar."
export async function readNdjson(response: Response, onEvent: (event: AiEvent) => void) {
if (!response.ok || !response.body) {
const data = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(data?.error ?? FALLBACK_ERROR)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newline = buffer.indexOf("\n")
while (newline >= 0) {
const line = buffer.slice(0, newline).trim()
buffer = buffer.slice(newline + 1)
if (line) onEvent(JSON.parse(line) as AiEvent)
newline = buffer.indexOf("\n")
}
}
}
export function messageText(message: ChatMessage) {
return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("").trim()
}
// Der Verlauf liegt zusätzlich in einer Ref: Sprach-Callbacks des KI-Telefons rufen `send` aus älteren Renders auf
export function useAiChat({ access, mode, onReply, onError }: {
access: AiAccess | null
mode: "chat" | "phone"
onReply?: (text: string) => void
onError?: () => void
}) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const history = useRef<ChatMessage[]>([])
const busy = useRef(false)
function commit(next: ChatMessage[]) {
history.current = next
setMessages(next)
}
function updateLast(change: (last: ChatMessage) => ChatMessage) {
const current = history.current
commit([...current.slice(0, -1), change(current[current.length - 1])])
}
async function send(text: string) {
const content = text.trim()
if (!content || !access || busy.current) return
busy.current = true
const base: ChatMessage[] = [...history.current, { role: "user", parts: [{ type: "text", text: content }] }]
commit([...base, { role: "assistant", parts: [] }])
setPending(true)
setError(null)
let reply = ""
try {
const response = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
access,
mode,
messages: base
.map((message) => ({ role: message.role, content: messageText(message) }))
.filter((message) => message.content)
.slice(-30),
}),
})
await readNdjson(response, (event) => {
if (event.type === "error") throw new Error(event.message)
if (event.type === "booking") {
updateLast((last) => ({ ...last, parts: [...last.parts, { type: "booking", data: event.data }] }))
} else if (event.type === "text") {
reply += event.text
updateLast((last) => {
const parts = [...last.parts]
const tail = parts[parts.length - 1]
if (tail?.type === "text") parts[parts.length - 1] = { type: "text", text: tail.text + event.text }
else parts.push({ type: "text", text: event.text })
return { ...last, parts }
})
}
})
busy.current = false
setPending(false)
onReply?.(reply.trim())
} catch (cause) {
const last = history.current[history.current.length - 1]
if (last?.role === "assistant" && last.parts.length === 0) commit(history.current.slice(0, -1))
busy.current = false
setPending(false)
setError(cause instanceof Error ? cause.message : FALLBACK_ERROR)
onError?.()
}
}
function reset() {
commit([])
setError(null)
}
return { messages, pending, error, send, reset }
}

View file

@ -0,0 +1,190 @@
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
export const beauty = defineTemplate({
key: "beauty",
name: "Maison",
industry: "Friseur, Beauty & Fitness",
description: "Editorial und verspielt: Bild-Collage mit Parallax, Preisliste mit mitlaufenden Bildern, Team, Bewertungen und Buchung.",
cover: unsplash("1522337360788-8b13dee7a37e", 1200),
samples: {
inquiry: "Hi, ich heirate im Juni und suche Brautstyling (Haare und Make-up) inklusive Probetermin – dazu Make-up für meine Mutter und zwei Brautjungfern. Was kostet das? Liebe Grüße, Julia",
topic: "Sommer-Special: 20 % auf Balayage im Juli",
},
palettes: [
{ key: "blush", label: "Blush", bg: "#f5ede6", fg: "#261c1a", muted: "#7d6b65", surface: "#ecdfd5", accent: "#c8664f" },
{ key: "noir", label: "Noir", dark: true, bg: "#0e0d0d", fg: "#f1ebe4", muted: "#8e857c", surface: "#1a1817", accent: "#e3c39d" },
{ key: "salbei", label: "Salbei", bg: "#eef0ea", fg: "#1f2a24", muted: "#66736b", surface: "#dfe4da", accent: "#5f7f6a" },
],
fonts: [
{ key: "editorial", label: "Bodoni Moda & Jost" },
{ key: "expressiv", label: "Syne & DM Sans" },
],
sections: [
{
key: "brand",
label: "Marke",
fields: [
{ key: "name", label: "Name" },
{ key: "tagline", label: "Untertitel" },
{ key: "instagram", label: "Instagram" },
],
},
{
key: "hero",
label: "Startbereich",
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "cta", label: "Button" },
{ key: "image", label: "Bild Mitte", type: "image" },
{ key: "imageLeft", label: "Bild links", type: "image" },
{ key: "imageRight", label: "Bild rechts", type: "image" },
],
},
{
key: "intro",
label: "Leitsatz",
optional: true,
fields: [{ key: "text", label: "Text (leuchtet beim Scrollen auf)", type: "textarea" }],
},
{
key: "services",
label: "Leistungen & Preise",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "items",
label: "Leistungen",
type: "list",
itemLabel: "Leistung",
fields: [
{ key: "name", label: "Name" },
{ key: "text", label: "Beschreibung" },
{ key: "duration", label: "Dauer" },
{ key: "price", label: "Preis" },
{ key: "image", label: "Bild (erscheint beim Darüberfahren)", type: "image" },
],
},
],
},
{
key: "gallery",
label: "Bildband",
optional: true,
fields: [{ key: "images", label: "Bilder", type: "list", itemLabel: "Bild", fields: [{ key: "src", label: "Bild", type: "image" }] }],
},
{
key: "team",
label: "Team",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "members",
label: "Personen",
type: "list",
itemLabel: "Person",
fields: [
{ key: "name", label: "Name" },
{ key: "role", label: "Rolle" },
{ key: "image", label: "Foto", type: "image" },
],
},
],
},
{
key: "reviews",
label: "Bewertungen",
optional: true,
fields: [
{
key: "items",
label: "Bewertungen",
type: "list",
itemLabel: "Bewertung",
fields: [
{ key: "text", label: "Text", type: "textarea" },
{ key: "author", label: "Von" },
],
},
],
},
{
key: "booking",
label: "Buchung & Kontakt",
fields: [
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "address", label: "Adresse" },
{ key: "phone", label: "Telefon" },
{ key: "hours", label: "Öffnungszeiten" },
],
},
],
defaults: {
brand: { name: "Maison Nove", tagline: "Hair & Beauty Studio", instagram: "@maisonnove" },
hero: {
eyebrow: "Friseur · Kosmetik · Make-up",
title: "Schönheit,\n*neu* gedacht.",
text: "Individuelle Schnitte, natürliche Farben und Treatments, die sich anfühlen wie eine kleine Auszeit vom Alltag.",
cta: "Termin buchen",
image: unsplash("1522337360788-8b13dee7a37e", 1400),
imageLeft: unsplash("1562322140-8baeececf3df", 900),
imageRight: unsplash("1487412947147-5cebf100ffc2", 900),
},
intro: {
text: "Wir glauben, dass gutes Aussehen mit einem guten Gefühl beginnt. Deshalb nehmen wir uns Zeit für jede Beratung, arbeiten mit hochwertigen, nachhaltigen Produkten und schaffen einen Ort, an dem Sie einfach ankommen dürfen.",
},
services: {
eyebrow: "Leistungen & Preise",
title: "Für jeden *Look.*",
items: [
{ name: "Schnitt & Styling", text: "Beratung, Waschen, Schnitt und Finish", duration: "60 Min", price: "ab 65 €", image: unsplash("1560066984-138dadb4c035", 600) },
{ name: "Coloration", text: "Ansatz, Glossing oder komplett neue Farbe", duration: "90 Min", price: "ab 89 €", image: unsplash("1580618672591-eb180b1a973f", 600) },
{ name: "Balayage", text: "Handgemalte Strähnen für natürliche Reflexe", duration: "180 Min", price: "ab 189 €", image: unsplash("1522337360788-8b13dee7a37e", 600) },
{ name: "Gesichtsbehandlung", text: "Tiefenreinigung, Maske und Massage", duration: "75 Min", price: "ab 95 €", image: unsplash("1570172619644-dfd03ed5d881", 600) },
{ name: "Make-up", text: "Für Hochzeiten, Shootings und besondere Anlässe", duration: "60 Min", price: "ab 79 €", image: unsplash("1487412947147-5cebf100ffc2", 600) },
],
},
gallery: {
images: [
{ src: unsplash("1521590832167-7bcbfaa6381f", 900) },
{ src: unsplash("1562322140-8baeececf3df", 900) },
{ src: unsplash("1534528741775-53994a69daeb", 900) },
{ src: unsplash("1560066984-138dadb4c035", 900) },
{ src: unsplash("1570172619644-dfd03ed5d881", 900) },
{ src: unsplash("1580618672591-eb180b1a973f", 900) },
],
},
team: {
eyebrow: "Team",
title: "Die Menschen\nhinter *Nove.*",
members: [
{ name: "Sophie", role: "Gründerin & Creative Director", image: unsplash("1595476108010-b4d1f102b1b1", 900) },
{ name: "Lea", role: "Coloristin", image: unsplash("1580489944761-15a19d654956", 900) },
{ name: "Mara", role: "Kosmetikerin", image: unsplash("1494790108377-be9c29b29330", 900) },
{ name: "Jonas", role: "Stylist", image: unsplash("1500648767791-00dcc994a43e", 900) },
],
},
reviews: {
items: [
{ text: "Ich war noch nie so glücklich mit meiner Haarfarbe. Lea hat genau verstanden, was ich wollte – und es noch besser gemacht.", author: "Anna K." },
{ text: "Ein Salon, in dem man sich sofort wohlfühlt. Die Beratung ist ehrlich und das Ergebnis jedes Mal perfekt.", author: "Miriam S." },
{ text: "Die Gesichtsbehandlung war pure Entspannung. Den nächsten Termin habe ich direkt vor Ort gebucht.", author: "Laura B." },
],
},
booking: {
title: "Bereit für Ihren\n*neuen* Look?",
text: "Wählen Sie Ihre Wunschleistung und einen Termin – wir melden uns zur Bestätigung.",
address: "Lindenstraße 21, 40233 Düsseldorf",
phone: "0211 123 45 67",
hours: "Di – Fr 10 – 19 Uhr · Sa 9 – 15 Uhr",
},
},
})
export type BeautyData = typeof beauty.defaults

View file

@ -0,0 +1,21 @@
import { Bodoni_Moda, DM_Sans, Jost, Syne } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const bodoni = Bodoni_Moda({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-bodoni", preload: false })
const jost = Jost({ subsets: ["latin"], variable: "--font-jost", preload: false })
const syne = Syne({ subsets: ["latin"], variable: "--font-syne", preload: false })
const dmSans = DM_Sans({ subsets: ["latin"], variable: "--font-dm-sans", preload: false })
export const fonts: Record<string, FontPair> = {
editorial: {
className: `${bodoni.variable} ${jost.variable}`,
display: "var(--font-bodoni)",
body: "var(--font-jost)",
},
expressiv: {
className: `${syne.variable} ${dmSans.variable}`,
display: "var(--font-syne)",
body: "var(--font-dm-sans)",
},
}

View file

@ -0,0 +1,418 @@
"use client"
import { AnimatePresence, motion, useMotionValue, useScroll, useSpring, useTransform } from "motion/react"
import { useEffect, useId, useRef, useState, type InputHTMLAttributes } from "react"
import { ArrowUpRightIcon, StarIcon } 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 { Marquee, Reveal, RevealWords, ScrollWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { beauty, type BeautyData } from "./definition"
import { fonts } from "./fonts"
const em = "italic text-t-accent"
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
const eyebrow = "mb-6 text-xs uppercase tracking-[0.3em] text-t-muted"
const pill = "inline-flex items-center gap-2 rounded-full bg-t-fg px-6 py-3.5 text-sm text-t-bg transition-transform duration-300 hover:scale-[1.04]"
export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
const { brand, hero, intro, services, gallery, team, reviews, booking } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#leistungen", label: "Leistungen", visible: show("services") },
{ href: "#team", label: "Team", visible: show("team") },
{ href: "#stimmen", label: "Stimmen", visible: show("reviews") },
].filter((link) => link.visible)
return (
<TemplateShell def={beauty} content={content} fonts={fonts[content.fonts] ?? fonts.editorial}>
<Nav name={brand.name} cta={hero.cta} links={links} />
<Hero hero={hero} />
{show("intro") && (
<section 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]"
/>
</section>
)}
{show("services") && <Services services={services} />}
{show("gallery") && (
<section aria-label="Bildband" className="py-10 md:py-16">
<Marquee duration={55}>
{gallery.images.map((image, i) => (
<div
key={i}
className={cn(
"relative mx-2 h-[40vh] w-[29vh] shrink-0 overflow-hidden md:mx-3 md:h-[54vh] md:w-[39vh]",
i % 2 === 0 ? "rounded-t-full" : "rounded-[28px]"
)}
>
<TImage src={image.src} sizes="40vh" />
</div>
))}
</Marquee>
</section>
)}
{show("team") && (
<section 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">
{team.members.map((member, i) => (
<Reveal key={i} delay={i * 0.08} className={cn(i % 2 === 1 && "md:mt-20")}>
<div className="group">
<div className="relative aspect-[3/4] overflow-hidden rounded-t-full bg-t-surface">
<TImage
src={member.image}
alt={member.name}
sizes="(min-width: 768px) 25vw, 50vw"
className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0"
/>
</div>
<p className="mt-5 font-t-display text-2xl md:text-3xl">{member.name}</p>
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
</div>
</Reveal>
))}
</div>
</section>
)}
{show("reviews") && <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">
<div aria-hidden className="absolute -top-48 -right-48 size-[36rem] rounded-full border border-current opacity-20" />
<div aria-hidden className="absolute -top-28 -right-28 size-[36rem] rounded-full border border-current opacity-15" />
<div className="relative grid gap-14 md:grid-cols-2 md:gap-20">
<div>
<RevealWords text={booking.title} emClassName="italic" className="font-t-display text-[clamp(2.75rem,6vw,5.5rem)] leading-[0.95] tracking-[-0.025em]" />
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed opacity-80">{booking.text}</p>
<dl className="mt-12 grid gap-6 text-sm">
{[
["Adresse", booking.address],
["Telefon", booking.phone],
["Öffnungszeiten", booking.hours],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs uppercase tracking-[0.25em] opacity-60">{label}</dt>
<dd className="mt-1 text-base">{value}</dd>
</div>
))}
</dl>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="rounded-[28px] bg-t-bg p-6 text-t-fg md:p-10"
success={
<div className="py-10 text-center">
<p className="font-t-display text-5xl italic text-t-accent">Wir freuen uns!</p>
<p className="mt-4 text-t-muted">Wir bestätigen Ihren Termin in Kürze per SMS oder Anruf.</p>
</div>
}
>
<p className="font-t-display text-3xl">Termin anfragen</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<Soft label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
<Soft label="Telefon" name="phone" type="tel" autoComplete="tel" required />
<Soft label="Wunschtermin" name="date" type="date" />
<label className="block sm:col-span-2">
<span className="px-4 text-xs uppercase tracking-[0.2em] text-t-muted">Leistung</span>
<select name="service" className="mt-1.5 w-full rounded-full border border-t-fg/15 bg-t-surface px-5 py-3.5 outline-none focus:border-t-accent">
{services.items.map((item, i) => (
<option key={i}>{item.name}</option>
))}
</select>
</label>
</div>
<button type="submit" className={cn(pill, "mt-8 w-full cursor-pointer justify-center py-4")}>
Termin anfragen <ArrowUpRightIcon className="size-4" />
</button>
</DemoForm>
</Reveal>
</div>
</div>
</section>
<footer className={cn(container, "flex flex-col gap-8 py-14 text-sm md:flex-row md:items-end md:justify-between")}>
<div>
<p className="font-t-display text-4xl tracking-tight">{brand.name}</p>
<p className="mt-1 text-t-muted">{brand.tagline}</p>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2 text-t-muted">
<a href="#" className="hover:text-t-fg">Instagram {brand.instagram}</a>
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
const { scrolled, hidden } = useScrollNav()
return (
<motion.header
animate={{ y: hidden ? "-120%" : "0%" }}
transition={{ duration: 0.45, ease }}
className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-5"
>
<nav
className={cn(
"mx-auto grid h-16 max-w-[1440px] grid-cols-[1fr_auto_1fr] items-center rounded-full px-4 transition-colors duration-500 md:px-6",
scrolled ? "bg-t-bg/80 shadow-[0_8px_30px_-12px_rgba(0,0,0,0.25)] backdrop-blur-xl" : "bg-transparent"
)}
>
<div className="hidden gap-8 text-sm md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-fg">
{link.label}
</a>
))}
</div>
<a href="#top" className="col-start-1 justify-self-start font-t-display text-2xl tracking-tight md:col-start-2 md:justify-self-center md:text-3xl">
{name}
</a>
<a href="#termin" className={cn(pill, "col-start-3 justify-self-end px-5 py-2.5")}>{cta}</a>
</nav>
</motion.header>
)
}
function Hero({ hero }: { hero: BeautyData["hero"] }) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const left = useTransform(scrollYProgress, [0, 1], [0, -160])
const middle = useTransform(scrollYProgress, [0, 1], [0, -60])
const right = useTransform(scrollYProgress, [0, 1], [0, -240])
const reveal = (delay: number) => ({
initial: { opacity: 0, y: 80, scale: 0.94 },
animate: { opacity: 1, y: 0, scale: 1 },
transition: { duration: 1.3, ease, delay },
})
return (
<section ref={ref} id="top" className="relative overflow-hidden pt-32 pb-16 md:pt-40 md:pb-24">
<div className={container}>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }} className={cn(eyebrow, "text-center")}>
{hero.eyebrow}
</motion.p>
<RevealWords
as="h1"
onMount
delay={0.3}
text={hero.title}
emClassName={em}
className="text-center font-t-display text-[clamp(3.6rem,12.5vw,13rem)] leading-[0.86] tracking-[-0.04em]"
/>
<div className="relative mt-14 grid grid-cols-12 items-start gap-3 md:mt-20 md:gap-6">
<motion.div style={{ y: left }} className="col-span-5 mt-20 md:col-span-3 md:mt-32">
<motion.div {...reveal(0.7)} className="relative aspect-[3/4] overflow-hidden rounded-t-full">
<TImage src={hero.imageLeft} sizes="(min-width: 768px) 25vw, 40vw" eager />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 1.2 }}
className="mt-8 hidden md:block"
>
<p className="text-lg leading-relaxed text-t-muted">{hero.text}</p>
</motion.div>
</motion.div>
<motion.div style={{ y: middle }} className="relative col-span-7 md:col-span-5 md:col-start-5">
<motion.div {...reveal(0.5)} className="relative aspect-[4/5] overflow-hidden rounded-[32px] md:rounded-[48px]">
<TImage src={hero.image} sizes="(min-width: 768px) 42vw, 60vw" eager />
</motion.div>
<motion.div
initial={{ scale: 0, rotate: -90 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", bounce: 0.35, duration: 1.2, delay: 1.3 }}
className="absolute -bottom-10 -left-6 md:-left-16"
>
<RotatingBadge text={hero.cta} href="#termin" />
</motion.div>
</motion.div>
<motion.div style={{ y: right }} className="hidden md:col-span-3 md:col-start-10 md:mt-48 md:block">
<motion.div {...reveal(0.9)} className="relative aspect-[3/4] overflow-hidden rounded-[28px]">
<TImage src={hero.imageRight} sizes="25vw" eager />
</motion.div>
</motion.div>
</div>
<p className="mt-24 text-center text-lg leading-relaxed text-t-muted md:hidden">{hero.text}</p>
</div>
</section>
)
}
function RotatingBadge({ text, href }: { text: string; href: string }) {
const pathId = `badge-${useId().replace(/[^a-zA-Z0-9]/g, "")}`
return (
<a href={href} className="group relative grid size-28 place-items-center rounded-full bg-t-accent text-t-accent-fg md:size-36">
<motion.svg
viewBox="0 0 100 100"
aria-hidden
className="absolute inset-0 size-full"
animate={{ rotate: 360 }}
transition={{ duration: 16, ease: "linear", repeat: Infinity }}
>
<defs>
<path id={pathId} d="M50,50 m-36,0 a36,36 0 1,1 72,0 a36,36 0 1,1 -72,0" />
</defs>
<text className="fill-current text-[9.5px] uppercase" style={{ letterSpacing: "0.18em" }}>
<textPath href={`#${pathId}`} textLength="222" lengthAdjust="spacing">
{`${text} ✦ ${text} ✦ `}
</textPath>
</text>
</motion.svg>
<span className="sr-only">{text}</span>
<ArrowUpRightIcon className="size-7 transition-transform duration-500 group-hover:rotate-45" />
</a>
)
}
function Services({ services }: { services: BeautyData["services"] }) {
const areaRef = useRef<HTMLDivElement>(null)
const [active, setActive] = useState<number | null>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const springX = useSpring(x, { stiffness: 260, damping: 28 })
const springY = useSpring(y, { stiffness: 260, damping: 28 })
const activeItem = active === null ? undefined : services.items[active]
return (
<section id="leistungen" className={cn(container, "py-24 md:py-36")}>
<Reveal><p className={eyebrow}>{services.eyebrow}</p></Reveal>
<RevealWords text={services.title} emClassName={em} className="font-t-display text-[clamp(2.75rem,6vw,5.75rem)] leading-[0.95] tracking-[-0.025em]" />
<div
ref={areaRef}
className="relative mt-14 md:mt-20"
onPointerMove={(event) => {
const rect = areaRef.current?.getBoundingClientRect()
if (!rect) return
x.set(event.clientX - rect.left)
y.set(event.clientY - rect.top)
}}
onPointerLeave={() => setActive(null)}
>
<ul className="border-t border-t-fg/15">
{services.items.map((item, i) => (
<motion.li
key={i}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.8, ease, delay: i * 0.06 }}
onPointerEnter={() => setActive(i)}
className="group grid grid-cols-12 items-baseline gap-x-4 gap-y-2 border-b border-t-fg/15 py-7 transition-[padding] duration-500 md:py-9 md:hover:pl-6"
>
<span className="col-span-12 font-t-display text-3xl tracking-tight transition-colors duration-300 group-hover:text-t-accent md:col-span-5 md:text-5xl">
{item.name}
</span>
<span className="col-span-12 text-t-muted md:col-span-4">{item.text}</span>
<span className="col-span-6 text-sm text-t-muted md:col-span-1">{item.duration}</span>
<span className="col-span-6 text-right font-t-display text-2xl md:col-span-2">{item.price}</span>
</motion.li>
))}
</ul>
<motion.div aria-hidden style={{ x: springX, y: springY }} className="pointer-events-none absolute top-0 left-0 z-10 hidden md:block">
<AnimatePresence>
{activeItem && (
<motion.div
key={active}
initial={{ opacity: 0, scale: 0.7, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.7, rotate: 8 }}
transition={{ duration: 0.4, ease }}
className="relative -mt-[160px] -ml-[120px] h-[320px] w-[240px] overflow-hidden rounded-t-full rounded-b-[28px] shadow-2xl"
>
<TImage src={activeItem.image} sizes="240px" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</section>
)
}
function Reviews({ reviews }: { reviews: BeautyData["reviews"] }) {
const [index, setIndex] = useState(0)
const count = reviews.items.length
useEffect(() => {
if (count < 2) return
const timer = setInterval(() => setIndex((i) => (i + 1) % count), 6500)
return () => clearInterval(timer)
}, [count])
if (count === 0) return null
const current = index % count
const review = reviews.items[current]
return (
<section id="stimmen" className="bg-t-surface py-24 md:py-36">
<div className="mx-auto max-w-[1000px] px-5 text-center">
<div className="flex justify-center gap-1.5 text-t-accent">
{Array.from({ length: 5 }, (_, i) => (
<StarIcon key={i} className="size-5 fill-current" />
))}
</div>
<div className="mt-10 grid min-h-[15rem] items-center md:min-h-[13rem]">
<AnimatePresence mode="wait">
<motion.blockquote
key={current}
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -24, filter: "blur(8px)" }}
transition={{ duration: 0.7, ease }}
>
<p className="font-t-display text-[clamp(1.6rem,3.4vw,2.9rem)] leading-[1.2] tracking-[-0.01em]">„{review.text}“</p>
<footer className="mt-8 text-xs uppercase tracking-[0.3em] text-t-muted">{review.author}</footer>
</motion.blockquote>
</AnimatePresence>
</div>
<div className="mt-10 flex justify-center gap-2">
{reviews.items.map((_, i) => (
<button
key={i}
type="button"
aria-label={`Bewertung ${i + 1} anzeigen`}
aria-current={i === current}
onClick={() => setIndex(i)}
className={cn("h-1.5 cursor-pointer rounded-full transition-all duration-500", i === current ? "w-10 bg-t-accent" : "w-1.5 bg-t-fg/25")}
/>
))}
</div>
</div>
</section>
)
}
function Soft({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return (
<label className={cn("block", className)}>
<span className="px-4 text-xs uppercase tracking-[0.2em] text-t-muted">{label}</span>
<input
{...props}
className="mt-1.5 w-full rounded-full border border-t-fg/15 bg-t-surface px-5 py-3.5 outline-none transition-colors focus:border-t-accent"
/>
</label>
)
}

View file

@ -0,0 +1,115 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "Lena Hoffmann", role: "Coach für Führungskräfte & Speakerin", email: "hallo@lena-hoffmann.de" },
hero: {
eyebrow: "Leadership Coaching",
title: "Führen, ohne sich\nselbst zu *verlieren.*",
text: "Ich begleite Führungskräfte dabei, klar zu entscheiden, gesund zu bleiben und Teams zu bauen, die gern Verantwortung übernehmen.",
cta: "Kostenloses Erstgespräch",
ctaSecondary: "Meine Angebote",
image: unsplash("1580894732444-8ecded7900cd", 1400),
quote: "Klarheit ist die freundlichste Form von Führung.",
badge: "300+ Führungskräfte begleitet",
},
press: { title: "Bekannt aus", items: [{ name: "Wirtschaftsforum" }, { name: "Leadership Talk" }, { name: "New Work Weekly" }, { name: "Führung heute" }, { name: "Podcast Kopfsache" }] },
pains: {
eyebrow: "Kennst du das?",
title: "Du gibst alles –\nund *trotzdem* fehlt etwas.",
items: [
{ text: "Dein Kalender gehört allen, nur nicht dir." },
{ text: "Entscheidungen kosten dich nachts den Schlaf." },
{ text: "Dein Team wartet auf dich, statt selbst zu handeln." },
{ text: "Du fragst dich, wie lange du das noch durchhältst." },
],
closing: "Dann lass uns reden.",
},
story: {
eyebrow: "Über mich",
title: "Von der Überholspur\nzur *eigenen* Spur.",
text: "Fünfzehn Jahre war ich Führungskraft in der Beratung – erfolgreich, erschöpft und irgendwann leer. Heute helfe ich Menschen in Verantwortung, ihren eigenen Stil zu finden: klar, menschlich und nachhaltig.",
image: unsplash("1573497019940-1c28c88b4f3e", 1200),
milestones: [
{ year: "2009", text: "Start als Beraterin in einer großen Unternehmensberatung" },
{ year: "2015", text: "Bereichsleitung mit 120 Mitarbeitenden" },
{ year: "2018", text: "Burnout – und der Anfang von etwas Neuem" },
{ year: "2019", text: "Ausbildung zur systemischen Coachin" },
{ year: "2026", text: "Über 300 begleitete Führungskräfte" },
],
signature: "Lena",
},
offers: {
eyebrow: "Angebote",
title: "Wie ich dich *begleite*",
items: [
{ title: "1:1 Coaching", text: "Individuelle Sessions für deine konkreten Führungsfragen – online oder in Hamburg.", price: "ab 290 € pro Session", cta: "Erstgespräch buchen", highlight: "" },
{ title: "Leadership-Programm", text: "Zwölf Wochen, die deinen Führungsalltag verändern: Coaching, Übungen und eine starke Peer-Gruppe.", price: "ab 2.900 €", cta: "Mehr erfahren", highlight: "ja" },
{ title: "Keynotes & Workshops", text: "Impulse für Teams und Konferenzen – ehrlich, nahbar und mit Humor.", price: "auf Anfrage", cta: "Anfragen", highlight: "" },
],
},
results: {
eyebrow: "Stimmen",
title: "Was sich *verändert*",
items: [
{ quote: "Ich führe heute mit halb so vielen Meetings und doppelt so viel Vertrauen. Mein Team blüht auf – und ich auch.", author: "Markus T.", role: "Geschäftsführer, Maschinenbau", image: unsplash("1560250097-0b93528c311a", 400) },
{ quote: "Lena stellt die Fragen, die sonst niemand stellt. Das war unbequem und das Beste, was mir beruflich passiert ist.", author: "Anna S.", role: "Head of Marketing", image: unsplash("1508214751196-bcfd4ca60f91", 400) },
{ quote: "Nach dem Programm habe ich endlich wieder Feierabend – ohne schlechtes Gewissen.", author: "Julia M.", role: "Gründerin & CEO", image: unsplash("1544005313-94ddf0286df2", 400) },
],
},
podcast: {
eyebrow: "Podcast",
title: "Klar führen –\n*der Podcast*",
items: [
{ number: "112", title: "Warum gute Chefs Nein sagen", duration: "34 Min" },
{ number: "111", title: "Feedback, das wirklich ankommt", duration: "28 Min" },
{ number: "110", title: "Der Mythos Work-Life-Balance", duration: "41 Min" },
],
},
leadmagnet: {
title: "Gratis: Der *Klarheits*-Kompass",
text: "Mein Workbook mit sieben Übungen für mehr Fokus im Führungsalltag – direkt in dein Postfach.",
book: "Der Klarheits-Kompass",
cta: "Workbook sichern",
},
contact: {
title: "Lass uns *sprechen.*",
text: "30 Minuten, kostenlos und unverbindlich. Wir schauen gemeinsam, wo du stehst und was dich weiterbringt.",
cta: "Erstgespräch buchen",
},
}
export const coach = defineTemplate({
key: "coach",
name: "Persona",
industry: "Personal Brand & Coach",
description: "Persönlich und editorial: Porträt in lebendiger Form, handgezeichnete Hervorhebungen, Story als Zeitleiste, Angebote, Podcast und Lead-Magnet mit 3D-Buch.",
cover: unsplash("1580894732444-8ecded7900cd", 1200),
samples: {
inquiry: "Hallo Frau Hoffmann, ich leite seit einem halben Jahr ein Team von 15 Personen und merke, dass ich mich völlig verausgabe. Wie läuft ein Coaching bei Ihnen ab, und was kostet es? Viele Grüße, Stefan Roth",
topic: "Drei Fragen, die sich jede Führungskraft montags stellen sollte",
},
palettes: [
{ key: "creme", label: "Creme", bg: "#f8f3ec", fg: "#2b2420", muted: "#776a60", surface: "#efe5d8", accent: "#d1603d" },
{ key: "salbei", label: "Salbei", bg: "#eef1ea", fg: "#202a22", muted: "#647066", surface: "#dfe5d9", accent: "#4f7a5a" },
{ key: "nacht", label: "Nacht", dark: true, bg: "#121014", fg: "#f3eee8", muted: "#9c948c", surface: "#1d1a1f", accent: "#e8b86d" },
],
fonts: [
{ key: "editorial", label: "Gloock & Hanken Grotesk" },
{ key: "warm", label: "Young Serif & Familjen Grotesk" },
],
sections: describeSections(defaults, {
brand: "Person",
hero: "Startbereich",
press: { label: "Bekannt aus", optional: true, items: { items: "Medium" } },
pains: { label: "Kennst du das?", optional: true, items: { items: "Punkt" } },
story: { label: "Über mich", optional: true, items: { milestones: "Meilenstein" } },
offers: { label: "Angebote", optional: true, items: { items: "Angebot" } },
results: { label: "Stimmen", optional: true, items: { items: "Stimme" } },
podcast: { label: "Podcast", optional: true, items: { items: "Folge" } },
leadmagnet: { label: "Lead-Magnet", optional: true },
contact: "Kontakt",
}),
defaults,
})
export type CoachData = typeof coach.defaults

View file

@ -0,0 +1,16 @@
import { Caveat, Familjen_Grotesk, Gloock, Hanken_Grotesk, Young_Serif } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts. Caveat nur für die handschriftliche Unterschrift.
const gloock = Gloock({ subsets: ["latin"], weight: "400", variable: "--font-coach-gloock", preload: false })
const hanken = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-coach-hanken", preload: false })
const youngSerif = Young_Serif({ subsets: ["latin"], weight: "400", variable: "--font-coach-young", preload: false })
const familjen = Familjen_Grotesk({ subsets: ["latin"], variable: "--font-coach-familjen", preload: false })
const caveat = Caveat({ subsets: ["latin"], variable: "--font-coach-caveat", preload: false })
export const fonts: Record<string, FontPair> = {
editorial: { className: `${gloock.variable} ${hanken.variable} ${caveat.variable}`, display: "var(--font-coach-gloock)", body: "var(--font-coach-hanken)" },
warm: { className: `${youngSerif.variable} ${familjen.variable} ${caveat.variable}`, display: "var(--font-coach-young)", body: "var(--font-coach-familjen)" },
}
export const script = { fontFamily: "var(--font-coach-caveat), cursive" }

View file

@ -0,0 +1,412 @@
"use client"
import { motion, useScroll } from "motion/react"
import { Fragment, useRef, useState } from "react"
import { ArrowDownRightIcon, ArrowRightIcon, CheckIcon, PauseIcon, PlayIcon, QuoteIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { TField } from "../shared/fields"
import { TImage } from "../shared/media"
import { Reveal, RevealWords, ease, parseRich, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { coach, type CoachData } from "./definition"
import { fonts, script } from "./fonts"
const em = "italic text-t-accent"
const container = "mx-auto w-full max-w-[1280px] px-5 md:px-8"
const heading = "font-t-display font-normal tracking-[-0.02em]"
function Eyebrow({ children }: { children: string }) {
return <p className="mb-5 text-sm font-semibold uppercase tracking-[0.2em] text-t-accent">{children}</p>
}
// Wörter gleiten herein, hervorgehobene Wörter bekommen eine handgezeichnete Linie
function MarkedTitle({ text }: { text: string }) {
const lines: { word: string; em: boolean; index: number }[][] = []
let count = 0
for (const tokens of parseRich(text)) {
const line = []
for (const token of tokens) {
line.push({ ...token, index: count })
count += 1
}
lines.push(line)
}
return (
<h1 className={cn(heading, "text-[clamp(2.5rem,4.8vw,4.25rem)] leading-[1.05]")} aria-label={text.replace(/\*/g, "").replace(/\n/g, " ")}>
{lines.map((tokens, l) => (
<span key={l} aria-hidden className="block">
{tokens.map((token, t) => (
<Fragment key={t}>
{t > 0 && " "}
<span className="relative inline-block">
<span className="mb-[-0.12em] inline-block overflow-hidden pb-[0.12em] align-top">
<motion.span
className={cn("inline-block", token.em && em)}
initial={{ y: "105%" }}
animate={{ y: "0%" }}
transition={{ duration: 1, ease, delay: 0.2 + token.index * 0.06 }}
>
{token.word}
</motion.span>
</span>
{token.em && (
<svg viewBox="0 0 200 20" preserveAspectRatio="none" aria-hidden className="absolute -bottom-[0.1em] left-0 h-[0.3em] w-full overflow-visible text-t-accent">
<motion.path
d="M3 14 C 40 5, 90 4, 120 10 S 175 17, 197 7"
fill="none"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.9, ease, delay: 1.1 + token.index * 0.05 }}
/>
</svg>
)}
</span>
</Fragment>
))}
</span>
))}
</h1>
)
}
export function CoachTemplate({ content }: TemplateProps<CoachData>) {
const { brand, hero, press, pains, story, offers, results, podcast, leadmagnet, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#ueber-mich", label: "Über mich", visible: show("story") },
{ href: "#angebote", label: "Angebote", visible: show("offers") },
{ href: "#podcast", label: "Podcast", visible: show("podcast") },
].filter((link) => link.visible)
return (
<TemplateShell def={coach} content={content} fonts={fonts[content.fonts] ?? fonts.editorial}>
<Nav name={brand.name} cta={contact.cta} links={links} />
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-16 md:grid-cols-[1.1fr_1fr] md:pt-40 md:pb-24")}>
<div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<MarkedTitle text={hero.title} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.7 }}>
<p className="mt-8 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-9 flex flex-wrap gap-3">
<a href="#kontakt" className="inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 font-semibold text-t-accent-fg transition-transform hover:-translate-y-0.5">
{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>
)}
</div>
</motion.div>
</div>
<div className="relative mx-auto w-full max-w-[480px]">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{
opacity: 1,
scale: 1,
borderRadius: ["58% 42% 38% 62% / 52% 38% 62% 48%", "42% 58% 62% 38% / 38% 56% 44% 62%", "58% 42% 38% 62% / 52% 38% 62% 48%"],
}}
transition={{ opacity: { duration: 1 }, scale: { duration: 1.2, ease }, borderRadius: { duration: 14, repeat: Infinity, ease: "easeInOut" } }}
className="relative aspect-[4/5] overflow-hidden bg-t-surface"
>
<TImage src={hero.image} eager sizes="(min-width: 768px) 40vw, 90vw" />
</motion.div>
<motion.figure
initial={{ opacity: 0, y: 20, rotate: -6 }}
animate={{ opacity: 1, y: 0, rotate: -3 }}
transition={{ duration: 0.9, ease, delay: 1 }}
className="absolute bottom-8 -left-4 max-w-60 rounded-2xl bg-t-bg p-5 shadow-xl md:-left-12"
>
<QuoteIcon className="size-5 text-t-accent" />
<blockquote className={cn(heading, "mt-2 text-lg leading-snug")}>{hero.quote}</blockquote>
</motion.figure>
<motion.span
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", bounce: 0.4, delay: 1.3 }}
className="absolute top-10 -right-2 rounded-full bg-t-accent px-4 py-2 text-sm font-semibold text-t-accent-fg shadow-lg md:-right-6"
>
{hero.badge}
</motion.span>
</div>
</section>
{show("press") && (
<section 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">
{press.items.map((item, i) => (
<span key={i} className={cn(heading, "text-xl text-t-fg/55 italic")}>{item.name}</span>
))}
</div>
</div>
</section>
)}
{show("pains") && (
<section 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]")} />
</div>
<div>
<ul className="space-y-4">
{pains.items.map((item, i) => (
<Reveal key={i} delay={i * 0.12}>
<li className="flex items-start gap-4 rounded-2xl bg-t-surface p-5 text-lg">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-t-bg text-t-accent">
<XIcon className="size-4" />
</span>
{item.text}
</li>
</Reveal>
))}
</ul>
<Reveal delay={0.5}>
<a href="#kontakt" className="mt-8 inline-flex items-center gap-3 text-4xl text-t-accent" style={script}>
{pains.closing} <ArrowDownRightIcon className="size-7" />
</a>
</Reveal>
</div>
</section>
)}
{show("story") && <Story story={story} />}
{show("offers") && (
<section 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">
{offers.items.map((offer, i) => {
const featured = Boolean(offer.highlight.trim())
return (
<Reveal key={i} delay={i * 0.1}>
<article className={cn("flex h-full flex-col rounded-[2rem] p-8 transition-transform duration-500 hover:-translate-y-1.5", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
<span className={cn(heading, "text-6xl opacity-30")}>0{i + 1}</span>
<h3 className={cn(heading, "mt-4 text-3xl")}>{offer.title}</h3>
<p className={cn("mt-3 flex-1 leading-relaxed", featured ? "opacity-85" : "text-t-muted")}>{offer.text}</p>
<p className="mt-8 font-semibold">{offer.price}</p>
<a href="#kontakt" className={cn("mt-5 inline-flex items-center justify-between rounded-full px-5 py-3.5 font-semibold", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
{offer.cta} <ArrowRightIcon className="size-4" />
</a>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("results") && (
<section 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]")} />
<div className="mt-12 grid gap-5 md:grid-cols-3">
{results.items.map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<figure className="flex h-full flex-col rounded-[2rem] bg-t-bg p-7">
<blockquote className="flex-1 text-lg leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<span className="relative size-12 overflow-hidden rounded-full bg-t-surface">
<TImage src={item.image} alt={item.author} sizes="48px" />
</span>
<span className="text-sm">
<span className="block font-semibold">{item.author}</span>
<span className="text-t-muted">{item.role}</span>
</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
)}
{show("podcast") && <Podcast podcast={podcast} />}
{show("leadmagnet") && (
<section 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
initial={{ rotateY: -40, opacity: 0 }}
whileInView={{ rotateY: -22, opacity: 1 }}
whileHover={{ rotateY: -8 }}
viewport={{ once: true }}
transition={{ duration: 1, ease }}
className="relative mx-auto h-80 w-56 [transform-style:preserve-3d]"
>
<div className="absolute inset-0 rounded-r-xl bg-[repeating-linear-gradient(90deg,#f5f5f4,#f5f5f4_2px,#d6d3d1_3px)] [transform:rotateY(180deg)_translateZ(14px)]" />
<div className="absolute inset-y-0 left-0 w-7 bg-t-accent brightness-75 [transform:rotateY(-90deg)_translateZ(14px)]" />
<div className="absolute inset-y-1 right-0 w-7 bg-[repeating-linear-gradient(90deg,#fafaf9,#fafaf9_2px,#e7e5e4_3px)] [transform:rotateY(90deg)_translateZ(14px)]" />
<div className="absolute inset-0 flex flex-col rounded-r-xl bg-t-accent p-6 text-t-accent-fg shadow-2xl [transform:translateZ(14px)]">
<p className="text-xs uppercase tracking-widest opacity-70">Workbook</p>
<p className={cn(heading, "mt-4 text-3xl leading-tight")}>{leadmagnet.book}</p>
<p className="mt-auto text-2xl" style={script}>{brand.name}</p>
</div>
</motion.div>
</div>
<div>
<RevealWords text={leadmagnet.title} emClassName={em} className={cn(heading, "text-[clamp(2rem,4vw,3.25rem)] leading-[1.08]")} />
<p className="mt-4 text-lg opacity-75">{leadmagnet.text}</p>
<DemoForm
className="mt-8 grid gap-3 sm:grid-cols-[1fr_1fr_auto]"
success={
<p className="mt-8 flex items-center gap-3 text-lg">
<CheckIcon className="size-6 text-t-accent" /> Ist unterwegs – schau in dein Postfach!
</p>
}
>
<TField label="Vorname" name="name" required autoComplete="given-name" variant="glass" />
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="glass" />
<button type="submit" className="cursor-pointer self-end rounded-xl bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">{leadmagnet.cta}</button>
</DemoForm>
</div>
</div>
</section>
)}
<section id="kontakt" className="mx-auto max-w-[900px] px-5 pb-24 text-center md:pb-32">
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(3rem,8vw,6.5rem)] leading-none")} />
<Reveal delay={0.1}>
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-t-muted">{contact.text}</p>
<a href={`mailto:${brand.email}`} className="mt-9 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-semibold text-t-accent-fg transition-transform hover:-translate-y-0.5">
{contact.cta} <ArrowRightIcon className="size-4" />
</a>
<p className="mt-5 text-sm text-t-muted">oder schreib mir: {brand.email}</p>
</Reveal>
</section>
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 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.role}</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({ name, cta, links }: { name: string; 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/85 backdrop-blur-xl")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
<a href="#top" className={cn(heading, "text-2xl")}>{name}</a>
<div className="hidden items-center gap-8 text-sm font-medium md: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="#kontakt" className="rounded-full bg-t-fg px-5 py-2.5 text-sm font-semibold text-t-bg">{cta}</a>
</nav>
</motion.header>
)
}
function Story({ story }: { story: CoachData["story"] }) {
const ref = useRef<HTMLOListElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.8", "end 0.6"] })
return (
<section id="ueber-mich" className={cn(container, "grid gap-14 py-24 md:grid-cols-2 md:py-32")}>
<div>
<Reveal>
<div className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-t-surface">
<TImage src={story.image} sizes="(min-width: 768px) 45vw, 100vw" />
</div>
</Reveal>
</div>
<div>
<Reveal><Eyebrow>{story.eyebrow}</Eyebrow></Reveal>
<RevealWords text={story.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{story.text}</p></Reveal>
<ol ref={ref} className="relative mt-10 space-y-7 pl-8">
<span className="absolute top-2 bottom-2 left-[5px] w-0.5 bg-t-fg/10" />
<motion.span style={{ scaleY: scrollYProgress }} className="absolute top-2 bottom-2 left-[5px] w-0.5 origin-top bg-t-accent" />
{story.milestones.map((milestone, i) => (
<Reveal key={i} delay={i * 0.06}>
<li className="relative">
<span className="absolute top-1.5 -left-8 size-3 rounded-full bg-t-accent ring-4 ring-t-bg" />
<p className="text-sm font-semibold text-t-accent">{milestone.year}</p>
<p className="mt-0.5">{milestone.text}</p>
</li>
</Reveal>
))}
</ol>
<Reveal delay={0.2}>
<p className="mt-10 text-5xl text-t-accent" style={script}>{story.signature}</p>
</Reveal>
</div>
</section>
)
}
function Podcast({ podcast }: { podcast: CoachData["podcast"] }) {
const [playing, setPlaying] = useState<number | null>(null)
return (
<section id="podcast" className={cn(container, "grid gap-12 py-24 md:grid-cols-[0.8fr_1.2fr] md:py-32")}>
<div>
<Reveal><Eyebrow>{podcast.eyebrow}</Eyebrow></Reveal>
<RevealWords text={podcast.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
</div>
<ul className="space-y-3">
{podcast.items.map((episode, i) => {
const active = playing === i
return (
<Reveal key={i} delay={i * 0.08}>
<li className={cn("rounded-2xl p-4 transition-colors md:p-5", active ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
<div className="flex items-center gap-4">
<button
type="button"
aria-label={active ? "Pause" : `Folge ${episode.number} abspielen`}
onClick={() => setPlaying(active ? null : i)}
className={cn("grid size-12 shrink-0 cursor-pointer place-items-center rounded-full", active ? "bg-t-bg text-t-fg" : "bg-t-accent text-t-accent-fg")}
>
{active ? <PauseIcon className="size-5 fill-current" /> : <PlayIcon className="ml-0.5 size-5 fill-current" />}
</button>
<div className="min-w-0 flex-1">
<p className={cn("text-xs font-semibold", active ? "opacity-80" : "text-t-muted")}>Folge {episode.number} · {episode.duration}</p>
<p className={cn(heading, "truncate text-xl")}>{episode.title}</p>
</div>
{active && (
<span className="hidden h-8 items-end gap-0.5 sm:flex" aria-hidden>
{[0, 1, 2, 3, 4, 5].map((bar) => (
<motion.span key={bar} className="w-1 rounded-full bg-current" animate={{ height: [6, 24, 10, 28, 8] }} transition={{ duration: 1, repeat: Infinity, delay: bar * 0.1 }} />
))}
</span>
)}
</div>
{active && (
<div className="mt-4 h-1 overflow-hidden rounded-full bg-current/20">
<motion.div className="h-full bg-current" initial={{ width: "0%" }} animate={{ width: "100%" }} transition={{ duration: 40, ease: "linear" }} />
</div>
)}
</li>
</Reveal>
)
})}
</ul>
</section>
)
}

View file

@ -0,0 +1,103 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "FUTURE NOW", suffix: "Konferenz für digitale Zukunft" },
hero: {
eyebrow: "Ausgabe 2026",
title: "FUTURE\n*NOW*",
date: "2026-11-19T09:00",
dateLabel: "19.–20. November 2026",
location: "Station Berlin",
text: "Zwei Tage, 40 Speaker, 1.500 Macher:innen. Die Konferenz für alle, die Zukunft nicht abwarten, sondern bauen.",
cta: "Tickets sichern",
image: unsplash("1540575467063-178a50c2df87"),
},
marquee: { items: [{ text: "Künstliche Intelligenz" }, { text: "Design" }, { text: "Nachhaltigkeit" }, { text: "Leadership" }, { text: "Commerce" }, { text: "Creator Economy" }] },
speakers: {
eyebrow: "Speaker",
title: "Die Stimmen der *Zukunft*",
items: [
{ name: "Dr. Mira Okafor", role: "KI-Forscherin", company: "Institute for Applied AI", image: unsplash("1531123897727-8f129e1688ce", 900) },
{ name: "Jonas Berg", role: "Gründer", company: "Northlane", image: unsplash("1552058544-f2b08422138a", 900) },
{ name: "Clara Weiss", role: "Chief Design Officer", company: "Studio Neon", image: unsplash("1524504388940-b1c1722653e1", 900) },
{ name: "Elif Kaya", role: "Klimaökonomin", company: "Future Lab", image: unsplash("1488426862026-3ee34a7d66df", 900) },
{ name: "Ben Hartmann", role: "CEO", company: "Voltworks", image: unsplash("1472099645785-5658abf4ff4e", 900) },
{ name: "Sofia Lindqvist", role: "Autorin", company: "„Morgen machen“", image: unsplash("1487412720507-e7ab37603c6f", 900) },
],
},
agenda: {
eyebrow: "Programm",
title: "Zwei Tage *volle* Energie",
items: [
{ day: "Tag 1", time: "09:00", title: "Einlass & Frühstück", speaker: "", room: "Foyer" },
{ day: "Tag 1", time: "10:00", title: "Keynote: Die nächsten zehn Jahre KI", speaker: "Dr. Mira Okafor", room: "Main Stage" },
{ day: "Tag 1", time: "11:15", title: "Design, das Menschen bewegt", speaker: "Clara Weiss", room: "Stage 2" },
{ day: "Tag 1", time: "14:30", title: "Panel: Wachstum ohne Burnout", speaker: "Jonas Berg, Sofia Lindqvist", room: "Main Stage" },
{ day: "Tag 1", time: "19:00", title: "After-Party", speaker: "", room: "Halle 7" },
{ day: "Tag 2", time: "09:30", title: "Climate Tech, die sich rechnet", speaker: "Elif Kaya", room: "Main Stage" },
{ day: "Tag 2", time: "11:00", title: "Workshops nach Wahl", speaker: "", room: "Räume A–D" },
{ day: "Tag 2", time: "14:00", title: "Founder Stories", speaker: "Ben Hartmann", room: "Stage 2" },
{ day: "Tag 2", time: "16:30", title: "Closing Keynote", speaker: "Überraschungsgast", room: "Main Stage" },
],
},
tickets: {
eyebrow: "Tickets",
title: "Sei *dabei.*",
items: [
{ name: "Early Bird", price: "249", text: "Beide Tage, alle Talks", features: "Zugang zu allen Bühnen\nFrühstück, Lunch & Kaffee\nAfter-Party", left: "23", highlight: "" },
{ name: "Regular", price: "349", text: "Das volle Programm", features: "Zugang zu allen Bühnen\nWorkshops nach Wahl\nCatering & After-Party", left: "", highlight: "ja" },
{ name: "VIP", price: "799", text: "Für das besondere Erlebnis", features: "Reservierte Plätze vorne\nSpeaker-Dinner\nLounge mit Getränken", left: "8", highlight: "" },
],
},
venue: {
eyebrow: "Location",
title: "Station *Berlin*",
text: "Historische Hallen am Gleisdreieck – fünf Minuten vom Potsdamer Platz, bestens mit Bahn und Rad erreichbar.",
address: "Luckenwalder Straße 4–6, 10963 Berlin",
image: unsplash("1523580494863-6f3031224c94", 1600),
},
partners: { title: "Partner", items: [{ name: "Voltworks" }, { name: "Northlane" }, { name: "Studio Neon" }, { name: "Future Lab" }, { name: "Brightfield" }] },
faq: {
items: [
{ question: "Gibt es Gruppenrabatte?", answer: "Ab fünf Tickets erhaltet ihr 15 % Rabatt. Schreibt uns einfach eine kurze Nachricht." },
{ question: "Werden die Talks aufgezeichnet?", answer: "Ja, alle Keynotes gibt es für Ticketinhaber:innen zwei Wochen nach dem Event im Stream." },
{ question: "Kann ich mein Ticket übertragen?", answer: "Klar – bis zu 48 Stunden vor Beginn kostenlos über euer Ticketkonto." },
{ question: "Ist die Location barrierefrei?", answer: "Alle Hallen sind ebenerdig oder per Aufzug erreichbar. Sagt uns Bescheid, wenn ihr Unterstützung braucht." },
],
},
}
export const event = defineTemplate({
key: "event",
name: "Summit",
industry: "Event & Launch",
description: "Kinetisch und laut: Titel aus einklappenden Buchstaben, Countdown mit rollenden Ziffern, Speaker, Programm mit Zeitleiste, Tickets mit Restkontingent und Location.",
cover: unsplash("1587825140708-dfaf72ae4b04", 1200),
samples: {
inquiry: "Hallo, wir würden gern mit zwölf Personen aus unserem Unternehmen zur Konferenz kommen. Gibt es Gruppenrabatte, und können wir eine Rechnung auf die Firma bekommen? Viele Grüße, Katrin Meier",
topic: "Speaker-Reveal: Dr. Mira Okafor eröffnet die Konferenz",
},
palettes: [
{ key: "neon", label: "Neon", dark: true, bg: "#08070b", fg: "#f4f2fb", muted: "#8e8a9e", surface: "#131119", accent: "#ff3d81" },
{ key: "acid", label: "Acid", dark: true, bg: "#0a0b07", fg: "#f2f5ea", muted: "#8f9584", surface: "#14160f", accent: "#d4ff3a" },
{ key: "papier", label: "Papier", bg: "#f0eee7", fg: "#121212", muted: "#64625c", surface: "#e3e0d6", accent: "#ff4f00" },
],
fonts: [
{ key: "techno", label: "Bebas Neue & Space Mono" },
{ key: "modern", label: "Tilt Warp & Onest" },
],
sections: describeSections(defaults, {
brand: "Event",
hero: "Startbereich",
marquee: { label: "Laufband", optional: true, items: { items: "Thema" } },
speakers: { label: "Speaker", optional: true, items: { items: "Speaker" } },
agenda: { label: "Programm", optional: true, items: { items: "Programmpunkt" } },
tickets: { label: "Tickets", items: { items: "Ticket" } },
venue: { label: "Location", optional: true },
partners: { label: "Partner", optional: true, items: { items: "Partner" } },
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
}),
defaults,
})
export type EventData = typeof event.defaults

View file

@ -0,0 +1,13 @@
import { Bebas_Neue, Onest, Space_Mono, Tilt_Warp } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const bebas = Bebas_Neue({ subsets: ["latin"], weight: "400", variable: "--font-event-bebas", preload: false })
const spaceMono = Space_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-event-mono", preload: false })
const tilt = Tilt_Warp({ subsets: ["latin"], variable: "--font-event-tilt", preload: false })
const onest = Onest({ subsets: ["latin"], variable: "--font-event-onest", preload: false })
export const fonts: Record<string, FontPair> = {
techno: { className: `${bebas.variable} ${spaceMono.variable}`, display: "var(--font-event-bebas)", body: "var(--font-event-mono)" },
modern: { className: `${tilt.variable} ${onest.variable}`, display: "var(--font-event-tilt)", body: "var(--font-event-onest)" },
}

View file

@ -0,0 +1,363 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { Fragment, useState } from "react"
import { ArrowUpRightIcon, CalendarIcon, CheckIcon, MapPinIcon, TicketIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { splitList, useCountdown } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, Marquee, ParallaxImage, Reveal, RevealWords, ease, parseRich, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { event, type EventData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-10"
// Zeilenhöhe als Arbitrary Property: tailwind-merge würde leading-* neben text-[…] verwerfen
const display = "font-t-display uppercase [line-height:0.85] tracking-[-0.01em]"
const label = "text-xs uppercase tracking-[0.2em]"
function Eyebrow({ children }: { children: string }) {
return <p className={cn(label, "mb-5 text-t-accent")}>[ {children} ]</p>
}
// Buchstaben klappen nacheinander auf; der Startindex jedes Worts bestimmt die Verzögerung
function KineticTitle({ text }: { text: string }) {
const lines: { word: string; em: boolean; start: number }[][] = []
let count = 0
for (const tokens of parseRich(text)) {
const line = []
for (const token of tokens) {
line.push({ ...token, start: count })
count += token.word.length
}
lines.push(line)
}
return (
<h1 className={cn(display, "text-[clamp(5rem,19vw,20rem)] [perspective:800px]")} aria-label={text.replace(/\*/g, "").replace(/\n/g, " ")}>
{lines.map((tokens, l) => (
<span key={l} aria-hidden className="block">
{tokens.map((token, t) => (
<Fragment key={t}>
{t > 0 && " "}
<span className={cn("inline-block whitespace-nowrap", token.em && em)}>
{[...token.word].map((char, c) => (
<motion.span
key={c}
className="inline-block origin-bottom"
initial={{ opacity: 0, rotateX: -90, y: "30%" }}
animate={{ opacity: 1, rotateX: 0, y: "0%" }}
transition={{ duration: 0.9, ease, delay: 0.3 + (token.start + c) * 0.06 }}
>
{char}
</motion.span>
))}
</span>
</Fragment>
))}
</span>
))}
</h1>
)
}
function Digit({ value }: { value: string }) {
return (
<span className="relative inline-flex h-[1em] w-[0.6em] justify-center overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={value} initial={{ y: "-100%" }} animate={{ y: "0%" }} exit={{ y: "100%" }} transition={{ duration: 0.35, ease }} className="absolute">
{value}
</motion.span>
</AnimatePresence>
</span>
)
}
function Countdown({ date, className }: { date: string; className?: string }) {
const time = useCountdown(date)
const parts = [
{ value: time?.days, label: "Tage" },
{ value: time?.hours, label: "Stunden" },
{ value: time?.minutes, label: "Minuten" },
{ value: time?.seconds, label: "Sekunden" },
]
return (
<div className={cn("flex gap-5 md:gap-10", className)}>
{parts.map((part) => (
<div key={part.label}>
<p className={cn(display, "flex text-5xl tabular-nums md:text-7xl")}>
{(part.value === undefined ? "--" : String(part.value).padStart(2, "0")).split("").map((char, i) => (
<Digit key={i} value={char} />
))}
</p>
<p className={cn(label, "mt-2 text-t-muted")}>{part.label}</p>
</div>
))}
</div>
)
}
export function EventTemplate({ content }: TemplateProps<EventData>) {
const { brand, hero, marquee, speakers, agenda, tickets, venue, partners, faq } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#speaker", label: "Speaker", visible: show("speakers") },
{ href: "#programm", label: "Programm", visible: show("agenda") },
{ href: "#tickets", label: "Tickets", visible: true },
{ href: "#location", label: "Location", visible: show("venue") },
].filter((link) => link.visible)
return (
<TemplateShell def={event} content={content} fonts={fonts[content.fonts] ?? fonts.techno}>
<Nav brand={brand} hero={hero} links={links} />
<section id="top" className="relative overflow-hidden pt-32 pb-16 md:pt-40">
<div className="absolute inset-0 opacity-25">
<TImage src={hero.image} eager className="grayscale" />
</div>
<div className="absolute inset-0 bg-gradient-to-b from-t-bg/60 via-t-bg/80 to-t-bg" />
<motion.div
aria-hidden
className="absolute top-1/4 -left-40 size-[36rem] rounded-full bg-t-accent/30 blur-[120px]"
animate={{ x: [0, 120, 0], y: [0, 60, 0] }}
transition={{ duration: 14, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute -right-40 bottom-0 size-[30rem] rounded-full bg-t-accent/20 blur-[120px]"
animate={{ x: [0, -100, 0], y: [0, -80, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
<div className={cn(container, "relative")}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className={cn(label, "flex flex-wrap gap-x-8 gap-y-2 text-t-muted")}>
<span className="text-t-accent">● {hero.eyebrow}</span>
<span className="flex items-center gap-2"><CalendarIcon className="size-3.5" /> {hero.dateLabel}</span>
<span className="flex items-center gap-2"><MapPinIcon className="size-3.5" /> {hero.location}</span>
</motion.div>
<div className="mt-8">
<KineticTitle text={hero.title} />
</div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 1.1 }} className="mt-12 grid gap-10 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<p className="max-w-xl text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
<a href="#tickets" className="group mt-8 inline-flex items-center gap-3 bg-t-accent px-8 py-4 font-bold text-t-accent-fg uppercase transition-transform hover:-translate-y-1">
<TicketIcon className="size-5" /> {hero.cta}
<ArrowUpRightIcon className="size-5 transition-transform group-hover:rotate-45" />
</a>
</div>
<Countdown date={hero.date} />
</motion.div>
</div>
</section>
{show("marquee") && (
<section 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) => (
<span key={i} className={cn(display, "px-6 text-4xl md:text-6xl")}>{item.text} ✦</span>
))}
</Marquee>
</div>
<div className="rotate-1 border-y border-t-fg/15 py-3">
<Marquee duration={30} reverse>
{marquee.items.map((item, i) => (
<span key={i} className={cn(display, "px-6 text-4xl text-transparent [-webkit-text-stroke:1px_var(--t-fg)] md:text-6xl")}>{item.text} ✦</span>
))}
</Marquee>
</div>
</section>
)}
{show("speakers") && (
<section 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">
{speakers.items.map((speaker, i) => (
<Reveal key={i} delay={(i % 3) * 0.08}>
<article className="group">
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
<TImage src={speaker.image} alt={speaker.name} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0" />
<div className="absolute inset-0 bg-t-accent opacity-0 mix-blend-multiply transition-opacity duration-500 group-hover:opacity-40" />
<span className={cn(label, "absolute top-4 left-4 bg-t-bg/80 px-2 py-1 text-t-fg backdrop-blur")}>{String(i + 1).padStart(2, "0")}</span>
</div>
<h3 className={cn(display, "mt-4 text-4xl")}>{speaker.name}</h3>
<p className={cn(label, "mt-1 text-t-muted")}>{speaker.role} · {speaker.company}</p>
</article>
</Reveal>
))}
</div>
</section>
)}
{show("agenda") && <Agenda agenda={agenda} />}
<section id="tickets" className={cn(container, "py-24 md:py-32")}>
<Reveal><Eyebrow>{tickets.eyebrow}</Eyebrow></Reveal>
<RevealWords text={tickets.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
<div className="mt-14 grid gap-4 lg:grid-cols-3">
{tickets.items.map((ticket, i) => {
const featured = Boolean(ticket.highlight.trim())
const left = Number(ticket.left)
return (
<Reveal key={i} delay={i * 0.1}>
<article className={cn("relative flex h-full flex-col border p-8", featured ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15 bg-t-surface")}>
<p className={cn(label, featured ? "opacity-80" : "text-t-muted")}>{ticket.text}</p>
<h3 className={cn(display, "mt-3 text-5xl")}>{ticket.name}</h3>
<p className={cn(display, "mt-6 text-7xl")}>{ticket.price} €</p>
<ul className="mt-8 flex-1 space-y-2.5 text-sm">
{splitList(ticket.features, "\n").map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<CheckIcon className={cn("mt-0.5 size-4 shrink-0", !featured && "text-t-accent")} /> {feature}
</li>
))}
</ul>
{left > 0 && (
<div className="mt-8">
<p className={cn(label, "mb-2")}>Nur noch {left} verfügbar</p>
<div className={cn("h-1.5 overflow-hidden", featured ? "bg-t-accent-fg/20" : "bg-t-fg/10")}>
<motion.div
className={cn("h-full", featured ? "bg-t-accent-fg" : "bg-t-accent")}
initial={{ width: "0%" }}
whileInView={{ width: `${Math.max(8, Math.min(96, 100 - left))}%` }}
viewport={{ once: true }}
transition={{ duration: 1.2, ease }}
/>
</div>
</div>
)}
<a href="#" className={cn("mt-8 block py-4 text-center font-bold uppercase transition-opacity hover:opacity-85", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
Ticket kaufen
</a>
</article>
</Reveal>
)
})}
</div>
</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]")}>
<ParallaxImage src={venue.image} offset={50} className="aspect-[16/10]" sizes="(min-width: 1024px) 55vw, 100vw" />
<div>
<Reveal><Eyebrow>{venue.eyebrow}</Eyebrow></Reveal>
<RevealWords text={venue.title} emClassName={em} className={cn(display, "text-[clamp(3rem,7vw,6rem)]")} />
<Reveal delay={0.1}>
<p className="mt-6 text-lg leading-relaxed text-t-muted">{venue.text}</p>
<p className="mt-6 flex items-center gap-2 font-bold"><MapPinIcon className="size-4 text-t-accent" /> {venue.address}</p>
</Reveal>
</div>
</section>
)}
{show("partners") && (
<section 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">
{partners.items.map((partner, i) => (
<span key={i} className={cn(display, "text-3xl text-t-fg/50")}>{partner.name}</span>
))}
</div>
</div>
</section>
)}
{show("faq") && (
<section 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>
)}
<footer className="overflow-hidden border-t border-t-fg/10 py-16">
<div className={cn(container, "text-center")}>
<p className={cn(display, "text-[clamp(3rem,10vw,9rem)]")}>Wir sehen <span className={em}>uns.</span></p>
<Countdown date={hero.date} className="mt-8 justify-center" />
<div className={cn(label, "mt-14 flex flex-wrap justify-center gap-6 text-t-muted")}>
<span>{brand.name} · {brand.suffix}</span>
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ brand, hero, links }: { brand: EventData["brand"]; hero: EventData["hero"]; 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/85 backdrop-blur-xl" : "border-transparent")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
<a href="#top" className={cn(display, "text-3xl")}>{brand.name}</a>
<div className={cn(label, "hidden items-center gap-8 md: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>
<div className="flex items-center gap-3">
<span className={cn(label, "hidden border border-t-fg/20 px-3 py-2 lg:block")}>{hero.dateLabel}</span>
<a href="#tickets" className="bg-t-accent px-5 py-2.5 text-sm font-bold text-t-accent-fg uppercase">{hero.cta}</a>
</div>
</nav>
</motion.header>
)
}
function Agenda({ agenda }: { agenda: EventData["agenda"] }) {
const days = [...new Set(agenda.items.map((item) => item.day))]
const [day, setDay] = useState(days[0] ?? "")
const current = days.includes(day) ? day : days[0]
return (
<section id="programm" className="bg-t-surface py-24 md:py-32">
<div className={container}>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><Eyebrow>{agenda.eyebrow}</Eyebrow></Reveal>
<RevealWords text={agenda.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
</div>
<div role="tablist" className="flex gap-2">
{days.map((d) => (
<button
key={d}
type="button"
role="tab"
aria-selected={d === current}
onClick={() => setDay(d)}
className={cn("relative cursor-pointer px-6 py-3 font-bold uppercase", d === current ? "text-t-accent-fg" : "text-t-muted hover:text-t-fg")}
>
{d === current && <motion.span layoutId="event-day" className="absolute inset-0 bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
<span className="relative">{d}</span>
</button>
))}
</div>
</div>
<AnimatePresence mode="wait">
<motion.ol key={current} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.35, ease }} className="relative mt-14 ml-3 border-l-2 border-t-fg/15">
{agenda.items.filter((item) => item.day === current).map((item, i) => (
<li key={`${item.time}-${i}`} className="relative grid gap-2 pb-10 pl-8 md:grid-cols-[8rem_1fr_auto] md:items-baseline md:gap-8">
<span className="absolute top-2 -left-[9px] size-4 rounded-full border-4 border-t-surface bg-t-accent" />
<span className={cn(display, "text-4xl text-t-accent")}>{item.time}</span>
<div>
<p className={cn(display, "text-3xl md:text-4xl")}>{item.title}</p>
{item.speaker && <p className="mt-1 text-t-muted">{item.speaker}</p>}
</div>
<span className={cn(label, "w-fit border border-t-fg/20 px-3 py-1.5")}>{item.room}</span>
</li>
))}
</motion.ol>
</AnimatePresence>
</div>
</section>
)
}

View file

@ -0,0 +1,121 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "PULSE", suffix: "Training Club", phone: "030 123 456 78", address: "Torstraße 140, 10119 Berlin" },
hero: {
eyebrow: "Training Club · Berlin Mitte",
title: "Stärker\nals *gestern.*",
text: "Functional Training, Kraft und Yoga in kleinen Gruppen – mit Coaches, die dich wirklich sehen.",
cta: "Probetraining sichern",
ctaSecondary: "Kursplan ansehen",
image: unsplash("1605296867304-46d5465a13f1"),
},
stats: {
items: [
{ value: "40", suffix: "+", label: "Kurse pro Woche" },
{ value: "12", suffix: "", label: "Coaches" },
{ value: "1.800", suffix: "", label: "Mitglieder" },
{ value: "24", suffix: "/7", label: "Zugang" },
],
},
marquee: { items: [{ text: "HIIT" }, { text: "Kraft" }, { text: "Mobility" }, { text: "Yoga" }, { text: "Boxen" }, { text: "Cycling" }] },
programs: {
eyebrow: "Training",
title: "Vier Wege zu *mehr* Energie.",
items: [
{ title: "Kraft", text: "Langhantel, Kettlebell und saubere Technik – Schritt für Schritt stärker.", image: unsplash("1517963879433-6ad2b056d712", 1400) },
{ title: "Functional", text: "Ganzkörper-Workouts, die dich für den Alltag fit machen.", image: unsplash("1599058917212-d750089bc07e", 1400) },
{ title: "Yoga & Mobility", text: "Beweglichkeit, Atmung und Ruhe für Körper und Kopf.", image: unsplash("1544367567-0f2fcb009e0b", 1400) },
{ title: "Boxen", text: "Technik, Ausdauer und Kopf frei – ganz ohne Sparring-Zwang.", image: unsplash("1549060279-7e168fcee0c2", 1400) },
],
},
classes: {
eyebrow: "Kursplan",
title: "Finde deinen *Flow.*",
items: [
{ day: "Mo", time: "07:00", name: "Morning HIIT", trainer: "Lena", level: "3" },
{ day: "Mo", time: "12:15", name: "Lunch Mobility", trainer: "Aylin", level: "1" },
{ day: "Mo", time: "18:30", name: "Strength Basics", trainer: "Marco", level: "2" },
{ day: "Di", time: "07:00", name: "Functional Flow", trainer: "Lena", level: "2" },
{ day: "Di", time: "19:00", name: "Box Fit", trainer: "Jonas", level: "3" },
{ day: "Mi", time: "06:45", name: "Sunrise Yoga", trainer: "Aylin", level: "1" },
{ day: "Mi", time: "18:00", name: "Heavy Lifting", trainer: "Marco", level: "3" },
{ day: "Do", time: "12:15", name: "Core & Back", trainer: "Lena", level: "2" },
{ day: "Do", time: "19:30", name: "Box Technik", trainer: "Jonas", level: "2" },
{ day: "Fr", time: "07:00", name: "Morning HIIT", trainer: "Lena", level: "3" },
{ day: "Fr", time: "17:30", name: "Friday Burn", trainer: "Marco", level: "3" },
{ day: "Sa", time: "10:00", name: "Team Workout", trainer: "Alle", level: "2" },
{ day: "So", time: "11:00", name: "Slow Flow Yoga", trainer: "Aylin", level: "1" },
],
},
transformation: {
eyebrow: "Ergebnisse",
title: "Zwölf Wochen.\n*Echte* Veränderung.",
text: "Mit festen Trainingszeiten, einem Plan, der zu dir passt, und Coaches, die dranbleiben, wenn du es mal nicht tust.",
image: unsplash("1574680096145-d05b474e2155", 1800),
before: "Woche 1",
after: "Woche 12",
quote: "Ich hätte nie gedacht, dass ich mich auf Montagmorgen freue. Minus acht Kilo, plus ganz viel Energie.",
author: "Mara, 34, Mitglied seit 2025",
},
pricing: {
eyebrow: "Mitgliedschaft",
title: "Kein Vertrag, der *bremst.*",
plans: [
{ name: "Flex", price: "59", period: "pro Monat", features: "Alle Kurse\nMonatlich kündbar\nBuchung per App", highlight: "" },
{ name: "Club", price: "79", period: "pro Monat", features: "Alle Kurse & Open Gym\n24/7 Zugang\nSauna & Recovery\n1 Personal Training im Quartal", highlight: "ja" },
{ name: "10er-Karte", price: "190", period: "einmalig", features: "10 Kurse nach Wahl\n6 Monate gültig\nIdeal zum Reinschnuppern", highlight: "" },
],
},
coaches: {
eyebrow: "Coaches",
title: "Dein *Team.*",
members: [
{ name: "Lena Kraus", role: "HIIT & Functional", image: unsplash("1546483875-ad9014c88eba", 900) },
{ name: "Marco Silva", role: "Kraft & Athletik", image: unsplash("1581009146145-b5ef050c2e1e", 900) },
{ name: "Aylin Demir", role: "Yoga & Mobility", image: unsplash("1552196563-55cd4e45efb3", 900) },
{ name: "Jonas Weber", role: "Boxen & Kondition", image: unsplash("1583454110551-21f2fa2afe61", 900) },
],
},
trial: {
title: "Dein erstes Training ist *gratis.*",
text: "Komm vorbei, lern das Team kennen und trainiere eine Stunde mit – ohne Verpflichtung.",
cta: "Probetraining sichern",
},
}
export const fitness = defineTemplate({
key: "fitness",
name: "Pulse",
industry: "Fitness, Sport & Yoga",
description: "Energiegeladen und dunkel: kinetische Typografie, aufklappende Trainingsbereiche, Kursplan nach Wochentagen, Vorher/Nachher-Regler und Mitgliedschaften.",
cover: unsplash("1517836357463-d25dfeac3438", 1200),
samples: {
inquiry: "Hey, ich bin 31 und möchte nach längerer Pause wieder anfangen – vor allem wegen Rückenschmerzen. Was empfehlt ihr mir, und was kostet eine Mitgliedschaft mit Personal Training? Gruß, Felix",
topic: "Neuer Kurs: Mobility & Rücken ab Montag",
},
palettes: [
{ key: "volt", label: "Volt", dark: true, bg: "#0a0a0a", fg: "#f4f4ef", muted: "#8f8f88", surface: "#161616", accent: "#c6ff3d" },
{ key: "ember", label: "Ember", dark: true, bg: "#0d0a09", fg: "#f7f1ec", muted: "#9a8f88", surface: "#1a1412", accent: "#ff5a1f" },
{ key: "kreide", label: "Kreide", bg: "#f1f1ee", fg: "#0c0c0c", muted: "#62625d", surface: "#e2e2dd", accent: "#3d5afe" },
],
fonts: [
{ key: "power", label: "Unbounded & Space Grotesk" },
{ key: "athletic", label: "Anton & Work Sans" },
],
sections: describeSections(defaults, {
brand: "Studio & Kontakt",
hero: "Startbereich",
stats: { label: "Zahlen", optional: true, items: { items: "Kennzahl" } },
marquee: { label: "Laufband", optional: true, items: { items: "Begriff" } },
programs: { label: "Trainingsbereiche", optional: true, items: { items: "Bereich" } },
classes: { label: "Kursplan", optional: true, items: { items: "Kurs" } },
transformation: { label: "Vorher/Nachher", optional: true },
pricing: { label: "Mitgliedschaften", optional: true, items: { plans: "Tarif" } },
coaches: { label: "Coaches", optional: true, items: { members: "Coach" } },
trial: "Probetraining",
}),
defaults,
})
export type FitnessData = typeof fitness.defaults

View file

@ -0,0 +1,13 @@
import { Anton, Space_Grotesk, Unbounded, Work_Sans } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const unbounded = Unbounded({ subsets: ["latin"], variable: "--font-fitness-unbounded", preload: false })
const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-fitness-space", preload: false })
const anton = Anton({ subsets: ["latin"], weight: "400", variable: "--font-fitness-anton", preload: false })
const workSans = Work_Sans({ subsets: ["latin"], variable: "--font-fitness-work", preload: false })
export const fonts: Record<string, FontPair> = {
power: { className: `${unbounded.variable} ${spaceGrotesk.variable}`, display: "var(--font-fitness-unbounded)", body: "var(--font-fitness-space)" },
athletic: { className: `${anton.variable} ${workSans.variable}`, display: "var(--font-fitness-anton)", body: "var(--font-fitness-work)" },
}

View file

@ -0,0 +1,422 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { ArrowUpRightIcon, CheckIcon, MoveHorizontalIcon } 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 { Counter, Marquee, Reveal, RevealWords, ease, useScrollNav, wordmarkSize } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { fitness, type FitnessData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
const display = "font-t-display uppercase tracking-[-0.02em]"
const DAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
function Eyebrow({ children }: { children: string }) {
return <p className="mb-5 text-xs font-semibold uppercase tracking-[0.3em] text-t-accent">— {children}</p>
}
function SkewButton({ href, children, ghost }: { href: string; children: string; ghost?: boolean }) {
return (
<a
href={href}
className={cn(
"group inline-flex -skew-x-12 items-center px-7 py-4 transition-transform hover:-translate-y-0.5",
ghost ? "ring-1 ring-t-fg/30 hover:ring-t-accent" : "bg-t-accent text-t-accent-fg"
)}
>
<span className="flex skew-x-12 items-center gap-2 text-sm font-bold uppercase tracking-wider">
{children}
<ArrowUpRightIcon className="size-4 transition-transform group-hover:rotate-45" />
</span>
</a>
)
}
export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
const { brand, hero, stats, marquee, programs, classes, transformation, pricing, coaches, trial } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#training", label: "Training", visible: show("programs") },
{ href: "#kurse", label: "Kursplan", visible: show("classes") },
{ href: "#preise", label: "Preise", visible: show("pricing") },
{ href: "#team", label: "Coaches", visible: show("coaches") },
].filter((link) => link.visible)
return (
<TemplateShell def={fitness} content={content} fonts={fonts[content.fonts] ?? fonts.power}>
<Nav name={brand.name} cta={trial.cta} 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.2 }} animate={{ scale: 1 }} transition={{ duration: 2.2, ease }}>
<TImage src={hero.image} eager className="contrast-125 grayscale" />
</motion.div>
<div className="absolute inset-0 bg-t-accent/10 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-t-bg/50 to-t-bg/20" />
<div className={cn(container, "relative pt-36 pb-10")}>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.8, 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, "text-[clamp(3.2rem,10.5vw,11rem)] leading-[0.88] font-black")}
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.9 }}
className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between"
>
<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>}
</div>
</motion.div>
{show("stats") && (
<dl 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")}>
<Counter value={stat.value} />
<span className="text-t-accent">{stat.suffix}</span>
</dd>
<dt className="mt-2 text-xs uppercase tracking-[0.2em] text-t-muted">{stat.label}</dt>
</div>
))}
</dl>
)}
</div>
</section>
{show("marquee") && (
<div 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")}>
{item.text}
<span className="text-2xl">✱</span>
</span>
))}
</Marquee>
</div>
)}
{show("programs") && <Programs programs={programs} />}
{show("classes") && <Schedule classes={classes} />}
{show("transformation") && <Transformation data={transformation} />}
{show("pricing") && (
<section 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">
{pricing.plans.map((plan, i) => {
const featured = Boolean(plan.highlight.trim())
return (
<Reveal key={i} delay={i * 0.1}>
<article className={cn("relative flex h-full flex-col p-8 md:p-10", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
{featured && <span className="absolute top-6 right-6 bg-t-bg px-3 py-1 text-xs font-bold uppercase tracking-wider text-t-fg">Beliebt</span>}
<h3 className={cn(display, "text-2xl")}>{plan.name}</h3>
<p className="mt-6 flex items-end gap-2">
<span className={cn(display, "text-6xl md:text-7xl")}>{plan.price}€</span>
<span className={cn("pb-2 text-sm", featured ? "opacity-70" : "text-t-muted")}>{plan.period}</span>
</p>
<ul className="mt-8 flex-1 space-y-3">
{splitList(plan.features, "\n").map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className={cn("mt-0.5 size-4 shrink-0", !featured && "text-t-accent")} />
{feature}
</li>
))}
</ul>
<a
href="#probetraining"
className={cn("mt-10 block py-4 text-center text-sm font-bold uppercase tracking-wider transition-opacity hover:opacity-85", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}
>
Jetzt starten
</a>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("coaches") && (
<section 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">
{coaches.members.map((member, i) => (
<Reveal key={i} delay={i * 0.08}>
<div className="group relative aspect-[3/4] overflow-hidden bg-t-surface">
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0" />
<div className="absolute inset-x-0 bottom-0 translate-y-[calc(100%-4.5rem)] bg-t-accent p-5 text-t-accent-fg transition-transform duration-500 group-hover:translate-y-0">
<p className={cn(display, "text-lg leading-tight")}>{member.name}</p>
<p className="mt-3 text-sm opacity-80">{member.role}</p>
</div>
</div>
</Reveal>
))}
</div>
</section>
)}
<section id="probetraining" className="relative overflow-hidden bg-t-surface py-24 md:py-36">
<div className={cn(container, "grid gap-14 lg:grid-cols-2")}>
<div>
<RevealWords text={trial.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,7vw,6.5rem)] leading-[0.9] font-black")} />
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{trial.text}</p>
<p className="mt-8 text-sm text-t-muted">{brand.address} · {brand.phone}</p>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-6 bg-t-bg p-7 md:p-10"
success={
<div className="py-8">
<p className={cn(display, "text-4xl text-t-accent")}>Let&apos;s go!</p>
<p className="mt-3 text-t-muted">Wir melden uns heute noch mit deinem Termin fürs Probetraining.</p>
</div>
}
>
<TField label="Name" name="name" required autoComplete="name" variant="line" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="line" />
<TSelect label="Was willst du testen?" name="class" variant="line" options={[...new Set(classes.items.map((item) => item.name))]} />
<button type="submit" className="mt-2 -skew-x-12 cursor-pointer bg-t-accent py-4 text-t-accent-fg transition-transform hover:-translate-y-0.5">
<span className="block skew-x-12 text-sm font-bold uppercase tracking-wider">{trial.cta}</span>
</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="overflow-hidden pt-16">
<div className={cn(container, "flex flex-col gap-6 text-sm text-t-muted md:flex-row md:justify-between")}>
<p>{brand.name} {brand.suffix} · {brand.address}</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>
<p
aria-hidden
style={{ fontSize: wordmarkSize(brand.name, 30) }}
className={cn(display, "mt-8 text-center leading-[0.8] font-black whitespace-nowrap text-transparent [-webkit-text-stroke:1px_var(--t-accent)]")}
>
{brand.name}
</p>
</footer>
</TemplateShell>
)
}
function Nav({ name, cta, links }: { name: string; 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/85 backdrop-blur-xl")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
<a href="#top" className={cn(display, "text-2xl font-black")}>
{name}<span className="text-t-accent">.</span>
</a>
<div className="hidden items-center gap-8 md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="text-xs font-semibold uppercase tracking-[0.2em] text-t-fg/70 transition-colors hover:text-t-accent">
{link.label}
</a>
))}
</div>
<a href="#probetraining" className="-skew-x-12 bg-t-accent px-5 py-2.5 text-t-accent-fg">
<span className="block skew-x-12 text-xs font-bold uppercase tracking-wider">{cta}</span>
</a>
</nav>
</motion.header>
)
}
function Programs({ programs }: { programs: FitnessData["programs"] }) {
const [active, setActive] = useState(0)
return (
<section id="training" className={cn(container, "py-24 md:py-36")}>
<Reveal><Eyebrow>{programs.eyebrow}</Eyebrow></Reveal>
<RevealWords text={programs.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
<div className="mt-14 flex flex-col gap-3 md:h-[34rem] md:flex-row">
{programs.items.map((item, i) => {
const open = active === i
return (
<button
key={i}
type="button"
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={() => setActive(i)}
className={cn(
"group relative h-72 cursor-pointer overflow-hidden text-left transition-[flex-grow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] md:h-auto",
open ? "md:flex-[3.2]" : "md:flex-1"
)}
>
<TImage src={item.image} sizes="(min-width: 768px) 50vw, 100vw" className={cn("transition duration-700", open ? "scale-100" : "scale-110 grayscale")} />
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
<span className={cn(display, "absolute top-5 left-5 text-sm text-t-accent")}>{String(i + 1).padStart(2, "0")}</span>
<div className="absolute inset-x-0 bottom-0 p-6 text-white md:p-8">
<h3 className={cn(display, "text-3xl font-black md:text-4xl", !open && "md:origin-bottom-left md:-rotate-90 md:translate-x-8 md:whitespace-nowrap")}>
{item.title}
</h3>
<AnimatePresence>
{open && (
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease, delay: 0.2 }}
className="mt-3 max-w-sm text-white/80"
>
{item.text}
</motion.p>
)}
</AnimatePresence>
</div>
</button>
)
})}
</div>
</section>
)
}
function Schedule({ classes }: { classes: FitnessData["classes"] }) {
const days = DAYS.filter((day) => classes.items.some((item) => item.day === day))
const [day, setDay] = useState(days[0] ?? "Mo")
const [booked, setBooked] = useState<string[]>([])
const current = days.includes(day) ? day : days[0]
return (
<section id="kurse" className="bg-t-surface py-24 md:py-36">
<div className={container}>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><Eyebrow>{classes.eyebrow}</Eyebrow></Reveal>
<RevealWords text={classes.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
</div>
<div role="tablist" className="flex flex-wrap gap-1">
{days.map((d) => (
<button
key={d}
type="button"
role="tab"
aria-selected={d === current}
onClick={() => setDay(d)}
className={cn("relative cursor-pointer px-4 py-2.5 text-sm font-bold uppercase", d === current ? "text-t-accent-fg" : "text-t-muted hover:text-t-fg")}
>
{d === current && <motion.span layoutId="fitness-day" className="absolute inset-0 bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
<span className="relative">{d}</span>
</button>
))}
</div>
</div>
<AnimatePresence mode="wait">
<motion.ul key={current} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.35, ease }} className="mt-12 border-t border-t-fg/10">
{classes.items.filter((item) => item.day === current).map((item) => {
const id = `${item.day}-${item.time}-${item.name}`
const isBooked = booked.includes(id)
const level = Math.min(3, Math.max(1, Number(item.level) || 1))
return (
<li key={id} className="grid grid-cols-[5rem_1fr_auto] items-center gap-4 border-b border-t-fg/10 py-6 md:grid-cols-[8rem_1fr_10rem_8rem_auto]">
<span className={cn(display, "text-2xl text-t-accent md:text-3xl")}>{item.time}</span>
<div>
<p className={cn(display, "text-lg md:text-2xl")}>{item.name}</p>
<p className="text-sm text-t-muted md:hidden">mit {item.trainer}</p>
</div>
<p className="hidden text-sm text-t-muted md:block">mit {item.trainer}</p>
<span className="hidden items-end gap-1 md:flex" aria-label={`Intensität ${level} von 3`}>
{[1, 2, 3].map((bar) => (
<span key={bar} className={cn("w-2 rounded-sm", bar <= level ? "bg-t-accent" : "bg-t-fg/15")} style={{ height: 6 + bar * 6 }} />
))}
</span>
<button
type="button"
onClick={() => setBooked((list) => (isBooked ? list.filter((entry) => entry !== id) : [...list, id]))}
className={cn(
"flex cursor-pointer items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider transition-colors",
isBooked ? "bg-t-accent text-t-accent-fg" : "ring-1 ring-t-fg/20 hover:ring-t-accent"
)}
>
{isBooked && <CheckIcon className="size-3.5" />}
{isBooked ? "Gebucht" : "Platz sichern"}
</button>
</li>
)
})}
</motion.ul>
</AnimatePresence>
</div>
</section>
)
}
function Transformation({ data }: { data: FitnessData["transformation"] }) {
const [position, setPosition] = useState(50)
return (
<section className={cn(container, "grid items-center gap-14 py-24 md:py-36 lg:grid-cols-[1fr_1.2fr]")}>
<div>
<Reveal><Eyebrow>{data.eyebrow}</Eyebrow></Reveal>
<RevealWords text={data.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5.5vw,5rem)] leading-[0.92] font-black")} />
<Reveal delay={0.1}>
<p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{data.text}</p>
<blockquote className="mt-10 border-l-4 border-t-accent pl-6">
<p className="text-xl leading-snug">„{data.quote}“</p>
<footer className="mt-3 text-sm text-t-muted">{data.author}</footer>
</blockquote>
</Reveal>
</div>
<Reveal delay={0.1}>
<div className="relative aspect-[4/3] overflow-hidden bg-t-surface select-none">
<TImage src={data.image} sizes="(min-width: 1024px) 55vw, 100vw" />
<div className="absolute inset-0" style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}>
<TImage src={data.image} sizes="(min-width: 1024px) 55vw, 100vw" className="brightness-50 grayscale" />
</div>
<div className="pointer-events-none absolute inset-y-0 w-0.5 bg-t-accent" style={{ left: `${position}%` }}>
<span className="absolute top-1/2 left-1/2 grid size-12 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-xl">
<MoveHorizontalIcon className="size-5" />
</span>
</div>
<span className="pointer-events-none absolute top-4 left-4 bg-black/70 px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">{data.before}</span>
<span className="pointer-events-none absolute top-4 right-4 bg-t-accent px-3 py-1 text-xs font-bold uppercase tracking-wider text-t-accent-fg">{data.after}</span>
<input
type="range"
min={0}
max={100}
value={position}
onChange={(event) => setPosition(Number(event.target.value))}
aria-label="Vorher/Nachher vergleichen"
className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
/>
</div>
</Reveal>
</section>
)
}

View file

@ -0,0 +1,227 @@
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
export const handwerk = defineTemplate({
key: "handwerk",
name: "Werkstatt",
industry: "Handwerk & Bau",
description: "Kräftig und grafisch: große Typografie, Leistungen mit Hover-Effekt, Zahlen zum Hochzählen, Projekt-Raster und Anfrageformular.",
cover: unsplash("1600566753190-17f0baa2a6c3", 1200),
samples: {
inquiry: "Guten Tag, wir möchten unser Badezimmer (ca. 8 m², Baujahr 1985) komplett sanieren: neue Fliesen, bodengleiche Dusche statt Wanne, neues WC und Waschtisch. Können Sie uns ein Angebot machen? Gruß, Thomas Krüger",
topic: "Vorher-nachher: Badsanierung in vier Wochen",
},
palettes: [
{ key: "signal", label: "Signal", bg: "#ebe9e4", fg: "#121212", muted: "#5f5d58", surface: "#dedbd4", accent: "#ff5a1f" },
{ key: "nacht", label: "Nacht", dark: true, bg: "#0e0f10", fg: "#f2f1ed", muted: "#8c8e91", surface: "#18191b", accent: "#ffc400" },
{ key: "werkstatt", label: "Werkstatt", bg: "#f2f0eb", fg: "#14233a", muted: "#5b6676", surface: "#e3e1db", accent: "#2563eb" },
],
fonts: [
{ key: "kraeftig", label: "Barlow Condensed & Barlow" },
{ key: "technisch", label: "Bricolage Grotesque & Inter Tight" },
],
sections: [
{
key: "brand",
label: "Marke",
fields: [
{ key: "name", label: "Name" },
{ key: "suffix", label: "Zusatz" },
{ key: "claim", label: "Hinweis in der Kopfzeile" },
{ key: "phone", label: "Telefon" },
],
},
{
key: "hero",
label: "Startbereich",
fields: [
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "image", label: "Bild", type: "image" },
{ key: "cta", label: "Button" },
{ key: "ctaSecondary", label: "Zweiter Link" },
{ key: "badgeValue", label: "Kennzahl" },
{ key: "badgeSuffix", label: "Kennzahl-Zusatz (z. B. +)" },
{ key: "badgeLabel", label: "Kennzahl-Beschriftung" },
{ key: "trust", label: "Vertrauenszeile" },
],
},
{
key: "marquee",
label: "Laufband",
optional: true,
fields: [{ key: "items", label: "Begriffe", type: "list", itemLabel: "Begriff", fields: [{ key: "text", label: "Text" }] }],
},
{
key: "services",
label: "Leistungen",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{
key: "items",
label: "Leistungen",
type: "list",
itemLabel: "Leistung",
fields: [
{ key: "title", label: "Titel" },
{ key: "text", label: "Beschreibung", type: "textarea" },
],
},
],
},
{
key: "stats",
label: "Zahlen",
optional: true,
fields: [
{
key: "items",
label: "Kennzahlen",
type: "list",
itemLabel: "Kennzahl",
fields: [
{ key: "value", label: "Zahl (z. B. 1.200)" },
{ key: "suffix", label: "Zusatz (z. B. + oder %)" },
{ key: "label", label: "Beschriftung" },
],
},
],
},
{
key: "projects",
label: "Projekte",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "items",
label: "Projekte",
type: "list",
itemLabel: "Projekt",
fields: [
{ key: "title", label: "Titel" },
{ key: "meta", label: "Art & Jahr" },
{ key: "image", label: "Bild", type: "image" },
],
},
],
},
{
key: "process",
label: "Ablauf",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "steps",
label: "Schritte",
type: "list",
itemLabel: "Schritt",
fields: [
{ key: "title", label: "Titel" },
{ key: "text", label: "Beschreibung", type: "textarea" },
],
},
],
},
{
key: "testimonial",
label: "Kundenstimme",
optional: true,
fields: [
{ key: "text", label: "Zitat", type: "textarea" },
{ key: "author", label: "Von" },
{ key: "role", label: "Projekt" },
],
},
{
key: "contact",
label: "Kontakt",
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "email", label: "E-Mail" },
{ key: "address", label: "Adresse" },
{ key: "hours", label: "Erreichbarkeit" },
],
},
],
defaults: {
brand: { name: "Brandt", suffix: "Bau & Sanierung", claim: "Meisterbetrieb in dritter Generation", phone: "07031 123 45 67" },
hero: {
title: "Wir bauen,\nwas *bleibt.*",
text: "Neubau, Sanierung und Innenausbau aus einer Hand. Mit festen Ansprechpartnern, ehrlichen Festpreisen und Handwerk, auf das Sie sich verlassen können.",
image: unsplash("1600566753190-17f0baa2a6c3", 1600),
cta: "Projekt anfragen",
ctaSecondary: "Unsere Projekte",
badgeValue: "35",
badgeSuffix: "+",
badgeLabel: "Jahre Erfahrung",
trust: "Meisterbetrieb · Seit 1989 · 4,9 ★ bei Google",
},
marquee: {
items: [{ text: "Rohbau" }, { text: "Sanierung" }, { text: "Innenausbau" }, { text: "Dach & Fassade" }, { text: "Energieberatung" }],
},
services: {
eyebrow: "Leistungen",
title: "Alles aus\n*einer Hand.*",
text: "Ein Ansprechpartner, ein Zeitplan, ein Festpreis. Wir koordinieren alle Gewerke – Sie behalten den Überblick.",
items: [
{ title: "Neubau & Rohbau", text: "Vom Fundament bis zum Dachstuhl – termintreu, sauber und mit festen Ansprechpartnern." },
{ title: "Sanierung", text: "Wir bringen Altbauten auf den neuesten Stand – energetisch, technisch und optisch." },
{ title: "Innenausbau", text: "Trockenbau, Böden, Bäder und Küchen. Präzise Arbeit bis ins letzte Detail." },
{ title: "Dach & Fassade", text: "Dämmung, Eindeckung und Fassadengestaltung, die Jahrzehnte hält." },
{ title: "Energieberatung", text: "Wir zeigen, welche Maßnahmen sich lohnen – inklusive Fördermittel-Check." },
],
},
stats: {
items: [
{ value: "35", suffix: "+", label: "Jahre Erfahrung" },
{ value: "1.200", suffix: "", label: "Projekte abgeschlossen" },
{ value: "48", suffix: "", label: "Fachkräfte im Team" },
{ value: "98", suffix: " %", label: "Weiterempfehlung" },
],
},
projects: {
eyebrow: "Projekte",
title: "Ausgewählte\n*Arbeiten.*",
items: [
{ title: "Haus am Hang", meta: "Neubau · 2025", image: unsplash("1600566753190-17f0baa2a6c3", 1600) },
{ title: "Altbau-Wohnung", meta: "Kernsanierung · 2024", image: unsplash("1600210492486-724fe5c67fb0", 1200) },
{ title: "Offene Küche", meta: "Innenausbau · 2024", image: unsplash("1484154218962-a197022b5858", 1200) },
{ title: "Familienbad", meta: "Badsanierung · 2023", image: unsplash("1552321554-5fefe8c9ef14", 1600) },
{ title: "Stadtvilla", meta: "Neubau · 2023", image: unsplash("1600047509807-ba8f99d2cdde", 2000) },
],
},
process: {
eyebrow: "Ablauf",
title: "So arbeiten *wir.*",
steps: [
{ title: "Erstgespräch", text: "Wir hören zu, schauen uns alles vor Ort an und klären Ihre Wünsche." },
{ title: "Angebot", text: "Sie erhalten ein transparentes Festpreisangebot mit klarem Zeitplan." },
{ title: "Umsetzung", text: "Ein fester Bauleiter koordiniert alle Gewerke und hält Sie auf dem Laufenden." },
{ title: "Übergabe", text: "Gemeinsame Abnahme, saubere Baustelle und fünf Jahre Gewährleistung." },
],
},
testimonial: {
text: "Von der ersten Beratung bis zur Schlüsselübergabe lief alles wie abgesprochen. Termintreu, sauber und immer erreichbar – so wünscht man sich einen Handwerksbetrieb.",
author: "Familie Krämer",
role: "Neubau Einfamilienhaus",
},
contact: {
eyebrow: "Kontakt",
title: "Lassen Sie uns\n*loslegen.*",
text: "Erzählen Sie uns von Ihrem Vorhaben. Wir melden uns innerhalb von 24 Stunden mit einem Terminvorschlag.",
email: "info@brandt-bau.de",
address: "Industriestraße 8, 71034 Böblingen",
hours: "Mo – Fr · 7 – 17 Uhr",
},
},
})
export type HandwerkData = typeof handwerk.defaults

View file

@ -0,0 +1,21 @@
import { Barlow, Barlow_Condensed, Bricolage_Grotesque, Inter_Tight } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const barlowCondensed = Barlow_Condensed({ subsets: ["latin"], weight: ["600", "700", "800"], variable: "--font-barlow-condensed", preload: false })
const barlow = Barlow({ subsets: ["latin"], weight: ["400", "500", "600"], variable: "--font-barlow", preload: false })
const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage", preload: false })
const interTight = Inter_Tight({ subsets: ["latin"], variable: "--font-inter-tight", preload: false })
export const fonts: Record<string, FontPair> = {
kraeftig: {
className: `${barlowCondensed.variable} ${barlow.variable}`,
display: "var(--font-barlow-condensed)",
body: "var(--font-barlow)",
},
technisch: {
className: `${bricolage.variable} ${interTight.variable}`,
display: "var(--font-bricolage)",
body: "var(--font-inter-tight)",
},
}

View file

@ -0,0 +1,441 @@
"use client"
import { motion, useScroll } from "motion/react"
import { useRef, type InputHTMLAttributes } from "react"
import { ArrowUpRightIcon } 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 { Counter, Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { handwerk, type HandwerkData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
const display = "font-t-display font-extrabold uppercase"
const fadeUp = {
initial: { opacity: 0, y: 40 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, margin: "0px 0px -10% 0px" },
} as const
// Raster der Projekte: wechselnde Breiten und Formate statt gleichförmiger Kacheln
const projectLayout = [
{ col: "md:col-span-7", aspect: "aspect-[4/3]" },
{ col: "md:col-span-5", aspect: "aspect-[4/5]" },
{ col: "md:col-span-5", aspect: "aspect-[4/5]" },
{ col: "md:col-span-7", aspect: "aspect-[4/3]" },
{ col: "md:col-span-12", aspect: "aspect-[4/3] md:aspect-[21/9]" },
]
function Eyebrow({ children, inverted }: { children: string; inverted?: boolean }) {
return (
<p className={cn("mb-6 flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.25em]", inverted ? "text-t-bg/60" : "text-t-muted")}>
<span className="size-2.5 bg-t-accent" />
{children}
</p>
)
}
function BlockButton({ href, children, inverted }: { href: string; children: string; inverted?: boolean }) {
return (
<a href={href} className="group inline-flex items-stretch">
<span className={cn(display, "px-6 py-4 text-sm tracking-wider md:text-base", inverted ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
{children}
</span>
<span className="grid place-items-center bg-t-accent px-4 text-t-accent-fg">
<ArrowUpRightIcon className="size-5 transition-transform duration-500 group-hover:rotate-45" />
</span>
</a>
)
}
export function HandwerkTemplate({ content }: TemplateProps<HandwerkData>) {
const { brand, hero, marquee, services, stats, projects, process, testimonial, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#leistungen", label: "Leistungen", visible: show("services") },
{ href: "#projekte", label: "Projekte", visible: show("projects") },
{ href: "#ablauf", label: "Ablauf", visible: show("process") },
{ href: "#kontakt", label: "Kontakt", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={handwerk} content={content} fonts={fonts[content.fonts] ?? fonts.kraeftig}>
<Header brand={brand} cta={hero.cta} links={links} />
<section id="top" className="relative overflow-hidden pt-36 pb-20 md:pt-48 md:pb-28">
<div
aria-hidden
className="absolute inset-0 [mask-image:linear-gradient(to_bottom,black,transparent_85%)] bg-[size:72px_72px] bg-[linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px)]"
/>
<div className={cn(container, "relative grid items-end gap-14 md:grid-cols-12 md:gap-10")}>
<div className="md:col-span-7">
<motion.p
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, ease, delay: 0.2 }}
className="mb-8 inline-flex items-center gap-3 border border-t-fg/15 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em]"
>
<span className="size-2 animate-pulse bg-t-accent" />
{hero.trust}
</motion.p>
<RevealWords
as="h1"
onMount
delay={0.3}
text={hero.title}
emClassName={em}
className={cn(display, "text-[clamp(3.6rem,11vw,11.5rem)] leading-[0.84] tracking-[-0.01em]")}
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 0.9 }}
>
<p className="mt-10 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
<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">
{hero.ctaSecondary}
</a>
)}
</div>
</motion.div>
</div>
<div className="relative md:col-span-5">
<motion.div
initial={{ clipPath: "inset(100% 0% 0% 0%)" }}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
transition={{ duration: 1.4, ease, delay: 0.3 }}
className="relative aspect-[4/5] overflow-hidden"
>
<motion.div className="absolute inset-0" initial={{ scale: 1.3 }} animate={{ scale: 1 }} transition={{ duration: 1.8, ease, delay: 0.3 }}>
<TImage src={hero.image} eager sizes="(min-width: 768px) 40vw, 100vw" />
</motion.div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 1.1 }}
className="absolute -bottom-8 -left-2 bg-t-accent p-6 text-t-accent-fg md:-left-16 md:p-8"
>
<p className={cn(display, "text-6xl leading-none md:text-7xl")}>
{hero.badgeValue}
{hero.badgeSuffix}
</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-[0.2em]">{hero.badgeLabel}</p>
</motion.div>
</div>
</div>
</section>
{show("marquee") && (
<section 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) => (
<span key={i} className="flex items-center">
<span
className={cn(
display,
"px-6 text-5xl md:px-10 md:text-8xl",
i % 2 === 1 && "text-transparent [-webkit-text-stroke:1.5px_var(--t-bg)]"
)}
>
{item.text}
</span>
<span className="size-3 bg-t-accent md:size-4" />
</span>
))}
</Marquee>
</div>
</section>
)}
{show("services") && (
<section 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>
<RevealWords text={services.title} emClassName={em} className={cn(display, "text-[clamp(2.8rem,6.5vw,6.5rem)] leading-[0.88]")} />
</div>
<Reveal delay={0.15} className="self-end md:col-span-5 md:col-start-8">
<p className="text-lg leading-relaxed text-t-muted">{services.text}</p>
</Reveal>
</div>
<ul className="border-t-2 border-t-fg">
{services.items.map((item, i) => (
<motion.li
key={i}
{...fadeUp}
transition={{ duration: 0.8, ease, delay: i * 0.05 }}
className="group relative overflow-hidden border-b border-t-fg/15"
>
<span className="absolute inset-0 origin-bottom scale-y-0 bg-t-accent transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-y-100" />
<div className="relative grid gap-3 py-8 transition-colors duration-500 group-hover:text-t-accent-fg md:grid-cols-12 md:items-center md:gap-6 md:py-10">
<span className={cn(display, "text-lg md:col-span-1")}>{String(i + 1).padStart(2, "0")}</span>
<h3 className={cn(display, "text-3xl leading-none md:col-span-5 md:text-5xl")}>{item.title}</h3>
<p className="text-t-muted transition-colors duration-500 group-hover:text-current md:col-span-5">{item.text}</p>
<ArrowUpRightIcon className="hidden size-9 justify-self-end transition-transform duration-500 group-hover:rotate-45 md:col-span-1 md:block" />
</div>
</motion.li>
))}
</ul>
</section>
)}
{show("stats") && (
<section 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">
<p className={cn(display, "text-6xl leading-none md:text-8xl")}>
<Counter value={stat.value} />
<span className="text-t-accent">{stat.suffix}</span>
</p>
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/60">{stat.label}</p>
</Reveal>
))}
</div>
</section>
)}
{show("projects") && (
<section 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">
{projects.items.map((project, i) => {
const layout = projectLayout[i % projectLayout.length]
return (
<Reveal key={i} delay={(i % 2) * 0.12} className={layout.col}>
<a href="#kontakt" className="group block">
<div className={cn("relative overflow-hidden bg-t-surface", layout.aspect)}>
<TImage
src={project.image}
alt={project.title}
sizes="(min-width: 768px) 60vw, 100vw"
className="transition-transform duration-[1.2s] ease-out group-hover:scale-105"
/>
<span className="absolute top-4 right-4 grid size-14 scale-0 place-items-center bg-t-accent text-t-accent-fg transition-transform duration-500 group-hover:scale-100">
<ArrowUpRightIcon className="size-6" />
</span>
</div>
<div className="mt-5 flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 border-b border-t-fg/15 pb-5">
<h3 className={cn(display, "text-2xl md:text-4xl")}>{project.title}</h3>
<span className="text-sm font-medium uppercase tracking-wider text-t-muted">{project.meta}</span>
</div>
</a>
</Reveal>
)
})}
</div>
</section>
)}
{show("process") && <Process process={process} />}
{show("testimonial") && (
<section 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>
<div className="md:col-span-10">
<RevealWords
as="p"
text={testimonial.text}
className="font-t-display text-[clamp(1.75rem,3.6vw,3.4rem)] leading-[1.08] font-semibold tracking-[-0.01em]"
/>
<Reveal delay={0.3}>
<p className="mt-10 flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="h-0.5 w-12 bg-t-accent" />
<span className="font-semibold uppercase tracking-wider">{testimonial.author}</span>
<span className="text-t-muted">{testimonial.role}</span>
</p>
</Reveal>
</div>
</section>
)}
<section id="kontakt" className="bg-t-fg text-t-bg">
<div className={cn(container, "grid gap-16 py-24 md:grid-cols-12 md:py-36")}>
<div className="md:col-span-5">
<Reveal><Eyebrow inverted>{contact.eyebrow}</Eyebrow></Reveal>
<RevealWords text={contact.title} emClassName={em} className={cn(display, "text-[clamp(2.8rem,6vw,6rem)] leading-[0.88]")} />
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-bg/70">{contact.text}</p>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className={cn(display, "mt-12 block text-4xl transition-colors hover:text-t-accent md:text-5xl")}>
{brand.phone}
</a>
<dl className="mt-10 grid gap-5 text-sm">
{[
["E-Mail", contact.email],
["Adresse", contact.address],
["Erreichbar", contact.hours],
].map(([label, value]) => (
<div key={label} className="grid grid-cols-[7rem_1fr] gap-4 border-t border-t-bg/15 pt-5">
<dt className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</Reveal>
</div>
<Reveal delay={0.1} className="md:col-span-6 md:col-start-7">
<DemoForm
className="border border-t-bg/15 p-6 md:p-10"
success={
<div className="py-10">
<p className={cn(display, "text-5xl text-t-accent")}>Danke!</p>
<p className="mt-4 text-t-bg/70">Wir melden uns innerhalb von 24 Stunden mit einem Terminvorschlag.</p>
</div>
}
>
<p className={cn(display, "text-3xl")}>Projekt anfragen</p>
<div className="mt-8 grid gap-7 sm:grid-cols-2">
<Line label="Name" name="name" autoComplete="name" required />
<Line label="Telefon" name="phone" type="tel" autoComplete="tel" required />
<Line label="E-Mail" name="email" type="email" autoComplete="email" className="sm:col-span-2" />
<label className="block sm:col-span-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">Art des Projekts</span>
<select
name="type"
className="mt-2 w-full border-b border-t-bg/25 bg-transparent py-3 text-base outline-none focus:border-t-accent [&>option]:text-t-fg"
>
{services.items.map((item, i) => (
<option key={i}>{item.title}</option>
))}
<option>Sonstiges</option>
</select>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">Ihr Vorhaben</span>
<textarea
name="message"
rows={4}
className="mt-2 w-full resize-none border-b border-t-bg/25 bg-transparent py-3 text-base outline-none focus:border-t-accent"
/>
</label>
</div>
<button type="submit" className="group mt-10 inline-flex w-full cursor-pointer items-stretch">
<span className={cn(display, "flex-1 bg-t-accent px-6 py-4 text-left tracking-wider text-t-accent-fg")}>Anfrage senden</span>
<span className="grid place-items-center bg-t-bg px-5 text-t-fg">
<ArrowUpRightIcon className="size-5 transition-transform duration-500 group-hover:rotate-45" />
</span>
</button>
</DemoForm>
</Reveal>
</div>
<footer className="border-t border-t-bg/15">
<div className={cn(container, "flex flex-col gap-6 py-10 text-sm text-t-bg/60 md:flex-row md:items-center md:justify-between")}>
<Logo brand={brand} />
<div className="flex gap-8">
<a href="#" className="hover:text-t-bg">Impressum</a>
<a href="#" className="hover:text-t-bg">Datenschutz</a>
</div>
</div>
</footer>
</section>
</TemplateShell>
)
}
function Logo({ brand }: { brand: HandwerkData["brand"] }) {
return (
<a href="#top" className="flex items-center gap-3">
<span className={cn(display, "grid size-10 place-items-center bg-t-accent text-xl text-t-accent-fg")}>
{brand.name.trim().charAt(0)}
</span>
<span className="flex flex-col leading-none">
<span className={cn(display, "text-2xl text-current")}>{brand.name}</span>
<span className="mt-1 text-[10px] font-semibold uppercase tracking-[0.25em] opacity-60">{brand.suffix}</span>
</span>
</a>
)
}
function Header({ brand, cta, links }: { brand: HandwerkData["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="fixed inset-x-0 top-0 z-50"
>
<motion.div
initial={false}
animate={{ height: scrolled ? 0 : "auto" }}
transition={{ duration: 0.4, ease }}
className="overflow-hidden bg-t-accent text-t-accent-fg"
>
<div className={cn(container, "flex items-center justify-between py-2 text-xs font-semibold uppercase tracking-[0.18em]")}>
<span className="truncate">{brand.claim}</span>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="shrink-0">{brand.phone}</a>
</div>
</motion.div>
<div className={cn("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")}>
<Logo brand={brand} />
<div className="hidden items-center gap-9 md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="group relative text-sm font-semibold uppercase tracking-wider">
{link.label}
<span className="absolute -bottom-1.5 left-0 h-0.5 w-full origin-right scale-x-0 bg-t-accent transition-transform duration-500 group-hover:origin-left group-hover:scale-x-100" />
</a>
))}
</div>
<a href="#kontakt" className={cn(display, "hidden bg-t-fg px-5 py-3 text-sm tracking-wider text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg sm:block")}>
{cta}
</a>
</nav>
</div>
</motion.header>
)
}
function Process({ process }: { process: HandwerkData["process"] }) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.75", "end 0.75"] })
return (
<section ref={ref} id="ablauf" className="bg-t-surface py-24 md:py-36">
<div className={container}>
<Reveal><Eyebrow>{process.eyebrow}</Eyebrow></Reveal>
<RevealWords text={process.title} emClassName={em} className={cn(display, "text-[clamp(2.8rem,6.5vw,6.5rem)] leading-[0.88]")} />
<div className="relative mt-16 md:mt-24">
<div className="absolute top-[26px] right-0 left-0 hidden h-0.5 bg-t-fg/10 md:block" />
<motion.div style={{ scaleX: scrollYProgress }} className="absolute top-[26px] right-0 left-0 hidden h-0.5 origin-left bg-t-accent md:block" />
<ol className="grid gap-12 md:grid-cols-4 md:gap-8">
{process.steps.map((step, i) => (
<motion.li key={i} {...fadeUp} transition={{ duration: 0.8, ease, delay: i * 0.12 }}>
<span className={cn(display, "relative grid size-[54px] place-items-center bg-t-fg text-xl text-t-bg")}>{i + 1}</span>
<h3 className={cn(display, "mt-8 text-3xl")}>{step.title}</h3>
<p className="mt-3 leading-relaxed text-t-muted">{step.text}</p>
</motion.li>
))}
</ol>
</div>
</div>
</section>
)
}
function Line({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return (
<label className={cn("block", className)}>
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-t-bg/50">{label}</span>
<input
{...props}
className="mt-2 w-full border-b border-t-bg/25 bg-transparent py-3 text-base outline-none transition-colors focus:border-t-accent"
/>
</label>
)
}

View file

@ -0,0 +1,91 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "Kessler", suffix: "Immobilien", phone: "040 123 456 78", email: "kontakt@kessler-immobilien.de", address: "Neuer Wall 18, 20354 Hamburg" },
hero: { eyebrow: "Exklusive Immobilien in Hamburg seit 1994", title: "Räume,\ndie *bleiben.*", cta: "Objekte entdecken" },
slides: {
items: [
{ title: "Villa am Elbhang", location: "Hamburg-Blankenese", image: unsplash("1613490493576-7fde63acd811") },
{ title: "Penthouse mit Weitblick", location: "Hamburg-Hafencity", image: unsplash("1600573472550-8090b5e0745e") },
{ title: "Stadtvilla mit Garten", location: "Hamburg-Harvestehude", image: unsplash("1600607688969-a5bfcd646154") },
],
},
listings: {
eyebrow: "Aktuelle Objekte",
title: "Ausgewählte *Angebote*",
items: [
{ title: "Moderne Villa mit Pool", location: "Blankenese", type: "Kaufen", price: "3.450.000 €", sqm: "320", rooms: "7", image: unsplash("1600596542815-ffad4c1539a9", 1200) },
{ title: "Lichtdurchflutetes Loft", location: "Ottensen", type: "Mieten", price: "3.200 € / Monat", sqm: "145", rooms: "4", image: unsplash("1600566753086-00f18fb6b3ea", 1200) },
{ title: "Penthouse mit Dachterrasse", location: "Hafencity", type: "Kaufen", price: "2.150.000 €", sqm: "180", rooms: "5", image: unsplash("1560448204-e02f11c3d0e2", 1200) },
{ title: "Familienhaus am Park", location: "Eppendorf", type: "Kaufen", price: "1.890.000 €", sqm: "210", rooms: "6", image: unsplash("1600047509358-9dc75507daeb", 1200) },
{ title: "Altbauwohnung mit Stuck", location: "Winterhude", type: "Mieten", price: "2.400 € / Monat", sqm: "118", rooms: "3,5", image: unsplash("1505691938895-1758d7feb511", 1200) },
{ title: "Neubauwohnung mit Loggia", location: "Uhlenhorst", type: "Mieten", price: "1.950 € / Monat", sqm: "92", rooms: "3", image: unsplash("1493809842364-78817add7ffb", 1200) },
],
},
about: {
eyebrow: "Über uns",
title: "Diskret. Persönlich.\n*Erfolgreich.*",
text: "Seit drei Jahrzehnten vermitteln wir besondere Immobilien in Hamburg und an der Küste – mit einem Netzwerk vorgemerkter Käufer:innen, ehrlichen Wertermittlungen und Exposés, die Lust auf Besichtigungen machen.",
image: unsplash("1600607687644-c7171b42498f", 1400),
facts: [
{ value: "30", label: "Jahre am Markt" },
{ value: "1.200+", label: "vermittelte Objekte" },
{ value: "8", label: "Wochen Ø Vermarktung" },
],
},
projects: {
eyebrow: "Referenzen",
title: "Projekte, auf die wir *stolz* sind.",
items: [
{ title: "Haus am See", place: "Plön", year: "2025", image: unsplash("1613977257363-707ba9348227", 1200) },
{ title: "Wohnensemble Alster", place: "Hamburg", year: "2024", image: unsplash("1545324418-cc1a3fa10c00", 1200) },
{ title: "Villa Bianca", place: "Sylt", year: "2023", image: unsplash("1580587771525-78b9dba3b914", 1200) },
{ title: "Kontorhaus Neu", place: "Hamburg", year: "2022", image: unsplash("1487958449943-2429e8be8625", 1200) },
{ title: "Pavillon Süd", place: "Lübeck", year: "2021", image: unsplash("1512917774080-9991f1c4c750", 1200) },
],
},
valuation: {
eyebrow: "Wertermittlung",
title: "Was ist Ihre Immobilie *wert?*",
text: "Kostenlose und unverbindliche Einschätzung durch unsere Expert:innen – innerhalb von 48 Stunden.",
image: unsplash("1502005229762-cf1b2da7c5d6"),
},
testimonial: {
quote: "Unser Haus war in fünf Wochen verkauft – über dem Angebotspreis und an Menschen, die wirklich passten. Diskreter und professioneller geht es nicht.",
author: "Familie Albrecht, Verkauf in Blankenese",
},
}
export const immobilien = defineTemplate({
key: "immobilien",
name: "Residenz",
industry: "Immobilien & Architektur",
description: "Luxuriös und ruhig: Vollbild-Slider mit Ken-Burns-Effekt, Suche mit filterbaren Objekten, Referenz-Index mit Bildvorschau und Wertermittlung.",
cover: unsplash("1613490493576-7fde63acd811", 1200),
samples: {
inquiry: "Guten Tag, wir möchten unser Einfamilienhaus in Hamburg-Volksdorf verkaufen (Baujahr 1998, 160 m² Wohnfläche, 700 m² Grundstück). Können Sie den Wert einschätzen, und wie hoch wäre Ihre Provision? Freundliche Grüße, Familie Peters",
topic: "Neu im Angebot: Penthouse mit Elbblick in der Hafencity",
},
palettes: [
{ key: "onyx", label: "Onyx", dark: true, bg: "#0d0d0c", fg: "#f1ede6", muted: "#9a958c", surface: "#171715", accent: "#b8955f" },
{ key: "kalk", label: "Kalk", bg: "#f4f1ec", fg: "#161513", muted: "#6e6a63", surface: "#e9e4dc", accent: "#8c6a3f" },
{ key: "salbei", label: "Salbei", bg: "#eef0eb", fg: "#1b241f", muted: "#66716a", surface: "#e0e4dc", accent: "#5e7a5a" },
],
fonts: [
{ key: "elegant", label: "Italiana & Montserrat" },
{ key: "modern", label: "Tenor Sans & Hanken Grotesk" },
],
sections: describeSections(defaults, {
brand: "Makler & Kontakt",
hero: "Startbereich",
slides: { label: "Slider", items: { items: "Slide" } },
listings: { label: "Objekte", optional: true, items: { items: "Objekt" } },
about: { label: "Über uns", optional: true, items: { facts: "Kennzahl" } },
projects: { label: "Referenzen", optional: true, items: { items: "Projekt" } },
valuation: "Wertermittlung",
testimonial: { label: "Kundenstimme", optional: true },
}),
defaults,
})
export type ImmobilienData = typeof immobilien.defaults

View file

@ -0,0 +1,13 @@
import { Hanken_Grotesk, Italiana, Montserrat, Tenor_Sans } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const italiana = Italiana({ subsets: ["latin"], weight: "400", variable: "--font-immo-italiana", preload: false })
const montserrat = Montserrat({ subsets: ["latin"], variable: "--font-immo-montserrat", preload: false })
const tenor = Tenor_Sans({ subsets: ["latin"], weight: "400", variable: "--font-immo-tenor", preload: false })
const hanken = Hanken_Grotesk({ subsets: ["latin"], variable: "--font-immo-hanken", preload: false })
export const fonts: Record<string, FontPair> = {
elegant: { className: `${italiana.variable} ${montserrat.variable}`, display: "var(--font-immo-italiana)", body: "var(--font-immo-montserrat)" },
modern: { className: `${tenor.variable} ${hanken.variable}`, display: "var(--font-immo-tenor)", body: "var(--font-immo-hanken)" },
}

View file

@ -0,0 +1,353 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import { ArrowRightIcon, BedDoubleIcon, ChevronLeftIcon, ChevronRightIcon, MapPinIcon, RulerIcon, SearchIcon } 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 { TImage } from "../shared/media"
import { ParallaxImage, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { immobilien, type ImmobilienData } 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 TYPES = ["Alle", "Kaufen", "Mieten"]
const ALL_PLACES = "Alle Lagen"
function Eyebrow({ children }: { children: string }) {
return (
<p className={cn(label, "mb-6 flex items-center gap-4 text-t-muted")}>
<span className="h-px w-12 bg-t-accent" />
{children}
</p>
)
}
export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
const { brand, hero, slides, listings, about, projects, valuation, testimonial } = content.data
const show = (key: string) => !content.hidden.includes(key)
const [type, setType] = useState("Alle")
const [place, setPlace] = useState(ALL_PLACES)
const places = [ALL_PLACES, ...new Set(listings.items.map((item) => item.location))]
const links = [
{ href: "#objekte", label: "Objekte", visible: show("listings") },
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
{ href: "#referenzen", label: "Referenzen", visible: show("projects") },
{ href: "#bewertung", label: "Wertermittlung", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={immobilien} content={content} fonts={fonts[content.fonts] ?? fonts.elegant}>
<Nav brand={brand} links={links} />
<HeroSlider hero={hero} slides={slides.items} />
{show("listings") && (
<div 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">
{TYPES.map((option) => (
<button
key={option}
type="button"
aria-pressed={type === option}
onClick={() => setType(option)}
className={cn("cursor-pointer px-4 py-3 text-sm transition-colors", type === option ? "bg-t-fg text-t-bg" : "hover:bg-t-surface")}
>
{option}
</button>
))}
</div>
<TSelect label="Lage" variant="line" value={place} onChange={(event) => setPlace(event.target.value)} options={places} />
<a href="#objekte" className="flex items-center justify-center gap-2 bg-t-accent px-6 py-3.5 text-sm text-t-accent-fg">
<SearchIcon className="size-4" /> Objekte anzeigen
</a>
</div>
</Reveal>
</div>
)}
{show("listings") && <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")}>
<ParallaxImage src={about.image} offset={50} className="aspect-[4/5]" sizes="(min-width: 768px) 45vw, 100vw" />
<div>
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
<Reveal delay={0.1}><p className="mt-8 text-lg leading-relaxed text-t-muted">{about.text}</p></Reveal>
<dl className="mt-12 grid grid-cols-3 border-t border-t-fg/15">
{about.facts.map((fact, i) => (
<Reveal key={i} delay={0.15 + i * 0.08} className={cn("pt-6", i > 0 && "border-l border-t-fg/15 pl-5")}>
<dd className={cn(heading, "text-4xl text-t-accent md:text-5xl")}>{fact.value}</dd>
<dt className="mt-2 text-xs text-t-muted">{fact.label}</dt>
</Reveal>
))}
</dl>
</div>
</section>
)}
{show("projects") && <ProjectIndex projects={projects} />}
<section id="bewertung" className="relative overflow-hidden py-24 md:py-36">
<TImage src={valuation.image} sizes="100vw" />
<div className="absolute inset-0 bg-black/60" />
<div className={cn(container, "relative grid items-center gap-12 text-white lg:grid-cols-2")}>
<div>
<p className={cn(label, "mb-6 flex items-center gap-4 text-white/70")}><span className="h-px w-12 bg-t-accent" />{valuation.eyebrow}</p>
<RevealWords text={valuation.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5.5vw,5rem)] leading-[1.02]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg leading-relaxed text-white/80">{valuation.text}</p></Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-5 bg-t-bg p-7 text-t-fg sm:grid-cols-2 md:p-10"
success={
<div className="py-8">
<p className={cn(heading, "text-4xl")}>Vielen <span className={em}>Dank.</span></p>
<p className="mt-3 text-t-muted">Wir melden uns innerhalb von 48 Stunden mit Ihrer Einschätzung.</p>
</div>
}
>
<TSelect label="Objektart" name="kind" variant="line" options={["Wohnung", "Haus", "Grundstück", "Gewerbe"]} />
<TField label="PLZ" name="zip" inputMode="numeric" required variant="line" />
<TField label="Wohnfläche in m²" name="area" inputMode="numeric" variant="line" />
<TField label="Baujahr" name="year" inputMode="numeric" variant="line" />
<TField label="Name" name="name" required autoComplete="name" variant="line" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="line" />
<button type="submit" className="flex cursor-pointer items-center justify-center gap-2 bg-t-fg py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg sm:col-span-2">
Kostenlos bewerten lassen <ArrowRightIcon className="size-4" />
</button>
</DemoForm>
</Reveal>
</div>
</section>
{show("testimonial") && (
<section 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>
)}
<footer className="border-t border-t-fg/10">
<div className={cn(container, "grid gap-10 py-14 text-sm text-t-muted md:grid-cols-4")}>
<div>
<p className={cn(heading, "text-2xl tracking-[0.3em] text-t-fg uppercase")}>{brand.name}</p>
<p className={cn(label, "mt-2")}>{brand.suffix}</p>
</div>
<p>{brand.address}</p>
<div className="grid gap-1">
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hover:text-t-fg">{brand.phone}</a>
<a href={`mailto:${brand.email}`} className="hover:text-t-fg">{brand.email}</a>
</div>
<div className="flex gap-6 md:justify-end">
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ brand, links }: { brand: ImmobilienData["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 transition-colors duration-500", scrolled ? "bg-t-bg/90 text-t-fg backdrop-blur-xl" : "text-white")}
>
<nav className={cn(container, "flex h-24 items-center justify-between gap-8")}>
<a href="#top" className="leading-none">
<span className={cn(heading, "block text-2xl tracking-[0.35em] uppercase")}>{brand.name}</span>
<span className={cn(label, "mt-1.5 block opacity-70")}>{brand.suffix}</span>
</a>
<div className={cn(label, "hidden items-center gap-9 lg:flex")}>
{links.map((link) => (
<a key={link.href} href={link.href} className="opacity-80 transition-opacity hover:opacity-100">{link.label}</a>
))}
</div>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hidden border border-current/40 px-5 py-3 text-sm transition-colors hover:border-t-accent hover:bg-t-accent hover:text-t-accent-fg sm:block">
{brand.phone}
</a>
</nav>
</motion.header>
)
}
function HeroSlider({ hero, slides }: { hero: ImmobilienData["hero"]; slides: ImmobilienData["slides"]["items"] }) {
const [index, setIndex] = useState(0)
const count = Math.max(slides.length, 1)
const current = index % count
const slide = slides[current]
useEffect(() => {
if (count < 2) return
const timer = setTimeout(() => setIndex((i) => (i + 1) % count), 6500)
return () => clearTimeout(timer)
}, [index, count])
return (
<section id="top" className="relative h-svh min-h-[640px] overflow-hidden bg-black text-white">
<AnimatePresence initial={false}>
<motion.div key={current} className="absolute inset-0" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 1.4, ease: "easeInOut" }}>
<motion.div className="absolute inset-0" initial={{ scale: 1.12 }} animate={{ scale: 1 }} transition={{ duration: 7.5, ease: "linear" }}>
<TImage src={slide?.image ?? ""} eager={current === 0} sizes="100vw" />
</motion.div>
</motion.div>
</AnimatePresence>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-black/45" />
<div className={cn(container, "relative flex h-full flex-col justify-end pb-28 md:pb-32")}>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.3 }} className={cn(label, "mb-6 text-white/75")}>
{hero.eyebrow}
</motion.p>
<RevealWords as="h1" onMount delay={0.4} text={hero.title} emClassName="italic text-t-accent" className={cn(heading, "text-[clamp(3.2rem,9vw,9.5rem)] leading-[0.95]")} />
<div className="mt-12 flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<a href="#objekte" className="group flex w-fit items-center gap-4 text-sm tracking-wide">
<span className="grid size-14 place-items-center rounded-full border border-white/40 transition-colors group-hover:border-t-accent group-hover:bg-t-accent group-hover:text-t-accent-fg">
<ArrowRightIcon className="size-5" />
</span>
{hero.cta}
</a>
{slide && (
<div className="flex items-end gap-8">
<AnimatePresence mode="wait">
<motion.div key={current} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.6, ease }} className="text-right">
<p className={cn(label, "text-white/60")}>{slide.location}</p>
<p className={cn(heading, "mt-1 text-2xl md:text-3xl")}>{slide.title}</p>
</motion.div>
</AnimatePresence>
<div className="flex gap-2">
<button type="button" aria-label="Vorheriges Objekt" onClick={() => setIndex((current - 1 + count) % count)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-white/30 hover:bg-white/10">
<ChevronLeftIcon className="size-5" />
</button>
<button type="button" aria-label="Nächstes Objekt" onClick={() => setIndex((current + 1) % count)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-white/30 hover:bg-white/10">
<ChevronRightIcon className="size-5" />
</button>
</div>
</div>
)}
</div>
<div className="mt-8 flex gap-2">
{slides.map((_, i) => (
<span key={i} className="h-0.5 flex-1 overflow-hidden bg-white/25">
{i === current && <motion.span key={index} className="block h-full bg-t-accent" initial={{ width: "0%" }} animate={{ width: "100%" }} transition={{ duration: 6.5, ease: "linear" }} />}
{i < current && <span className="block h-full w-full bg-white/70" />}
</span>
))}
</div>
</div>
</section>
)
}
function Listings({ listings, type, place, onType, onReset }: {
listings: ImmobilienData["listings"]
type: string
place: string
onType: (type: string) => void
onReset: () => void
}) {
const visible = listings.items.filter((item) => (type === "Alle" || item.type === type) && (place === ALL_PLACES || item.location === place))
return (
<section id="objekte" 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><Eyebrow>{listings.eyebrow}</Eyebrow></Reveal>
<RevealWords text={listings.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
</div>
<div className="flex gap-2">
{TYPES.map((option) => (
<button key={option} type="button" aria-pressed={type === option} onClick={() => onType(option)} className={cn(label, "cursor-pointer border px-4 py-2.5 transition-colors", type === option ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/20 hover:border-t-fg")}>
{option}
</button>
))}
</div>
</div>
<motion.div layout className="mt-14 grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
<AnimatePresence mode="popLayout">
{visible.map((item) => (
<motion.article
layout
key={item.title}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.5, ease }}
className="group"
>
<div className="relative aspect-[4/3] overflow-hidden bg-t-surface">
<TImage src={item.image} alt={item.title} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<span className={cn(label, "absolute top-4 left-4 bg-t-bg/90 px-3 py-1.5 text-t-fg")}>{item.type}</span>
<p className={cn(heading, "absolute bottom-4 left-4 text-2xl text-white")}>{item.price}</p>
</div>
<h3 className={cn(heading, "mt-5 text-2xl")}>{item.title}</h3>
<p className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm text-t-muted">
<span className="flex items-center gap-1.5"><RulerIcon className="size-4" /> {item.sqm} m²</span>
<span className="flex items-center gap-1.5"><BedDoubleIcon className="size-4" /> {item.rooms} Zimmer</span>
<span className="flex items-center gap-1.5"><MapPinIcon className="size-4" /> {item.location}</span>
</p>
</motion.article>
))}
</AnimatePresence>
</motion.div>
{visible.length === 0 && (
<p className="mt-10 text-t-muted">
Aktuell keine passenden Objekte.{" "}
<button type="button" onClick={onReset} className="cursor-pointer text-t-accent underline-offset-4 hover:underline">Alle Lagen zeigen</button>
</p>
)}
</section>
)
}
function ProjectIndex({ projects }: { projects: ImmobilienData["projects"] }) {
const [active, setActive] = useState(0)
const current = projects.items[Math.min(active, projects.items.length - 1)]
return (
<section id="referenzen" className="bg-t-surface py-24 md:py-36">
<div className={container}>
<Reveal><Eyebrow>{projects.eyebrow}</Eyebrow></Reveal>
<RevealWords text={projects.title} emClassName={em} className={cn(heading, "max-w-3xl text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
<div className="mt-14 grid gap-10 lg:grid-cols-[1fr_440px]">
<ul className="border-t border-t-fg/15">
{projects.items.map((project, i) => (
<li key={i} onMouseEnter={() => setActive(i)} className="group border-b border-t-fg/15">
<div className="flex items-baseline gap-6 py-6 transition-[padding] duration-500 md:group-hover:pl-4">
<span className={cn(label, "w-12 text-t-accent")}>{project.year}</span>
<span className={cn(heading, "flex-1 text-3xl transition-colors md:text-5xl", active === i ? "text-t-fg" : "text-t-fg/45")}>{project.title}</span>
<span className="hidden text-sm text-t-muted sm:block">{project.place}</span>
</div>
<div className="relative mb-6 aspect-[16/10] overflow-hidden lg:hidden">
<TImage src={project.image} alt={project.title} sizes="100vw" />
</div>
</li>
))}
</ul>
<div className="hidden lg:block">
<div className="sticky top-32 aspect-[4/5] overflow-hidden bg-t-bg">
<AnimatePresence initial={false}>
<motion.div key={active} className="absolute inset-0" initial={{ opacity: 0, scale: 1.06 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.7, ease }}>
<TImage src={current?.image ?? ""} alt={current?.title} sizes="440px" />
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,108 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "SonnenWerk", suffix: "Solar & Speicher", phone: "0800 123 45 67" },
offer: { badge: "Herbstaktion", deadline: "2026-11-30", deadlineLabel: "Aktion endet in" },
hero: {
title: "Solaranlage zum\n*Aktionspreis.*",
text: "Bis zu 70 % weniger Stromkosten – mit Speicher, Montage und 25 Jahren Garantie. Nur für die ersten 50 Anfragen im Aktionszeitraum.",
bullets: "Kostenlose Vor-Ort-Beratung\nFestpreis inklusive Montage\nFinanzierung ab 0 % möglich",
image: unsplash("1613665813446-82a78c468a1d", 1400),
rating: "4,9",
reviews: "1.240 Bewertungen",
},
quiz: {
title: "Jetzt Angebot sichern",
text: "In 30 Sekunden zum persönlichen Angebot",
questions: [
{ question: "Welche Art von Gebäude?", options: "Einfamilienhaus | Doppelhaushälfte | Mehrfamilienhaus | Gewerbe" },
{ question: "Wie hoch ist Ihr Stromverbrauch?", options: "Unter 3.000 kWh | 3.000–5.000 kWh | Über 5.000 kWh | Weiß nicht" },
{ question: "Wann soll es losgehen?", options: "So schnell wie möglich | In 3–6 Monaten | Ich informiere mich nur" },
],
success: "Geschafft! Wir melden uns innerhalb von 24 Stunden mit Ihrem persönlichen Angebot.",
},
trust: { items: [{ label: "Geprüfter Meisterbetrieb" }, { label: "25 Jahre Garantie" }, { label: "Förderfähig" }, { label: "Montage in einem Tag" }] },
benefits: {
title: "Warum *SonnenWerk?*",
items: [
{ title: "Alles aus einer Hand", text: "Planung, Montage und Anmeldung beim Netzbetreiber – wir kümmern uns um alles." },
{ title: "Festpreis-Garantie", text: "Keine versteckten Kosten. Der Preis im Angebot ist der Preis, den Sie zahlen." },
{ title: "Montage in einem Tag", text: "Unser eigenes Team installiert Ihre Anlage in der Regel an nur einem Tag." },
{ title: "Regionaler Service", text: "Feste Ansprechpartner:innen aus Ihrer Region – auch nach der Installation." },
],
},
calculator: {
title: "Was können Sie *sparen?*",
text: "Ziehen Sie den Regler auf Ihre monatliche Stromrechnung.",
min: "50",
max: "400",
factor: "0,7",
},
steps: {
title: "So *einfach* geht's",
items: [
{ title: "Anfrage", text: "30-Sekunden-Check ausfüllen." },
{ title: "Beratung", text: "Kostenloser Termin vor Ort oder per Video." },
{ title: "Montage", text: "Installation durch unser eigenes Team." },
{ title: "Sparen", text: "Ab dem ersten Sonnentag." },
],
},
reviews: {
title: "Über 1.200 *zufriedene* Kund:innen",
items: [
{ quote: "Von der Beratung bis zur Montage absolut professionell. Unsere Stromrechnung hat sich mehr als halbiert.", author: "Familie Hoffmann, Kassel" },
{ quote: "Schnell, sauber, freundlich. Die Montage hat genau einen Tag gedauert.", author: "Thomas R., Göttingen" },
{ quote: "Endlich ein Anbieter, der ehrlich berät und nichts aufschwatzt.", author: "Sabine M., Fulda" },
{ quote: "Die App zeigt live, wie viel Strom wir erzeugen – macht richtig Spaß.", author: "Jan K., Marburg" },
],
},
guarantee: {
title: "Unsere *Bestpreis*-Garantie",
text: "Finden Sie innerhalb von 14 Tagen ein vergleichbares Angebot günstiger, passen wir unseren Preis an.",
},
faq: {
items: [
{ question: "Lohnt sich Solar für mein Haus?", answer: "In den allermeisten Fällen ja. Entscheidend sind Dachfläche, Ausrichtung und Verbrauch – genau das prüfen wir im kostenlosen Check." },
{ question: "Welche Förderungen gibt es?", answer: "Je nach Region gibt es Zuschüsse und günstige Kredite. Wir prüfen alle Fördertöpfe für Sie und übernehmen die Anträge." },
{ question: "Wie lange dauert es bis zur Installation?", answer: "Nach der Beratung meist vier bis acht Wochen. Die Montage selbst dauert in der Regel nur einen Tag." },
{ question: "Was passiert bei Defekten?", answer: "Sie erhalten 25 Jahre Garantie auf die Module und einen festen Service-Kontakt. Störungen beheben wir in der Regel innerhalb von 48 Stunden." },
],
},
}
export const kampagne = defineTemplate({
key: "kampagne",
name: "Kampagne",
industry: "Kampagnen-Landingpage",
description: "Auf Conversion gebaut: Countdown zum Aktionsende, Quiz-Funnel zur Lead-Erfassung, Ersparnis-Rechner, Social Proof und mobile CTA-Leiste.",
cover: unsplash("1613665813446-82a78c468a1d", 1200),
samples: {
inquiry: "Hallo, wir haben ein Einfamilienhaus mit Süddach (etwa 60 m²) und verbrauchen rund 4.500 kWh im Jahr. Was würde eine PV-Anlage mit Speicher bei Ihnen kosten, und gibt es Förderungen? Gruß, Familie Wolf",
topic: "Letzte Chance: Die Herbstaktion endet in sieben Tagen",
},
palettes: [
{ key: "solar", label: "Solar", bg: "#fffdf6", fg: "#132029", muted: "#5d6b74", surface: "#fff3d6", accent: "#ffb000" },
{ key: "gruen", label: "Grün", bg: "#f5faf6", fg: "#0f2417", muted: "#5b7062", surface: "#e2f1e6", accent: "#16a34a" },
{ key: "blau", label: "Blau", bg: "#f5f8ff", fg: "#0b1a38", muted: "#5a6680", surface: "#e3eafb", accent: "#2563eb" },
],
fonts: [
{ key: "stark", label: "Archivo Black & Archivo" },
{ key: "rund", label: "Outfit" },
],
sections: describeSections(defaults, {
brand: "Anbieter",
offer: "Aktion & Countdown",
hero: "Startbereich",
quiz: { label: "Quiz-Funnel", items: { questions: "Frage" } },
trust: { label: "Vertrauens-Leiste", optional: true, items: { items: "Merkmal" } },
benefits: { label: "Vorteile", optional: true, items: { items: "Vorteil" } },
calculator: { label: "Ersparnis-Rechner", optional: true },
steps: { label: "Ablauf", optional: true, items: { items: "Schritt" } },
reviews: { label: "Bewertungen", optional: true, items: { items: "Bewertung" } },
guarantee: { label: "Garantie", optional: true },
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
}),
defaults,
})
export type KampagneData = typeof kampagne.defaults

View file

@ -0,0 +1,12 @@
import { Archivo, Archivo_Black, Outfit } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const archivoBlack = Archivo_Black({ subsets: ["latin"], weight: "400", variable: "--font-kampagne-black", preload: false })
const archivo = Archivo({ subsets: ["latin"], variable: "--font-kampagne-archivo", preload: false })
const outfit = Outfit({ subsets: ["latin"], variable: "--font-kampagne-outfit", preload: false })
export const fonts: Record<string, FontPair> = {
stark: { className: `${archivoBlack.variable} ${archivo.variable}`, display: "var(--font-kampagne-black)", body: "var(--font-kampagne-archivo)" },
rund: { className: outfit.variable, display: "var(--font-kampagne-outfit)", body: "var(--font-kampagne-outfit)" },
}

View file

@ -0,0 +1,412 @@
"use client"
import { motion } from "motion/react"
import { useState } from "react"
import {
ArrowLeftIcon,
ArrowRightIcon,
BadgeCheckIcon,
CheckIcon,
CircleCheckIcon,
ClockIcon,
LeafIcon,
PhoneIcon,
PiggyBankIcon,
ShieldCheckIcon,
StarIcon,
SunIcon,
WrenchIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { TField } from "../shared/fields"
import { splitList, useCountdown } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, Marquee, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { kampagne, type KampagneData } 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 tracking-[-0.03em]"
const trustIcons = [BadgeCheckIcon, ShieldCheckIcon, PiggyBankIcon, ClockIcon]
const benefitIcons = [WrenchIcon, BadgeCheckIcon, ClockIcon, LeafIcon]
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })
function Stars({ className }: { className?: string }) {
return (
<span className={cn("flex text-amber-400", className)}>
{Array.from({ length: 5 }, (_, i) => (
<StarIcon key={i} className="size-4 fill-current" />
))}
</span>
)
}
function Countdown({ deadline, compact }: { deadline: string; compact?: boolean }) {
const time = useCountdown(deadline)
const parts = [
{ value: time?.days, label: "Tage" },
{ value: time?.hours, label: "Std" },
{ value: time?.minutes, label: "Min" },
{ value: time?.seconds, label: "Sek" },
]
const pad = (value?: number) => (value === undefined ? "--" : String(value).padStart(2, "0"))
if (compact) {
return <span className="font-mono tabular-nums">{time?.done ? "beendet" : `${pad(time?.days)}T ${pad(time?.hours)}:${pad(time?.minutes)}:${pad(time?.seconds)}`}</span>
}
return (
<div className="flex gap-2">
{parts.map((part) => (
<div key={part.label} className="w-16 rounded-2xl bg-t-fg py-3 text-center text-t-bg md:w-20">
<p className={cn(heading, "text-2xl tabular-nums md:text-3xl")}>{pad(part.value)}</p>
<p className="text-[10px] uppercase tracking-wider opacity-60">{part.label}</p>
</div>
))}
</div>
)
}
export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
const { brand, offer, hero, quiz, trust, benefits, calculator, steps, reviews, guarantee, faq } = content.data
const show = (key: string) => !content.hidden.includes(key)
return (
<TemplateShell def={kampagne} content={content} fonts={fonts[content.fonts] ?? fonts.stark} className="max-md:[--t-safe-bottom:4.75rem]">
<div className="bg-t-fg py-2.5 text-center text-sm text-t-bg">
<span className="font-semibold text-t-accent">{offer.badge}:</span> {offer.deadlineLabel} <Countdown deadline={offer.deadline} compact />
</div>
<Nav brand={brand} cta={quiz.title} />
<section id="top" className={cn(container, "grid items-start gap-12 pt-10 pb-16 md:pt-16 lg:grid-cols-[1.1fr_0.9fr]")}>
<div className="lg:pt-6">
<motion.span initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className="inline-flex items-center gap-2 rounded-full bg-t-accent px-4 py-1.5 text-sm font-bold text-t-accent-fg">
<SunIcon className="size-4" /> {offer.badge} · nur bis {offer.deadline.split("-").reverse().join(".")}
</motion.span>
<RevealWords as="h1" onMount delay={0.1} text={hero.title} emClassName={em} className={cn(heading, "mt-6 text-[clamp(2.75rem,6.5vw,5.5rem)] leading-[0.98]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.5 }}>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
<ul className="mt-6 space-y-2.5">
{splitList(hero.bullets, "\n").map((bullet) => (
<li key={bullet} className="flex items-center gap-3 font-medium">
<span className="grid size-6 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<CheckIcon className="size-3.5" />
</span>
{bullet}
</li>
))}
</ul>
<div className="mt-8 flex items-center gap-3 text-sm">
<Stars />
<span className="font-bold">{hero.rating}</span>
<span className="text-t-muted">aus {hero.reviews}</span>
</div>
<div className="mt-8">
<p className="mb-3 text-sm font-semibold">{offer.deadlineLabel}</p>
<Countdown deadline={offer.deadline} />
</div>
</motion.div>
</div>
<motion.div id="angebot" initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 0.3 }} className="scroll-mt-6">
<Quiz quiz={quiz} image={hero.image} />
</motion.div>
</section>
{show("trust") && (
<section 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]
return (
<li key={i} className="flex items-center gap-3 text-sm font-semibold">
<Icon className="size-6 shrink-0 text-t-accent" /> {item.label}
</li>
)
})}
</ul>
</section>
)}
{show("benefits") && (
<section 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) => {
const Icon = benefitIcons[i % benefitIcons.length]
return (
<Reveal key={i} delay={(i % 2) * 0.08}>
<article className="flex h-full gap-5 rounded-3xl border border-t-fg/10 p-7">
<span className="grid size-14 shrink-0 place-items-center rounded-2xl bg-t-accent text-t-accent-fg">
<Icon className="size-6" />
</span>
<div>
<h3 className={cn(heading, "text-xl")}>{item.title}</h3>
<p className="mt-2 leading-relaxed text-t-muted">{item.text}</p>
</div>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("calculator") && <Calculator calculator={calculator} />}
{show("steps") && (
<section 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) => (
<Reveal key={i} delay={i * 0.1}>
<li className="relative h-full rounded-3xl bg-t-surface p-6">
<span className={cn(heading, "text-5xl text-t-accent")}>{i + 1}</span>
<p className={cn(heading, "mt-4 text-xl")}>{step.title}</p>
<p className="mt-1 text-t-muted">{step.text}</p>
</li>
</Reveal>
))}
</ol>
</section>
)}
{show("reviews") && (
<section 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]")} />
<p className="mt-3 text-t-muted">Ø {hero.rating} von 5 Sternen · {hero.reviews}</p>
</div>
<div className="mt-12">
<Marquee duration={45}>
{reviews.items.map((review, i) => (
<figure key={i} className="mx-3 w-80 shrink-0 rounded-3xl bg-t-bg p-6 shadow-sm">
<Stars />
<blockquote className="mt-3 leading-relaxed">„{review.quote}“</blockquote>
<figcaption className="mt-4 text-sm font-semibold text-t-muted">{review.author}</figcaption>
</figure>
))}
</Marquee>
</div>
</section>
)}
{show("guarantee") && (
<section 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" />
<div>
<RevealWords text={guarantee.title} emClassName={em} className={cn(heading, "text-3xl")} />
<p className="mt-2 text-t-muted">{guarantee.text}</p>
</div>
</div>
</Reveal>
</section>
)}
{show("faq") && (
<section 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>
)}
<section className={cn(container, "pb-24")}>
<div className="rounded-[2.5rem] bg-t-fg px-6 py-16 text-center text-t-bg md:py-20">
<p className={cn(heading, "mx-auto max-w-2xl text-[clamp(2rem,4.5vw,3.5rem)] leading-[1.05]")}>Sichern Sie sich den Aktionspreis</p>
<p className="mt-4 opacity-70">{offer.deadlineLabel} <Countdown deadline={offer.deadline} compact /></p>
<a href="#angebot" className="mt-8 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-bold text-t-accent-fg transition-transform hover:scale-105">
{quiz.title} <ArrowRightIcon className="size-4" />
</a>
</div>
</section>
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p>{brand.name} · {brand.suffix} · {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>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-t-fg/10 bg-t-bg/95 p-3 backdrop-blur md:hidden">
<a href="#angebot" className="flex items-center justify-between rounded-full bg-t-accent px-5 py-3.5 font-bold text-t-accent-fg">
{quiz.title}
<span className="text-xs font-medium opacity-80"><Countdown deadline={offer.deadline} compact /></span>
</a>
</div>
</TemplateShell>
)
}
function Nav({ brand, cta }: { brand: KampagneData["brand"]; cta: string }) {
const { scrolled } = useScrollNav()
return (
<header className={cn("sticky top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent bg-t-bg")}>
<nav className={cn(container, "flex h-18 items-center justify-between gap-4 py-3")}>
<a href="#top" className="flex items-center gap-2.5">
<span className="grid size-10 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<SunIcon className="size-5" />
</span>
<span className="leading-tight">
<span className={cn(heading, "block text-lg")}>{brand.name}</span>
<span className="text-xs text-t-muted">{brand.suffix}</span>
</span>
</a>
<div className="flex items-center gap-3">
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hidden items-center gap-2 text-sm font-semibold md:flex">
<PhoneIcon className="size-4 text-t-accent" /> {brand.phone}
</a>
<a href="#angebot" className="hidden rounded-full bg-t-fg px-5 py-2.5 text-sm font-bold text-t-bg sm:block">{cta}</a>
</div>
</nav>
</header>
)
}
function Quiz({ quiz, image }: { quiz: KampagneData["quiz"]; image: string }) {
const questions = quiz.questions.filter((item) => item.question.trim())
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState<string[]>([])
const [done, setDone] = useState(false)
const total = questions.length + 1
const progress = done ? 100 : (step / total) * 100
return (
<div className="overflow-hidden rounded-[2rem] bg-t-bg shadow-[0_40px_80px_-30px_rgba(0,0,0,0.35)] ring-1 ring-t-fg/10">
<div className="relative h-36 overflow-hidden">
<TImage src={image} eager sizes="(min-width: 1024px) 40vw, 100vw" />
<div className="absolute inset-0 bg-gradient-to-t from-black/75 to-black/10" />
<div className="absolute inset-x-6 bottom-4 text-white">
<p className={cn(heading, "text-2xl")}>{quiz.title}</p>
<p className="text-sm opacity-80">{quiz.text}</p>
</div>
</div>
<div className="p-6 md:p-8">
<div className="mb-2 flex justify-between text-xs font-semibold text-t-muted">
<span>{done ? "Fertig" : `Schritt ${step + 1} von ${total}`}</span>
<span>{Math.round(progress)} %</span>
</div>
<div className="mb-7 h-2 overflow-hidden rounded-full bg-t-surface">
<motion.div className="h-full rounded-full bg-t-accent" animate={{ width: `${progress}%` }} transition={{ duration: 0.5, ease }} />
</div>
{done ? (
<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>
</motion.div>
) : (
<StepPanel step={step}>
{step < questions.length ? (
<div>
<p className={cn(heading, "text-xl md:text-2xl")}>{questions[step].question}</p>
<div className="mt-5 grid gap-2.5 sm:grid-cols-2">
{splitList(questions[step].options, "|").map((option) => (
<button
key={option}
type="button"
onClick={() => {
setAnswers((list) => [...list.slice(0, step), option])
setStep(step + 1)
}}
className={cn(
"cursor-pointer rounded-2xl border-2 px-4 py-4 text-left font-semibold transition-all hover:-translate-y-0.5 hover:border-t-accent",
answers[step] === option ? "border-t-accent bg-t-surface" : "border-t-fg/10"
)}
>
{option}
</button>
))}
</div>
</div>
) : (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
setDone(true)
}}
>
<p className={cn(heading, "text-xl md:text-2xl")}>Wohin dürfen wir Ihr Angebot schicken?</p>
<TField label="Name" name="name" required autoComplete="name" />
<div className="grid grid-cols-[7rem_1fr] gap-3">
<TField label="PLZ" name="zip" required inputMode="numeric" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" />
</div>
<button type="submit" className="mt-1 inline-flex cursor-pointer items-center justify-center gap-2 rounded-full bg-t-accent py-4 font-bold text-t-accent-fg transition-transform hover:scale-[1.02]">
Kostenloses Angebot anfordern <ArrowRightIcon className="size-4" />
</button>
<p className="text-center text-xs text-t-muted">100 % kostenlos und unverbindlich</p>
</form>
)}
</StepPanel>
)}
{!done && step > 0 && (
<button type="button" onClick={() => setStep(step - 1)} className="mt-5 flex cursor-pointer items-center gap-1.5 text-sm text-t-muted hover:text-t-fg">
<ArrowLeftIcon className="size-4" /> Zurück
</button>
)}
</div>
</div>
)
}
function Calculator({ calculator }: { calculator: KampagneData["calculator"] }) {
const min = Number(calculator.min) || 50
const max = Math.max(min + 10, Number(calculator.max) || 400)
const factor = Number(calculator.factor.replace(",", ".")) || 0.7
const [bill, setBill] = useState(Math.round((min + max) / 2 / 10) * 10)
const yearly = bill * 12 * factor
return (
<section className="bg-t-fg py-20 text-t-bg md:py-28">
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-2")}>
<div>
<RevealWords text={calculator.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<p className="mt-4 text-lg opacity-70">{calculator.text}</p>
<div className="mt-10">
<div className="flex items-end justify-between">
<span className="text-sm opacity-70">Stromrechnung pro Monat</span>
<span className={cn(heading, "text-4xl")}>{euro.format(bill)}</span>
</div>
<input
type="range"
min={min}
max={max}
step={10}
value={bill}
onChange={(event) => setBill(Number(event.target.value))}
aria-label="Monatliche Stromrechnung"
className="mt-4 w-full cursor-pointer accent-[var(--t-accent)]"
/>
<div className="mt-1 flex justify-between text-xs opacity-50">
<span>{euro.format(min)}</span>
<span>{euro.format(max)}</span>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{[
{ label: "Ersparnis pro Jahr", value: yearly },
{ label: "Ersparnis in 20 Jahren", value: yearly * 20 },
].map((item) => (
<div key={item.label} className="rounded-3xl bg-t-bg/10 p-7">
<p className="text-sm opacity-70">{item.label}</p>
<motion.p key={Math.round(item.value)} initial={{ opacity: 0.4, y: 6 }} animate={{ opacity: 1, y: 0 }} className={cn(heading, "mt-2 text-4xl text-t-accent md:text-5xl")}>
{euro.format(item.value)}
</motion.p>
</div>
))}
<p className="text-xs opacity-50 sm:col-span-2">Unverbindliche Beispielrechnung. Die genaue Ersparnis ermitteln wir im kostenlosen Check.</p>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,224 @@
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
export const kanzlei = defineTemplate({
key: "kanzlei",
name: "Kanzlei",
industry: "Kanzlei, Beratung & Praxis",
description: "Ruhig und hochwertig: viel Weißraum, Serifenschrift, Rechtsgebiete als Akkordeon, Team, Ablauf und Terminanfrage.",
cover: unsplash("1505664194779-8beaceb93744", 1200),
samples: {
inquiry: "Sehr geehrte Damen und Herren, mir wurde nach zwölf Jahren Betriebszugehörigkeit betriebsbedingt gekündigt. Steht mir eine Abfindung zu, und was kostet eine Erstberatung bei Ihnen? Mit freundlichen Grüßen, Michael Wagner",
topic: "Kündigung erhalten? Fünf Dinge, die Sie jetzt wissen sollten",
},
palettes: [
{ key: "papier", label: "Papier", bg: "#f7f5f0", fg: "#18212f", muted: "#666d7a", surface: "#eeeae2", accent: "#94744a" },
{ key: "tinte", label: "Tinte", dark: true, bg: "#0f1724", fg: "#eceef2", muted: "#8e98a9", surface: "#162032", accent: "#c8a774" },
{ key: "praxis", label: "Praxis", bg: "#f5f8f7", fg: "#12302d", muted: "#5a716e", surface: "#e6eeec", accent: "#2a8a78" },
],
fonts: [
{ key: "klassisch", label: "Cormorant Garamond & Inter" },
{ key: "zeitgemaess", label: "Source Serif & Source Sans" },
],
sections: [
{
key: "brand",
label: "Marke",
fields: [
{ key: "name", label: "Name" },
{ key: "suffix", label: "Zusatz" },
],
},
{
key: "hero",
label: "Startbereich",
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "cta", label: "Button" },
{ key: "image", label: "Bild", type: "image" },
{
key: "facts",
label: "Eckdaten",
type: "list",
itemLabel: "Eckdatum",
fields: [
{ key: "value", label: "Wert" },
{ key: "label", label: "Beschriftung" },
],
},
],
},
{
key: "areas",
label: "Kompetenzen",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{
key: "items",
label: "Bereiche",
type: "list",
itemLabel: "Bereich",
fields: [
{ key: "title", label: "Titel" },
{ key: "text", label: "Beschreibung", type: "textarea" },
],
},
],
},
{
key: "about",
label: "Über uns",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "lead", label: "Einleitung", type: "textarea" },
{ key: "text", label: "Text", type: "textarea" },
{ key: "image", label: "Bild", type: "image" },
],
},
{
key: "team",
label: "Team",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "members",
label: "Personen",
type: "list",
itemLabel: "Person",
fields: [
{ key: "name", label: "Name" },
{ key: "role", label: "Funktion" },
{ key: "image", label: "Foto", type: "image" },
],
},
],
},
{
key: "steps",
label: "Ablauf",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "items",
label: "Schritte",
type: "list",
itemLabel: "Schritt",
fields: [
{ key: "title", label: "Titel" },
{ key: "text", label: "Beschreibung", type: "textarea" },
],
},
],
},
{
key: "quotes",
label: "Stimmen",
optional: true,
fields: [
{
key: "items",
label: "Stimmen",
type: "list",
itemLabel: "Stimme",
fields: [
{ key: "text", label: "Zitat", type: "textarea" },
{ key: "author", label: "Von" },
],
},
],
},
{
key: "contact",
label: "Kontakt",
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "address", label: "Adresse", type: "textarea" },
{ key: "phone", label: "Telefon" },
{ key: "email", label: "E-Mail" },
{ key: "hours", label: "Bürozeiten" },
],
},
],
defaults: {
brand: { name: "Hartmann & Partner", suffix: "Rechtsanwälte · Fachanwälte" },
hero: {
eyebrow: "Arbeits-, Familien- und Erbrecht",
title: "Klarheit in\n*rechtlichen* Fragen.",
text: "Seit über 25 Jahren begleiten wir Privatpersonen und Unternehmen – mit fundierter Beratung, klarer Sprache und dem nötigen Gespür für das, was Ihnen wichtig ist.",
cta: "Termin vereinbaren",
image: unsplash("1505664194779-8beaceb93744", 1400),
facts: [
{ value: "1998", label: "gegründet" },
{ value: "12", label: "Fachanwältinnen & Fachanwälte" },
{ value: "3.500+", label: "betreute Mandate" },
],
},
areas: {
eyebrow: "Kompetenzen",
title: "Unsere\n*Rechtsgebiete*",
text: "Spezialisiert statt Bauchladen: Jedes Mandat betreut eine Fachanwältin oder ein Fachanwalt für das jeweilige Gebiet.",
items: [
{ title: "Arbeitsrecht", text: "Kündigung, Abfindung, Arbeits- oder Aufhebungsvertrag – wir vertreten Arbeitnehmer wie Arbeitgeber, außergerichtlich und vor Gericht." },
{ title: "Familienrecht", text: "Trennung, Scheidung, Unterhalt und Sorgerecht. Wir begleiten Sie sachlich und mit dem nötigen Feingefühl." },
{ title: "Erbrecht", text: "Testament, Erbvertrag, Pflichtteil und Nachlassstreit – vorausschauend gestalten statt später streiten." },
{ title: "Mietrecht", text: "Ob Mieter oder Vermieter: Wir klären Mietminderung, Nebenkosten, Eigenbedarf und Räumung." },
{ title: "Vertragsrecht", text: "Wir prüfen, gestalten und verhandeln Verträge, damit Sie Risiken früh erkennen." },
],
},
about: {
eyebrow: "Über uns",
title: "Recht ist\n*Vertrauenssache.*",
lead: "Wir nehmen uns Zeit, Ihren Fall wirklich zu verstehen. Erst dann entwickeln wir eine Strategie – ehrlich in der Einschätzung und konsequent in der Umsetzung.",
text: "Ob außergerichtliche Einigung oder Prozess: Sie wissen bei uns jederzeit, wo Ihr Fall steht und welche Kosten auf Sie zukommen. Kurze Wege, persönliche Erreichbarkeit und verständliche Sprache sind für uns selbstverständlich.",
image: unsplash("1497366811353-6870744d04b2", 1600),
},
team: {
eyebrow: "Team",
title: "Ihre *Ansprechpartner*",
members: [
{ name: "Dr. Katharina Hartmann", role: "Fachanwältin für Arbeitsrecht", image: unsplash("1573496359142-b8d87734a5a2", 900) },
{ name: "Michael Berger", role: "Fachanwalt für Familienrecht", image: unsplash("1560250097-0b93528c311a", 900) },
{ name: "Julia Neumann", role: "Fachanwältin für Erbrecht", image: unsplash("1438761681033-6461ffad8d80", 900) },
{ name: "Thomas Wagner", role: "Rechtsanwalt, Mietrecht", image: unsplash("1519085360753-af0119f7cbe7", 900) },
],
},
steps: {
eyebrow: "Ihr Weg zu uns",
title: "In drei Schritten\nzur *Lösung.*",
items: [
{ title: "Erstberatung", text: "Sie schildern Ihr Anliegen, wir geben eine ehrliche Einschätzung – auch zu den Kosten." },
{ title: "Strategie", text: "Wir prüfen Ihre Unterlagen und entwickeln gemeinsam mit Ihnen das weitere Vorgehen." },
{ title: "Umsetzung", text: "Wir vertreten Ihre Interessen mit Nachdruck – außergerichtlich oder vor Gericht." },
],
},
quotes: {
items: [
{ text: "Kompetent, menschlich und immer erreichbar. Ich habe mich in einer schwierigen Zeit sehr gut aufgehoben gefühlt.", author: "Mandantin, Familienrecht" },
{ text: "Klare Einschätzung, realistische Erwartungen und am Ende ein besseres Ergebnis als erhofft.", author: "Mandant, Arbeitsrecht" },
],
},
contact: {
eyebrow: "Kontakt",
title: "Vereinbaren Sie ein\n*Erstgespräch.*",
text: "Rufen Sie uns an oder schreiben Sie uns. Wir melden uns innerhalb eines Werktags.",
address: "Theaterstraße 15\n30159 Hannover",
phone: "0511 123 45 67",
email: "kanzlei@hartmann-partner.de",
hours: "Mo – Fr · 8:30 – 18 Uhr",
},
},
})
export type KanzleiData = typeof kanzlei.defaults

View file

@ -0,0 +1,20 @@
import { Cormorant_Garamond, Source_Sans_3, Source_Serif_4 } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts. "Klassisch" nutzt für Fließtext das Inter des Portals.
const cormorant = Cormorant_Garamond({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-cormorant", preload: false })
const sourceSerif = Source_Serif_4({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-source-serif", preload: false })
const sourceSans = Source_Sans_3({ subsets: ["latin"], variable: "--font-source-sans", preload: false })
export const fonts: Record<string, FontPair> = {
klassisch: {
className: cormorant.variable,
display: "var(--font-cormorant)",
body: "var(--font-inter)",
},
zeitgemaess: {
className: `${sourceSerif.variable} ${sourceSans.variable}`,
display: "var(--font-source-serif)",
body: "var(--font-source-sans)",
},
}

View file

@ -0,0 +1,370 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState, type InputHTMLAttributes } from "react"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { ParallaxImage, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TImage } from "../shared/media"
import { TemplateShell } from "../shared/shell"
import { kanzlei, type KanzleiData } from "./definition"
import { fonts } from "./fonts"
const em = "italic text-t-accent"
const container = "mx-auto w-full max-w-[1320px] px-6 md:px-10"
const heading = "font-t-display font-medium tracking-[-0.015em]"
function Eyebrow({ children, className }: { children: string; className?: string }) {
return (
<p className={cn("mb-7 flex items-center gap-4 text-[11px] font-medium uppercase tracking-[0.28em] text-t-muted", className)}>
<span className="h-px w-10 bg-t-accent" />
{children}
</p>
)
}
export function KanzleiTemplate({ content }: TemplateProps<KanzleiData>) {
const { brand, hero, areas, about, team, steps, quotes, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#kompetenzen", label: "Kompetenzen", visible: show("areas") },
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
{ href: "#team", label: "Team", visible: show("team") },
{ href: "#kontakt", label: "Kontakt", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={kanzlei} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
<Nav brand={brand} cta={hero.cta} links={links} />
<section id="top" className={cn(container, "pt-36 pb-20 md:pt-48 md:pb-28")}>
<div className="grid items-end gap-14 md:grid-cols-12 md:gap-10">
<div className="md:col-span-7">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<RevealWords
as="h1"
onMount
delay={0.3}
text={hero.title}
emClassName={em}
className={cn(heading, "text-[clamp(3.1rem,7.6vw,7.75rem)] leading-[0.98]")}
/>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, ease, delay: 0.9 }}>
<p className="mt-10 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-4">
<a
href="#kontakt"
className="group inline-flex items-center gap-3 bg-t-fg px-7 py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg"
>
{hero.cta}
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</a>
<a href={`tel:${contact.phone.replace(/\s/g, "")}`} className="text-sm text-t-muted transition-colors hover:text-t-fg">
oder anrufen: <span className="text-t-fg">{contact.phone}</span>
</a>
</div>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.4, ease, delay: 0.4 }}
className="md:col-span-4 md:col-start-9"
>
<ParallaxImage src={hero.image} eager offset={40} className="aspect-[4/5] rounded-t-full" sizes="(min-width: 768px) 32vw, 100vw" />
</motion.div>
</div>
<dl className="mt-20 grid border-t border-t-fg/15 sm:grid-cols-3 md:mt-28">
{hero.facts.map((fact, i) => (
<Reveal key={i} delay={i * 0.1} className={cn("pt-8 pb-2 sm:px-8 sm:first:pl-0", i > 0 && "sm:border-l sm:border-t-fg/15")}>
<dt className="sr-only">{fact.label}</dt>
<dd className={cn(heading, "text-5xl md:text-6xl")}>{fact.value}</dd>
<dd className="mt-2 text-sm text-t-muted">{fact.label}</dd>
</Reveal>
))}
</dl>
</section>
{show("areas") && <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")}>
<div className="md:col-span-5">
<div className="md:sticky md:top-36">
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
</div>
</div>
<div className="flex flex-col gap-12 md:col-span-6 md:col-start-7">
<ParallaxImage src={about.image} offset={50} className="aspect-[4/3]" sizes="(min-width: 768px) 50vw, 100vw" />
<Reveal>
<p className={cn(heading, "text-2xl leading-snug md:text-[2rem]")}>{about.lead}</p>
</Reveal>
<Reveal delay={0.1}>
<p className="text-lg leading-relaxed text-t-muted">{about.text}</p>
</Reveal>
</div>
</section>
)}
{show("team") && (
<section 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]")} />
<div className="mt-16 grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-4">
{team.members.map((member, i) => (
<Reveal key={i} delay={i * 0.08}>
<div className="group">
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
<TImage
src={member.image}
alt={member.name}
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
className="grayscale transition duration-[900ms] group-hover:scale-[1.04] group-hover:grayscale-0"
/>
</div>
<span className="mt-6 block h-px w-8 bg-t-accent transition-all duration-700 group-hover:w-full" />
<p className={cn(heading, "mt-5 text-2xl")}>{member.name}</p>
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
)}
{show("steps") && (
<section 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]")} />
<ol className="mt-16 grid gap-px bg-t-bg/15 md:grid-cols-3">
{steps.items.map((step, i) => (
<motion.li
key={i}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.9, ease, delay: i * 0.12 }}
className="bg-t-fg py-10 md:p-10 md:first:pl-0"
>
<span className="font-t-display text-6xl italic text-t-accent">{i + 1}</span>
<h3 className={cn(heading, "mt-8 text-3xl")}>{step.title}</h3>
<p className="mt-4 max-w-sm leading-relaxed text-t-bg/65">{step.text}</p>
</motion.li>
))}
</ol>
</div>
</section>
)}
{show("quotes") && (
<section 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">
<blockquote className={cn(heading, "text-[clamp(1.6rem,2.7vw,2.4rem)] italic leading-[1.25]")}>„{quote.text}“</blockquote>
<figcaption className="mt-8 text-[11px] uppercase tracking-[0.28em] text-t-muted">{quote.author}</figcaption>
</figure>
</Reveal>
))}
</section>
)}
<section id="kontakt" className="bg-t-surface py-24 md:py-36">
<div className={cn(container, "grid gap-16 md:grid-cols-12 md:gap-10")}>
<div className="md:col-span-5">
<Reveal><Eyebrow>{contact.eyebrow}</Eyebrow></Reveal>
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.5rem)] leading-[1.02]")} />
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{contact.text}</p>
<dl className="mt-12 grid gap-7">
{[
["Adresse", contact.address],
["Telefon", contact.phone],
["E-Mail", contact.email],
["Bürozeiten", contact.hours],
].map(([label, value]) => (
<div key={label}>
<dt className="text-[11px] uppercase tracking-[0.28em] text-t-muted">{label}</dt>
<dd className={cn(heading, "mt-1.5 text-xl whitespace-pre-line")}>{value}</dd>
</div>
))}
</dl>
</Reveal>
</div>
<Reveal delay={0.1} className="md:col-span-6 md:col-start-7">
<DemoForm
className="bg-t-bg p-7 shadow-[0_40px_100px_-50px_rgba(0,0,0,0.35)] md:p-12"
success={
<div className="py-10">
<p className={cn(heading, "text-5xl")}>Vielen <span className={em}>Dank.</span></p>
<p className="mt-4 text-t-muted">Wir melden uns innerhalb eines Werktags bei Ihnen.</p>
</div>
}
>
<p className={cn(heading, "text-3xl")}>Ihre Anfrage</p>
<div className="mt-8 grid gap-5 sm:grid-cols-2">
<Box label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
<Box label="E-Mail" name="email" type="email" autoComplete="email" required />
<Box label="Telefon" name="phone" type="tel" autoComplete="tel" />
<label className="block sm:col-span-2">
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">Anliegen</span>
<select name="area" className="mt-2 w-full border border-t-fg/15 bg-transparent px-4 py-3 outline-none focus:border-t-accent">
{areas.items.map((item, i) => (
<option key={i}>{item.title}</option>
))}
<option>Sonstiges</option>
</select>
</label>
<label className="block sm:col-span-2">
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">Ihre Nachricht</span>
<textarea name="message" rows={4} className="mt-2 w-full resize-none border border-t-fg/15 bg-transparent px-4 py-3 outline-none focus:border-t-accent" />
</label>
<label className="flex items-start gap-3 text-sm text-t-muted sm:col-span-2">
<input type="checkbox" required className="mt-1 size-4 accent-[var(--t-accent)]" />
Ich habe die Datenschutzerklärung gelesen und bin mit der Verarbeitung meiner Angaben einverstanden.
</label>
</div>
<button
type="submit"
className="group mt-9 inline-flex w-full cursor-pointer items-center justify-center gap-3 bg-t-fg py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg"
>
Anfrage senden
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="border-t border-t-fg/10">
<div className={cn(container, "grid gap-10 py-14 text-sm text-t-muted md:grid-cols-4")}>
<div>
<p className={cn(heading, "text-2xl text-t-fg")}>{brand.name}</p>
<p className="mt-1">{brand.suffix}</p>
</div>
<p className="whitespace-pre-line">{contact.address}</p>
<div className="grid gap-1">
<span>{contact.phone}</span>
<span>{contact.email}</span>
</div>
<div className="flex gap-6 md:justify-end">
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ brand, cta, links }: { brand: KanzleiData["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/85 backdrop-blur-xl")}
>
<nav className={cn(container, "flex h-24 items-center justify-between gap-8")}>
<a href="#top" className="flex flex-col">
<span className={cn(heading, "text-2xl leading-none")}>{brand.name}</span>
<span className="mt-1.5 text-[10px] uppercase tracking-[0.28em] text-t-muted">{brand.suffix}</span>
</a>
<div className="hidden items-center gap-10 text-sm 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="#kontakt" className="hidden border border-t-fg/30 px-5 py-3 text-sm transition-colors hover:border-t-fg hover:bg-t-fg hover:text-t-bg sm:block">
{cta}
</a>
</nav>
<motion.span
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 1.6, ease, delay: 0.2 }}
className="absolute inset-x-0 bottom-0 h-px origin-left bg-t-fg/12"
/>
</motion.header>
)
}
function Areas({ areas }: { areas: KanzleiData["areas"] }) {
const [open, setOpen] = useState<number | null>(0)
return (
<section id="kompetenzen" className="bg-t-surface py-24 md:py-40">
<div className={cn(container, "grid gap-14 md:grid-cols-12 md:gap-10")}>
<div className="md:col-span-4">
<div className="md:sticky md:top-36">
<Reveal><Eyebrow>{areas.eyebrow}</Eyebrow></Reveal>
<RevealWords text={areas.title} emClassName={em} className={cn(heading, "text-[clamp(2.6rem,5vw,4.75rem)] leading-[1.02]")} />
<Reveal delay={0.15}>
<p className="mt-8 max-w-sm leading-relaxed text-t-muted">{areas.text}</p>
</Reveal>
</div>
</div>
<ul className="border-t border-t-fg/15 md:col-span-7 md:col-start-6">
{areas.items.map((item, i) => {
const isOpen = open === i
return (
<li key={i} className="border-b border-t-fg/15">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setOpen(isOpen ? null : i)}
className="group flex w-full cursor-pointer items-baseline gap-6 py-7 text-left md:gap-10 md:py-8"
>
<span className="w-6 shrink-0 font-t-display text-sm text-t-accent tabular-nums">{String(i + 1).padStart(2, "0")}</span>
<span className={cn(heading, "flex-1 text-3xl transition-colors duration-300 group-hover:text-t-accent md:text-4xl")}>{item.title}</span>
<span aria-hidden className="relative size-4 shrink-0 self-center">
<span className="absolute top-1/2 left-0 h-px w-full bg-current" />
<span className={cn("absolute top-1/2 left-0 h-px w-full bg-current transition-transform duration-500", !isOpen && "rotate-90")} />
</span>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.5, ease }}
className="overflow-hidden"
>
<p className="max-w-xl pb-8 pl-12 leading-relaxed text-t-muted md:pl-16">{item.text}</p>
</motion.div>
)}
</AnimatePresence>
</li>
)
})}
</ul>
</div>
</section>
)
}
function Box({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return (
<label className={cn("block", className)}>
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">{label}</span>
<input
{...props}
className="mt-2 w-full border border-t-fg/15 bg-transparent px-4 py-3 outline-none transition-colors focus:border-t-accent"
/>
</label>
)
}

View file

@ -0,0 +1,105 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "Weber Elektro", suffix: "Elektrotechnik seit 1978", phone: "0221 123 456 78", email: "jobs@weber-elektro.de" },
hero: {
eyebrow: "Wir stellen ein · Köln & Umgebung",
title: "Kein Job.\nDein *Ding.*",
roles: "Elektroniker:in, Azubi, Meister:in, Projektleiter:in, Servicetechniker:in",
text: "Bei uns zählt, was du kannst – nicht, was im Zeugnis steht. Faire Bezahlung, moderne Ausstattung und ein Team, das zusammenhält.",
cta: "Offene Stellen",
ctaSecondary: "In 60 Sekunden bewerben",
image: unsplash("1621905252507-b35492cc74b4", 1200),
imageSecondary: unsplash("1504328345606-18bbc8c9d7d1", 900),
imageThird: unsplash("1543269865-cbf427effbad", 900),
badge: "Über Tarif bezahlt",
},
benefits: {
eyebrow: "Benefits",
title: "Mehr als nur *Gehalt.*",
items: [
{ title: "Über Tarif", text: "Faire Löhne plus Zuschläge, Urlaubs- und Weihnachtsgeld." },
{ title: "4-Tage-Woche möglich", text: "Du entscheidest mit, wie deine Woche aussieht." },
{ title: "Eigener E-Transporter", text: "Voll ausgestattet – auch für den Heimweg." },
{ title: "Weiterbildung", text: "Meisterkurs, Techniker oder Smart-Home-Zertifikat? Zahlen wir." },
{ title: "Team-Events", text: "Sommerfest, Kartbahn und das legendäre Grillen am Freitag." },
{ title: "Moderne Tools", text: "Tablet, App und Top-Werkzeug statt Zettelwirtschaft." },
],
},
facts: {
items: [
{ value: "85", suffix: "", label: "Kolleg:innen" },
{ value: "46", suffix: "", label: "Jahre Familienbetrieb" },
{ value: "12", suffix: "", label: "Azubis aktuell" },
{ value: "4,8", suffix: " ★", label: "Mitarbeiter-Bewertung" },
],
},
jobs: {
eyebrow: "Offene Stellen",
title: "Finde deinen *Platz.*",
items: [
{ title: "Elektroniker:in Energie- und Gebäudetechnik", area: "Montage", type: "Vollzeit", location: "Köln", tasks: "Installation in Neubau und Sanierung\nSmart-Home- und PV-Anlagen\nArbeiten im festen Zweierteam" },
{ title: "Ausbildung Elektroniker:in (Start 2027)", area: "Ausbildung", type: "Ausbildung", location: "Köln", tasks: "3,5 Jahre Ausbildung mit Übernahmegarantie\nEigene:r Ausbildungspat:in\nTablet ab dem ersten Tag" },
{ title: "Projektleiter:in Elektrotechnik", area: "Büro", type: "Vollzeit", location: "Köln", tasks: "Planung und Kalkulation von Projekten\nKoordination von Teams und Gewerken\nDirekter Draht zu Bauherr:innen" },
{ title: "Servicetechniker:in", area: "Service", type: "Teilzeit", location: "Bonn", tasks: "Wartung und Störungsbehebung beim Kunden\nFlexible Einsatzplanung per App\n20 bis 30 Stunden pro Woche" },
],
},
voices: {
eyebrow: "Team",
title: "Das sagen die *Kolleg:innen.*",
items: [
{ name: "Kemal, 29", role: "Elektroniker seit 2019", quote: "Hier fragt der Chef nach deiner Meinung – und hört auch zu. Ich habe in fünf Jahren mehr gelernt als je zuvor.", image: unsplash("1607990281513-2c110a25bd8c", 800) },
{ name: "Jana, 24", role: "Azubi im 3. Lehrjahr", quote: "Ich dachte, Handwerk heißt Kaffee kochen. Stattdessen habe ich im zweiten Monat meine erste Wallbox installiert.", image: unsplash("1590650153855-d9e808231d41", 800) },
{ name: "Peter, 52", role: "Meister & Ausbilder", quote: "Wir arbeiten mit moderner Technik, aber der Zusammenhalt ist wie früher. Das gibt es selten.", image: unsplash("1574757987642-5755f0839101", 800) },
],
},
process: {
eyebrow: "Bewerbung",
title: "So einfach *geht's.*",
items: [
{ title: "Bewerben", text: "60 Sekunden, ohne Anschreiben und Lebenslauf." },
{ title: "Kennenlernen", text: "Wir rufen dich innerhalb von 48 Stunden an." },
{ title: "Probetag", text: "Lern das Team und die Baustelle kennen – bezahlt." },
{ title: "Loslegen", text: "Vertrag, Ausrüstung, Transporter: Willkommen!" },
],
},
apply: {
title: "Bewirb dich in *60 Sekunden.*",
text: "Kein Lebenslauf nötig – den kannst du später nachreichen.",
experience: "Berufseinsteiger:in, 1–3 Jahre, Mehr als 3 Jahre, Meister:in / Techniker:in",
},
}
export const karriere = defineTemplate({
key: "karriere",
name: "Karriere",
industry: "Recruiting & Karriereseite",
description: "Nahbar und mutig: wechselnde Jobtitel im Hero, Benefits, filterbare Stellen, Bewerbung in 60 Sekunden in drei Schritten und Stimmen aus dem Team.",
cover: unsplash("1621905252507-b35492cc74b4", 1200),
samples: {
inquiry: "Hallo, ich bin gelernter Elektroniker mit fünf Jahren Berufserfahrung und interessiere mich für die Stelle als Servicetechniker. Ist auch eine 4-Tage-Woche möglich, und mit welchem Gehalt kann ich rechnen? Viele Grüße, Daniel Schulz",
topic: "Ausbildungsstart 2027: Werde Elektroniker:in bei uns",
},
palettes: [
{ key: "sonne", label: "Sonne", bg: "#fffaf0", fg: "#1b1a17", muted: "#6d675c", surface: "#f6ecd6", accent: "#ffb703" },
{ key: "petrol", label: "Petrol", bg: "#f1f7f7", fg: "#0c2a2e", muted: "#557073", surface: "#ddeceb", accent: "#ff6b4a" },
{ key: "nacht", label: "Nacht", dark: true, bg: "#111214", fg: "#f4f4f5", muted: "#9b9ca3", surface: "#1b1c20", accent: "#a3e635" },
],
fonts: [
{ key: "freundlich", label: "Gabarito & Onest" },
{ key: "klar", label: "Schibsted Grotesk" },
],
sections: describeSections(defaults, {
brand: "Unternehmen",
hero: "Startbereich",
benefits: { label: "Benefits", optional: true, items: { items: "Benefit" } },
facts: { label: "Zahlen", optional: true, items: { items: "Kennzahl" } },
jobs: { label: "Stellen", items: { items: "Stelle" } },
voices: { label: "Team-Stimmen", optional: true, items: { items: "Stimme" } },
process: { label: "Ablauf", optional: true, items: { items: "Schritt" } },
apply: "Schnellbewerbung",
}),
defaults,
})
export type KarriereData = typeof karriere.defaults

View file

@ -0,0 +1,12 @@
import { Gabarito, Onest, Schibsted_Grotesk } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const gabarito = Gabarito({ subsets: ["latin"], variable: "--font-karriere-gabarito", preload: false })
const onest = Onest({ subsets: ["latin"], variable: "--font-karriere-onest", preload: false })
const schibsted = Schibsted_Grotesk({ subsets: ["latin"], variable: "--font-karriere-schibsted", preload: false })
export const fonts: Record<string, FontPair> = {
freundlich: { className: `${gabarito.variable} ${onest.variable}`, display: "var(--font-karriere-gabarito)", body: "var(--font-karriere-onest)" },
klar: { className: schibsted.variable, display: "var(--font-karriere-schibsted)", body: "var(--font-karriere-schibsted)" },
}

View file

@ -0,0 +1,443 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import {
ArrowRightIcon,
BanknoteIcon,
BriefcaseIcon,
CalendarDaysIcon,
CheckIcon,
GraduationCapIcon,
MapPinIcon,
PartyPopperIcon,
PlayIcon,
PlusIcon,
TabletSmartphoneIcon,
TruckIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { TField, TSelect } from "../shared/fields"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Counter, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { karriere, type KarriereData } 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 heading = "font-t-display font-bold tracking-[-0.03em]"
const icons = [BanknoteIcon, CalendarDaysIcon, TruckIcon, GraduationCapIcon, PartyPopperIcon, TabletSmartphoneIcon]
const INITIATIVE = "Initiativ bewerben"
function Eyebrow({ children }: { children: string }) {
return <p className="mb-4 inline-block -rotate-1 rounded-lg bg-t-accent px-3 py-1 text-sm font-bold text-t-accent-fg">{children}</p>
}
export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
const { brand, hero, benefits, facts, jobs, voices, process, apply } = content.data
const show = (key: string) => !content.hidden.includes(key)
const [job, setJob] = useState<string | null>(null)
const links = [
{ href: "#benefits", label: "Benefits", visible: show("benefits") },
{ href: "#stellen", label: "Stellen", visible: true },
{ href: "#team", label: "Team", visible: show("voices") },
].filter((link) => link.visible)
return (
<TemplateShell def={karriere} content={content} fonts={fonts[content.fonts] ?? fonts.freundlich}>
<Nav name={brand.name} links={links} />
<Hero hero={hero} openJobs={jobs.items.length} />
{show("benefits") && (
<section 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">
{benefits.items.map((item, i) => {
const Icon = icons[i % icons.length]
return (
<Reveal key={i} delay={(i % 3) * 0.08}>
<motion.article
whileHover={{ rotate: i % 2 ? 1.5 : -1.5, y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 18 }}
className="h-full rounded-3xl border-2 border-t-fg/10 bg-t-bg p-7 transition-colors hover:border-t-accent"
>
<span className="grid size-12 place-items-center rounded-2xl bg-t-accent text-t-accent-fg">
<Icon className="size-6" />
</span>
<h3 className={cn(heading, "mt-5 text-xl")}>{item.title}</h3>
<p className="mt-2 text-t-muted">{item.text}</p>
</motion.article>
</Reveal>
)
})}
</div>
</section>
)}
{show("facts") && (
<section 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}>
<dd className={cn(heading, "text-5xl md:text-6xl")}>
<Counter value={fact.value} />
<span className="text-t-accent">{fact.suffix}</span>
</dd>
<dt className="mt-1 text-sm opacity-70">{fact.label}</dt>
</div>
))}
</dl>
</section>
)}
<Jobs jobs={jobs} onApply={setJob} />
{show("voices") && (
<section 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]")} />
<div className="mt-12 grid gap-5 md:grid-cols-3">
{voices.items.map((voice, i) => (
<Reveal key={i} delay={i * 0.1}>
<figure className="group h-full overflow-hidden rounded-3xl bg-t-bg">
<div className="relative aspect-[4/3] overflow-hidden">
<TImage src={voice.image} alt={voice.name} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
<span className="absolute bottom-4 left-4 grid size-12 place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-lg transition-transform group-hover:scale-110">
<PlayIcon className="ml-0.5 size-5 fill-current" />
</span>
</div>
<blockquote className="p-6 text-lg leading-snug">„{voice.quote}“</blockquote>
<figcaption className="px-6 pb-6 text-sm">
<span className="font-bold">{voice.name}</span> <span className="text-t-muted">· {voice.role}</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
)}
{show("process") && (
<section 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">
<span aria-hidden className="absolute top-7 right-[12%] left-[12%] hidden border-t-2 border-dashed border-t-fg/20 md:block" />
{process.items.map((step, i) => (
<Reveal key={i} delay={i * 0.12}>
<li className="relative md:text-center">
<span className={cn(heading, "relative grid size-14 place-items-center rounded-full bg-t-accent text-xl text-t-accent-fg md:mx-auto")}>{i + 1}</span>
<p className={cn(heading, "mt-5 text-xl")}>{step.title}</p>
<p className="mt-1 text-t-muted">{step.text}</p>
</li>
</Reveal>
))}
</ol>
</section>
)}
<Apply apply={apply} jobs={jobs.items} job={job} onJob={setJob} />
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 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>
</footer>
</TemplateShell>
)
}
function Nav({ name, links }: { name: 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 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")}>
<a href="#top" className="flex items-center gap-2">
<span className={cn(heading, "text-xl")}>{name}</span>
<span className="rounded-full bg-t-accent px-2.5 py-0.5 text-xs font-bold text-t-accent-fg">Karriere</span>
</a>
<div className="hidden items-center gap-8 font-medium md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-fg">{link.label}</a>
))}
</div>
<a href="#bewerben" className="rounded-full bg-t-fg px-5 py-2.5 text-sm font-bold text-t-bg transition-transform hover:scale-105">Jetzt bewerben</a>
</nav>
</motion.header>
)
}
function Hero({ hero, openJobs }: { hero: KarriereData["hero"]; openJobs: number }) {
const roles = splitList(hero.roles)
const [index, setIndex] = useState(0)
useEffect(() => {
if (roles.length < 2) return
const timer = setInterval(() => setIndex((i) => i + 1), 2200)
return () => clearInterval(timer)
}, [roles.length])
const role = roles.length ? roles[index % roles.length] : ""
return (
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-20 lg:grid-cols-[1.1fr_1fr] lg:pt-40")}>
<div>
<motion.p initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className="mb-6 inline-flex items-center gap-2 rounded-full border-2 border-t-fg/10 px-4 py-1.5 text-sm font-medium">
<span className="size-2 animate-pulse rounded-full bg-emerald-500" /> {hero.eyebrow}
</motion.p>
<RevealWords as="h1" onMount delay={0.15} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(3.25rem,8vw,7.5rem)] leading-[0.92]")} />
{role && (
<div className={cn(heading, "mt-6 flex flex-wrap items-center gap-3 text-2xl md:text-3xl")}>
<span className="text-t-muted">Wir suchen</span>
<span className="relative inline-flex h-[1.4em] overflow-hidden rounded-xl bg-t-accent px-3 text-t-accent-fg">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={role} initial={{ y: "100%" }} animate={{ y: "0%" }} exit={{ y: "-100%" }} transition={{ duration: 0.45, ease }} className="leading-[1.4em]">
{role}
</motion.span>
</AnimatePresence>
</span>
</div>
)}
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
<p className="mt-8 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-9 flex flex-wrap gap-3">
<a href="#stellen" className="inline-flex items-center gap-2 rounded-full bg-t-fg px-7 py-4 font-bold text-t-bg transition-transform hover:scale-105">
{hero.cta} <span className="rounded-full bg-t-accent px-2 text-sm text-t-accent-fg">{openJobs}</span>
</a>
<a href="#bewerben" className="inline-flex items-center gap-2 rounded-full border-2 border-t-fg px-7 py-4 font-bold transition-colors hover:bg-t-fg hover:text-t-bg">
{hero.ctaSecondary} <ArrowRightIcon className="size-4" />
</a>
</div>
</motion.div>
</div>
<div className="relative mx-auto aspect-square w-full max-w-[560px]">
{[
{ src: hero.imageSecondary, className: "top-0 left-0 w-[46%] -rotate-6", delay: 0.4 },
{ src: hero.image, className: "top-[12%] right-0 w-[58%] rotate-3", delay: 0.25 },
{ src: hero.imageThird, className: "bottom-0 left-[14%] w-[52%] -rotate-2", delay: 0.55 },
].map((photo, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 40, rotate: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: photo.delay }}
className={cn("absolute aspect-[4/5] overflow-hidden rounded-3xl border-[6px] border-t-bg bg-t-surface shadow-xl", photo.className)}
>
<TImage src={photo.src} eager={i === 1} sizes="(min-width: 1024px) 30vw, 60vw" />
</motion.div>
))}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1, rotate: [0, 360] }}
transition={{ scale: { type: "spring", bounce: 0.4, delay: 1 }, rotate: { duration: 22, repeat: Infinity, ease: "linear" } }}
className="absolute right-[6%] bottom-[8%] grid size-28 place-items-center rounded-full bg-t-accent p-3 text-center text-sm leading-tight font-black text-t-accent-fg shadow-xl md:size-32"
>
{hero.badge}
</motion.div>
</div>
</section>
)
}
function Jobs({ jobs, onApply }: { jobs: KarriereData["jobs"]; onApply: (job: string) => void }) {
const types = ["Alle", ...new Set(jobs.items.map((item) => item.type))]
const [filter, setFilter] = useState("Alle")
const [open, setOpen] = useState<number | null>(0)
const visible = jobs.items.map((item, i) => ({ item, i })).filter(({ item }) => filter === "Alle" || item.type === filter)
return (
<section id="stellen" className={cn(container, "py-20 md:py-28")}>
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><Eyebrow>{jobs.eyebrow}</Eyebrow></Reveal>
<RevealWords text={jobs.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
</div>
<div className="flex flex-wrap gap-2">
{types.map((type) => (
<button
key={type}
type="button"
aria-pressed={filter === type}
onClick={() => setFilter(type)}
className={cn("cursor-pointer rounded-full border-2 px-4 py-2 text-sm font-bold transition-colors", filter === type ? "border-t-fg bg-t-fg text-t-bg" : "border-t-fg/15 hover:border-t-fg")}
>
{type}
</button>
))}
</div>
</div>
<ul className="mt-10 space-y-3">
{visible.map(({ item, i }) => {
const isOpen = open === i
return (
<motion.li layout key={item.title} className={cn("overflow-hidden rounded-3xl border-2 transition-colors", isOpen ? "border-t-fg" : "border-t-fg/10")}>
<button type="button" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? null : i)} className="flex w-full cursor-pointer items-center gap-4 p-5 text-left md:p-7">
<span className="hidden size-12 shrink-0 place-items-center rounded-2xl bg-t-surface sm:grid">
<BriefcaseIcon className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className={cn(heading, "block text-lg md:text-2xl")}>{item.title}</span>
<span className="mt-2 flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-t-accent px-2.5 py-1 text-t-accent-fg">{item.type}</span>
<span className="rounded-full bg-t-surface px-2.5 py-1">{item.area}</span>
<span className="flex items-center gap-1 rounded-full bg-t-surface px-2.5 py-1"><MapPinIcon className="size-3" /> {item.location}</span>
</span>
</span>
<PlusIcon className={cn("size-6 shrink-0 transition-transform duration-300", isOpen && "rotate-45")} />
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }} transition={{ duration: 0.4, ease }}>
<div className="flex flex-col gap-6 px-5 pb-6 md:flex-row md:items-end md:justify-between md:px-7 md:pb-7">
<ul className="space-y-2">
{splitList(item.tasks, "\n").map((task) => (
<li key={task} className="flex items-start gap-2.5">
<CheckIcon className="mt-1 size-4 shrink-0 text-t-accent" /> {task}
</li>
))}
</ul>
<a href="#bewerben" onClick={() => onApply(item.title)} className="inline-flex shrink-0 items-center gap-2 rounded-full bg-t-accent px-6 py-3.5 font-bold text-t-accent-fg">
Jetzt bewerben <ArrowRightIcon className="size-4" />
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.li>
)
})}
</ul>
</section>
)
}
function Apply({ apply, jobs, job, onJob }: { apply: KarriereData["apply"]; jobs: KarriereData["jobs"]["items"]; job: string | null; onJob: (job: string | null) => void }) {
const [step, setStep] = useState(0)
const [experience, setExperience] = useState<string | null>(null)
const [done, setDone] = useState(false)
const levels = splitList(apply.experience)
const current = job && step === 0 ? 1 : step
const progress = done ? 100 : ((current + 1) / 3) * 100
return (
<section id="bewerben" className="bg-t-accent py-20 text-t-accent-fg md:py-28">
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-2")}>
<div>
<RevealWords text={apply.title} emClassName="underline decoration-4 underline-offset-8" className={cn(heading, "text-[clamp(2.6rem,6vw,5.5rem)] leading-[0.95]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg opacity-80">{apply.text}</p></Reveal>
</div>
<div className="relative rounded-[2rem] bg-t-bg p-6 text-t-fg shadow-2xl md:p-9">
<div className="mb-8 h-2 overflow-hidden rounded-full bg-t-surface">
<motion.div className="h-full rounded-full bg-t-accent" animate={{ width: `${progress}%` }} transition={{ duration: 0.5, ease }} />
</div>
{done ? (
<div className="relative py-10 text-center">
{Array.from({ length: 18 }, (_, i) => {
const angle = (i / 18) * Math.PI * 2
return (
<motion.span
key={i}
aria-hidden
className={cn("absolute top-1/2 left-1/2 size-2.5 rounded-sm", i % 3 === 0 ? "bg-t-accent" : i % 3 === 1 ? "bg-t-fg" : "bg-t-muted")}
initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }}
animate={{ x: Math.cos(angle) * 160, y: Math.sin(angle) * 110, opacity: 0, rotate: 200 }}
transition={{ duration: 1.2, ease: "easeOut" }}
/>
)
})}
<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>
</div>
) : (
<StepPanel step={current}>
{current === 0 && (
<div>
<p className={cn(heading, "text-2xl")}>Welche Stelle?</p>
<div className="mt-5 grid gap-2">
{[...jobs.map((item) => item.title), INITIATIVE].map((title) => (
<button
key={title}
type="button"
onClick={() => {
onJob(title)
setStep(1)
}}
className="flex cursor-pointer items-center justify-between rounded-2xl border-2 border-t-fg/10 px-4 py-3.5 text-left font-medium transition-colors hover:border-t-accent"
>
{title} <ArrowRightIcon className="size-4 shrink-0" />
</button>
))}
</div>
</div>
)}
{current === 1 && (
<div>
<p className="text-sm text-t-muted">{job}</p>
<p className={cn(heading, "mt-1 text-2xl")}>Wie viel Erfahrung bringst du mit?</p>
<div className="mt-5 grid grid-cols-2 gap-2">
{levels.map((level) => (
<button
key={level}
type="button"
onClick={() => {
setExperience(level)
setStep(2)
}}
className={cn("cursor-pointer rounded-2xl border-2 px-4 py-5 font-bold transition-colors", experience === level ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/10 hover:border-t-accent")}
>
{level}
</button>
))}
</div>
<button
type="button"
onClick={() => {
onJob(null)
setStep(0)
}}
className="mt-5 cursor-pointer text-sm text-t-muted hover:text-t-fg"
>
Andere Stelle wählen
</button>
</div>
)}
{current === 2 && (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
setDone(true)
}}
>
<p className={cn(heading, "text-2xl")}>Wie erreichen wir dich?</p>
<TField label="Vorname und Name" name="name" required autoComplete="name" variant="soft" />
<TField label="Handynummer" name="phone" type="tel" required autoComplete="tel" variant="soft" />
<TSelect label="Wann passt ein Anruf?" name="time" options={["Vormittags", "Mittags", "Nachmittags", "Abends"]} variant="soft" />
<button type="submit" className="mt-2 inline-flex cursor-pointer items-center justify-center gap-2 rounded-full bg-t-fg py-4 font-bold text-t-bg">
Bewerbung abschicken <ArrowRightIcon className="size-4" />
</button>
<button type="button" onClick={() => setStep(1)} className="cursor-pointer text-sm text-t-muted hover:text-t-fg">Zurück</button>
</form>
)}
</StepPanel>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,127 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: {
name: "Zahnwerk",
suffix: "Zahnarztpraxis Dr. Sommer",
phone: "089 123 456 78",
email: "praxis@zahnwerk-muenchen.de",
address: "Leopoldstraße 52, 80802 München",
},
hero: {
eyebrow: "Zahnarztpraxis in München-Schwabing",
title: "Gesunde Zähne.\n*Entspannte* Termine.",
text: "Moderne Zahnmedizin mit viel Zeit, Ruhe und verständlichen Erklärungen – auch für Angstpatient:innen. Termine buchen Sie bequem online.",
cta: "Termin online buchen",
image: unsplash("1629909613654-28e377c37b09", 1600),
rating: "4,9",
reviews: "320 Google-Bewertungen",
note: "Angstpatient:innen herzlich willkommen",
},
hours: {
items: [
{ day: "Montag", open: "08:00", close: "18:00" },
{ day: "Dienstag", open: "08:00", close: "18:00" },
{ day: "Mittwoch", open: "08:00", close: "14:00" },
{ day: "Donnerstag", open: "08:00", close: "19:00" },
{ day: "Freitag", open: "08:00", close: "14:00" },
{ day: "Samstag", open: "", close: "" },
{ day: "Sonntag", open: "", close: "" },
],
},
services: {
eyebrow: "Behandlungen",
title: "Alles für Ihr *Lächeln.*",
items: [
{ title: "Prophylaxe", text: "Professionelle Zahnreinigung für gesunde Zähne, festes Zahnfleisch und frischen Atem.", duration: "45 Min" },
{ title: "Ästhetik & Bleaching", text: "Hellere Zähne und harmonische Formen – schonend und mit natürlichem Ergebnis.", duration: "60 Min" },
{ title: "Implantate", text: "Fester Zahnersatz, der sich anfühlt wie eigene Zähne. Planung digital und in 3D.", duration: "Beratung 30 Min" },
{ title: "Unsichtbare Schienen", text: "Gerade Zähne ohne feste Spange – mit transparenten Alignern und digitaler Vorschau.", duration: "Beratung 30 Min" },
{ title: "Kinderzahnmedizin", text: "Spielerisch, geduldig und kindgerecht – damit der Zahnarztbesuch Spaß macht.", duration: "30 Min" },
{ title: "Angstpatient:innen", text: "Ruhige Atmosphäre, viel Zeit und auf Wunsch Behandlung im Dämmerschlaf.", duration: "nach Absprache" },
],
},
about: {
eyebrow: "Praxis",
title: "Eine Praxis zum *Wohlfühlen.*",
text: "Helle Räume, kurze Wartezeiten und ein Team, das sich Zeit nimmt. Wir erklären jeden Schritt, bevor wir ihn gehen – und arbeiten mit digitaler Technik, die Behandlungen kürzer und angenehmer macht.",
image: unsplash("1519494026892-80bbd2d6fd0d", 1400),
imageSecondary: unsplash("1598256989800-fe5f95da9787", 900),
facts: [
{ value: "15+", label: "Jahre Erfahrung" },
{ value: "6", label: "Behandlungsräume" },
{ value: "9.000", label: "zufriedene Patient:innen" },
],
},
booking: {
eyebrow: "Online-Termin",
title: "Ihr Termin in *drei* Schritten.",
text: "Behandlung wählen, Wunschzeit aussuchen, fertig. Wir bestätigen innerhalb weniger Stunden.",
slots: "08:30, 09:15, 10:00, 11:30, 14:00, 15:30, 16:15",
},
team: {
eyebrow: "Team",
title: "Menschen, die *zuhören.*",
members: [
{ name: "Dr. Julia Sommer", role: "Zahnärztin, Implantologie", image: unsplash("1559839734-2b71ea197ec2", 900) },
{ name: "Dr. Arjun Mehta", role: "Zahnarzt, Ästhetik", image: unsplash("1612349317150-e413f6a5b16d", 900) },
{ name: "Lisa Brandt", role: "Prophylaxe-Assistentin", image: unsplash("1594824476967-48c8b964273f", 900) },
{ name: "Tom Becker", role: "Zahnmedizinischer Fachangestellter", image: unsplash("1622253692010-333f2da6031d", 900) },
],
},
testimonial: {
quote: "Ich hatte jahrelang Angst vor dem Zahnarzt. Hier wird jeder Schritt erklärt, niemand hetzt – zum ersten Mal gehe ich entspannt zur Kontrolle.",
author: "Sabine K., Patientin seit 2019",
},
faq: {
eyebrow: "FAQ",
title: "Häufige *Fragen*",
items: [
{ question: "Nehmen Sie neue Patient:innen auf?", answer: "Ja, gern. Buchen Sie einfach online einen Termin für eine Erstuntersuchung oder rufen Sie uns an." },
{ question: "Behandeln Sie gesetzlich und privat Versicherte?", answer: "Wir behandeln alle Kassen- und Privatpatient:innen sowie Selbstzahler:innen. Kosten besprechen wir immer vorab." },
{ question: "Ich habe große Angst – was kann ich tun?", answer: "Sagen Sie es uns bei der Buchung. Wir planen mehr Zeit ein, erklären alles in Ruhe und bieten auf Wunsch Dämmerschlaf an." },
{ question: "Was mache ich bei akuten Schmerzen?", answer: "Rufen Sie uns während der Sprechzeiten an – wir halten täglich Notfalltermine frei. Außerhalb hilft der zahnärztliche Notdienst." },
],
},
contact: {
eyebrow: "Anfahrt",
title: "So finden Sie *uns.*",
text: "Direkt an der U-Bahn Giselastraße (U3/U6). Parkhaus in zwei Minuten Entfernung, barrierefreier Zugang mit Aufzug.",
},
}
export const praxis = defineTemplate({
key: "praxis",
name: "Praxis",
industry: "Arzt, Zahnarzt & Physio",
description: "Freundlich und vertrauensvoll: Live-Öffnungsstatus, Behandlungen als Kacheln, Online-Terminbuchung in drei Schritten, Team und FAQ.",
cover: unsplash("1629909613654-28e377c37b09", 1200),
samples: {
inquiry: "Hallo, ich habe seit ein paar Tagen Zahnschmerzen unten links und bin Angstpatientin. Haben Sie diese Woche noch einen Termin frei, und bieten Sie Behandlung im Dämmerschlaf an? Danke, Petra Lang",
topic: "Tipps für gesunde Kinderzähne zum Schulstart",
},
palettes: [
{ key: "minze", label: "Minze", bg: "#f3f8f6", fg: "#0f2a24", muted: "#5b716b", surface: "#e3efea", accent: "#13a37f" },
{ key: "himmel", label: "Himmel", bg: "#f4f7fb", fg: "#0d2140", muted: "#5b6b82", surface: "#e4ebf5", accent: "#2f6bff" },
{ key: "sand", label: "Sand", bg: "#faf6f0", fg: "#2a2118", muted: "#76695b", surface: "#f0e8dc", accent: "#c46a3a" },
],
fonts: [
{ key: "klar", label: "Plus Jakarta Sans" },
{ key: "warm", label: "Newsreader & Figtree" },
],
sections: describeSections(defaults, {
brand: "Praxis & Kontakt",
hero: "Startbereich",
hours: { label: "Öffnungszeiten", items: { items: "Tag" } },
services: { label: "Behandlungen", optional: true, items: { items: "Behandlung" } },
about: { label: "Über die Praxis", optional: true, items: { facts: "Kennzahl" } },
booking: { label: "Online-Termin", optional: true },
team: { label: "Team", optional: true, items: { members: "Person" } },
testimonial: { label: "Patientenstimme", optional: true },
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
contact: "Anfahrt",
}),
defaults,
})
export type PraxisData = typeof praxis.defaults

View file

@ -0,0 +1,12 @@
import { Figtree, Newsreader, Plus_Jakarta_Sans } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts
const jakarta = Plus_Jakarta_Sans({ subsets: ["latin"], variable: "--font-praxis-jakarta", preload: false })
const newsreader = Newsreader({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-praxis-newsreader", preload: false })
const figtree = Figtree({ subsets: ["latin"], variable: "--font-praxis-figtree", preload: false })
export const fonts: Record<string, FontPair> = {
klar: { className: jakarta.variable, display: "var(--font-praxis-jakarta)", body: "var(--font-praxis-jakarta)" },
warm: { className: `${newsreader.variable} ${figtree.variable}`, display: "var(--font-praxis-newsreader)", body: "var(--font-praxis-figtree)" },
}

View file

@ -0,0 +1,478 @@
"use client"
import { motion } from "motion/react"
import { useState } from "react"
import {
ArrowRightIcon,
BabyIcon,
CalendarCheckIcon,
CircleCheckIcon,
HeartHandshakeIcon,
MapPinIcon,
PhoneIcon,
ScanFaceIcon,
ShieldCheckIcon,
SmileIcon,
SparklesIcon,
StarIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { TField, TSelect } from "../shared/fields"
import { minutesOf, splitList, useNow } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, ParallaxImage, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { praxis, type PraxisData } from "./definition"
import { fonts } from "./fonts"
type Hours = PraxisData["hours"]["items"]
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1280px] px-5 md:px-8"
const heading = "font-t-display font-semibold tracking-[-0.025em]"
const icons = [SparklesIcon, SmileIcon, ShieldCheckIcon, ScanFaceIcon, BabyIcon, HeartHandshakeIcon]
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]
function Eyebrow({ children }: { children: string }) {
return (
<p className="mb-5 inline-flex items-center gap-2 rounded-full bg-t-surface px-3 py-1.5 text-xs font-semibold text-t-accent">
<span className="size-1.5 rounded-full bg-t-accent" />
{children}
</p>
)
}
function useOpenStatus(hours: Hours) {
const now = useNow()
if (now === null) return null
const date = new Date(now)
const minutes = date.getHours() * 60 + date.getMinutes()
const today = WEEKDAYS[date.getDay()]
const entry = hours.find((h) => h.day === today)
const open = entry ? minutesOf(entry.open) : null
const close = entry ? minutesOf(entry.close) : null
if (entry && open !== null && close !== null && minutes >= open && minutes < close) {
return { open: true, today, label: `Jetzt geöffnet · bis ${entry.close} Uhr` }
}
for (let offset = 0; offset < 7; offset++) {
const name = WEEKDAYS[(date.getDay() + offset) % 7]
const next = hours.find((h) => h.day === name)
const opens = next ? minutesOf(next.open) : null
if (next && opens !== null && (offset > 0 || minutes < opens)) {
const when = offset === 0 ? "heute" : offset === 1 ? "morgen" : name
return { open: false, today, label: `Geschlossen · öffnet ${when} ${next.open} Uhr` }
}
}
return { open: false, today, label: "Derzeit geschlossen" }
}
function OpenPill({ hours, className }: { hours: Hours; className?: string }) {
const status = useOpenStatus(hours)
return (
<span className={cn("inline-flex items-center gap-2 rounded-full bg-t-bg/90 px-3 py-1.5 text-xs font-medium shadow-sm ring-1 ring-t-fg/5", className)}>
<span className={cn("size-2 rounded-full", status?.open ? "animate-pulse bg-emerald-500" : "bg-t-muted/50")} />
{status?.label ?? "Öffnungszeiten"}
</span>
)
}
export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
const { brand, hero, hours, services, about, booking, team, testimonial, faq, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const bookingHref = show("booking") ? "#termin" : "#kontakt"
const links = [
{ href: "#leistungen", label: "Behandlungen", visible: show("services") },
{ href: "#team", label: "Team", visible: show("team") },
{ href: "#faq", label: "FAQ", visible: show("faq") },
{ href: "#kontakt", label: "Kontakt", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={praxis} content={content} fonts={fonts[content.fonts] ?? fonts.klar}>
<Nav brand={brand} hours={hours.items} cta={hero.cta} href={bookingHref} links={links} />
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-20 md:grid-cols-2 md:pt-40 md:pb-28")}>
<div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.1 }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(2.75rem,6vw,5.25rem)] leading-[1.02]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.7 }}>
<p className="mt-7 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-9 flex flex-wrap items-center gap-4">
<a href={bookingHref} className="group inline-flex items-center gap-3 rounded-full bg-t-accent px-7 py-4 font-semibold text-t-accent-fg shadow-[0_14px_40px_-12px_var(--t-accent)] transition-transform hover:-translate-y-0.5">
<CalendarCheckIcon className="size-5" /> {hero.cta}
</a>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="inline-flex items-center gap-2 rounded-full px-5 py-4 font-medium ring-1 ring-t-fg/15 transition-colors hover:bg-t-surface">
<PhoneIcon className="size-4" /> {brand.phone}
</a>
</div>
<div className="mt-9 flex items-center gap-3 text-sm">
<span className="flex text-amber-400">
{Array.from({ length: 5 }, (_, i) => (
<StarIcon key={i} className="size-4 fill-current" />
))}
</span>
<span className="font-semibold">{hero.rating}</span>
<span className="text-t-muted">· {hero.reviews}</span>
</div>
</motion.div>
</div>
<motion.div initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 1.1, ease, delay: 0.2 }} className="relative">
<div className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-t-surface">
<TImage src={hero.image} eager sizes="(min-width: 768px) 45vw, 100vw" />
</div>
<motion.div animate={{ y: [0, -10, 0] }} transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }} className="absolute top-8 -left-4 md:-left-10">
<OpenPill hours={hours.items} className="px-4 py-2.5 text-sm shadow-xl" />
</motion.div>
<motion.div
animate={{ y: [0, 10, 0] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-3 bottom-10 flex items-center gap-3 rounded-2xl bg-t-bg p-4 shadow-xl ring-1 ring-t-fg/5 md:-right-8"
>
<span className="grid size-11 place-items-center rounded-xl bg-t-accent text-t-accent-fg">
<HeartHandshakeIcon className="size-5" />
</span>
<span className="max-w-40 text-sm font-medium leading-snug">{hero.note}</span>
</motion.div>
</motion.div>
</section>
{show("services") && (
<section 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]")} />
</div>
<div className="mt-14 grid gap-4 sm: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="group h-full rounded-[1.75rem] bg-t-surface p-7 transition-all duration-500 hover:-translate-y-1 hover:bg-t-bg hover:shadow-[0_30px_60px_-30px_rgba(0,0,0,0.25)] hover:ring-1 hover:ring-t-fg/5">
<span className="grid size-12 place-items-center rounded-2xl bg-t-bg text-t-accent transition-colors duration-500 group-hover:bg-t-accent group-hover:text-t-accent-fg">
<Icon className="size-5" />
</span>
<h3 className={cn(heading, "mt-6 text-xl")}>{item.title}</h3>
<p className="mt-2 leading-relaxed text-t-muted">{item.text}</p>
<p className="mt-5 text-sm font-medium text-t-accent">{item.duration}</p>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("about") && (
<section 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">
<div className="relative aspect-square overflow-hidden rounded-[1.5rem] border-8 border-t-bg">
<TImage src={about.imageSecondary} sizes="20vw" />
</div>
</Reveal>
</div>
<div>
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{about.text}</p></Reveal>
<dl className="mt-10 grid grid-cols-3 gap-4">
{about.facts.map((fact, i) => (
<Reveal key={i} delay={0.15 + i * 0.08} className="rounded-2xl bg-t-surface p-4">
<dd className={cn(heading, "text-3xl text-t-accent")}>{fact.value}</dd>
<dt className="mt-1 text-sm text-t-muted">{fact.label}</dt>
</Reveal>
))}
</dl>
</div>
</section>
)}
{show("booking") && <Booking booking={booking} services={services.items} hours={hours.items} />}
{show("team") && (
<section 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">
{team.members.map((member, i) => (
<Reveal key={i} delay={i * 0.08}>
<div className="group">
<div className="relative aspect-[4/5] overflow-hidden rounded-[1.75rem] bg-t-surface">
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="transition-transform duration-700 group-hover:scale-105" />
</div>
<p className={cn(heading, "mt-4 text-lg")}>{member.name}</p>
<p className="text-sm text-t-muted">{member.role}</p>
</div>
</Reveal>
))}
</div>
</section>
)}
{show("testimonial") && (
<section 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>
<blockquote className={cn(heading, "relative max-w-4xl text-[clamp(1.6rem,3.2vw,2.75rem)] leading-[1.2]")}>{testimonial.quote}</blockquote>
<figcaption className="relative mt-8 text-sm font-medium opacity-80">{testimonial.author}</figcaption>
</figure>
</Reveal>
</section>
)}
{show("faq") && (
<section 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]")} />
</div>
<Accordion
items={faq.items}
className="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>
)}
<section id="kontakt" className={cn(container, "pb-20 md:pb-28")}>
<div className="grid gap-6 rounded-[2.5rem] bg-t-surface p-6 md:grid-cols-2 md:p-10">
<div className="p-2 md:p-4">
<Eyebrow>{contact.eyebrow}</Eyebrow>
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4vw,3.25rem)] leading-[1.05]")} />
<p className="mt-5 leading-relaxed text-t-muted">{contact.text}</p>
<div className="mt-8 space-y-3 text-sm">
<p className="flex items-center gap-3"><MapPinIcon className="size-4 text-t-accent" /> {brand.address}</p>
<p className="flex items-center gap-3"><PhoneIcon className="size-4 text-t-accent" /> {brand.phone}</p>
<p className="flex items-center gap-3"><CalendarCheckIcon className="size-4 text-t-accent" /> {brand.email}</p>
</div>
</div>
<HoursCard hours={hours.items} />
</div>
</section>
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p>© {brand.name} · {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>
</footer>
</TemplateShell>
)
}
function Nav({ brand, hours, cta, href, links }: { brand: PraxisData["brand"]; hours: Hours; cta: string; href: string; links: { href: string; label: string }[] }) {
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 md:pt-4">
<nav className={cn("mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 rounded-full px-3 transition-all duration-500 md:px-4", scrolled && "bg-t-bg/85 shadow-[0_10px_40px_-20px_rgba(0,0,0,0.3)] backdrop-blur-xl")}>
<a href="#top" className="flex items-center gap-2.5">
<span className="grid size-10 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<SmileIcon className="size-5" />
</span>
<span className="leading-tight">
<span className={cn(heading, "block text-lg")}>{brand.name}</span>
<span className="hidden text-xs text-t-muted sm:block">{brand.suffix}</span>
</span>
</a>
<div className="hidden items-center gap-7 text-sm font-medium 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>
<div className="flex items-center gap-3">
<OpenPill hours={hours} className="hidden md:inline-flex" />
<a href={href} className="rounded-full bg-t-accent px-5 py-2.5 text-sm font-semibold whitespace-nowrap text-t-accent-fg">{cta}</a>
</div>
</nav>
</motion.header>
)
}
function HoursCard({ hours }: { hours: Hours }) {
const status = useOpenStatus(hours)
return (
<div className="rounded-[2rem] bg-t-bg p-6 md:p-8">
<div className="flex items-center justify-between gap-4">
<p className={cn(heading, "text-xl")}>Sprechzeiten</p>
<span className={cn("rounded-full px-3 py-1 text-xs font-medium", status?.open ? "bg-emerald-500/15 text-emerald-600" : "bg-t-surface text-t-muted")}>
{status?.open ? "Geöffnet" : "Geschlossen"}
</span>
</div>
<dl className="mt-6 divide-y divide-t-fg/5">
{hours.map((entry) => (
<div key={entry.day} className={cn("flex justify-between py-3 text-sm", status?.today === entry.day && "font-semibold text-t-accent")}>
<dt>{entry.day}</dt>
<dd>{minutesOf(entry.open) !== null ? `${entry.open} – ${entry.close} Uhr` : "geschlossen"}</dd>
</div>
))}
</dl>
</div>
)
}
function nextOpenDays(now: number, hours: Hours, count: number) {
const days: { key: string; weekday: string; date: string }[] = []
for (let offset = 1; days.length < count && offset < 21; offset++) {
const date = new Date(now + offset * 86_400_000)
const name = WEEKDAYS[date.getDay()]
const entry = hours.find((h) => h.day === name)
if (!entry || minutesOf(entry.open) === null) continue
days.push({
key: date.toDateString(),
weekday: name.slice(0, 2),
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
})
}
return days
}
function Booking({ booking, services, hours }: { booking: PraxisData["booking"]; services: PraxisData["services"]["items"]; hours: Hours }) {
const now = useNow()
const [step, setStep] = useState(0)
const [service, setService] = useState<string | null>(null)
const [day, setDay] = useState<{ key: string; label: string } | null>(null)
const [slot, setSlot] = useState<string | null>(null)
const [done, setDone] = useState(false)
const days = now === null ? [] : nextOpenDays(now, hours, 6)
const slots = splitList(booking.slots)
const labels = ["Behandlung", "Termin", "Kontakt"]
const canContinue = step === 0 ? Boolean(service) : step === 1 ? Boolean(day && slot) : true
return (
<section id="termin" className="bg-t-surface py-20 md:py-28">
<div className={cn(container, "grid gap-12 lg:grid-cols-[0.8fr_1.2fr]")}>
<div>
<Reveal><Eyebrow>{booking.eyebrow}</Eyebrow></Reveal>
<RevealWords text={booking.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{booking.text}</p></Reveal>
</div>
<Reveal delay={0.1}>
<div className="rounded-[2rem] bg-t-bg p-6 shadow-[0_40px_80px_-40px_rgba(0,0,0,0.25)] md:p-9">
{done ? (
<motion.div initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} className="py-8 text-center">
<span className="mx-auto grid size-16 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<CircleCheckIcon className="size-8" />
</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>
</motion.div>
) : (
<>
<div className="mb-8 grid grid-cols-3 gap-2">
{labels.map((label, i) => (
<div key={label}>
<div className="h-1.5 overflow-hidden rounded-full bg-t-surface">
<motion.div className="h-full bg-t-accent" initial={false} animate={{ width: i <= step ? "100%" : "0%" }} transition={{ duration: 0.5, ease }} />
</div>
<p className={cn("mt-2 text-xs font-medium", i <= step ? "text-t-fg" : "text-t-muted")}>{i + 1}. {label}</p>
</div>
))}
</div>
<StepPanel step={step}>
{step === 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{services.map((item) => (
<button
key={item.title}
type="button"
onClick={() => setService(item.title)}
className={cn(
"cursor-pointer rounded-2xl p-4 text-left ring-1 transition-all",
service === item.title ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50"
)}
>
<span className="block font-semibold">{item.title}</span>
<span className="mt-1 block text-xs opacity-70">{item.duration}</span>
</button>
))}
</div>
)}
{step === 1 && (
<div>
<p className="text-sm font-medium">Tag wählen</p>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{days.map((d) => (
<button
key={d.key}
type="button"
onClick={() => setDay({ key: d.key, label: `${d.weekday}, ${d.date}` })}
className={cn("cursor-pointer rounded-2xl py-3 text-center ring-1 transition-all", day?.key === d.key ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50")}
>
<span className="block text-xs opacity-70">{d.weekday}</span>
<span className="block font-semibold">{d.date}</span>
</button>
))}
</div>
<p className="mt-6 text-sm font-medium">Uhrzeit wählen</p>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((time) => (
<button
key={time}
type="button"
onClick={() => setSlot(time)}
className={cn("cursor-pointer rounded-xl py-2.5 text-sm font-medium ring-1 transition-all", slot === time ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50")}
>
{time}
</button>
))}
</div>
</div>
)}
{step === 2 && (
<form
id="praxis-booking"
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
setDone(true)
}}
>
<TField label="Vor- und Nachname" name="name" required autoComplete="name" variant="soft" className="sm:col-span-2" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="soft" />
<TField label="E-Mail" name="email" type="email" autoComplete="email" variant="soft" />
<TSelect label="Versicherung" name="insurance" options={["Gesetzlich versichert", "Privat versichert", "Selbstzahler:in"]} variant="soft" className="sm:col-span-2" />
</form>
)}
</StepPanel>
<div className="mt-8 flex items-center justify-between gap-4">
<button type="button" onClick={() => setStep((s) => Math.max(0, s - 1))} className={cn("cursor-pointer text-sm font-medium text-t-muted hover:text-t-fg", step === 0 && "invisible")}>
Zurück
</button>
{step < 2 ? (
<button
type="button"
disabled={!canContinue}
onClick={() => setStep((s) => s + 1)}
className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg transition-opacity disabled:opacity-40"
>
Weiter <ArrowRightIcon className="size-4" />
</button>
) : (
<button type="submit" form="praxis-booking" className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">
Termin anfragen <ArrowRightIcon className="size-4" />
</button>
)}
</div>
</>
)}
</div>
</Reveal>
</div>
</section>
)
}

View file

@ -0,0 +1,205 @@
import { defineTemplate, RICH_HINT, unsplash } from "../../lib/content"
export const restaurant = defineTemplate({
key: "restaurant",
name: "Osteria",
industry: "Restaurant & Café",
description: "Warm und atmosphärisch: großes Bild-Intro, Speisekarte mit Kategorien, Galerie zum Durchscrollen und Reservierung.",
cover: unsplash("1517248135467-4c7edcad34c4", 1200),
samples: {
inquiry: "Hallo, wir möchten am 12. Dezember unsere Weihnachtsfeier bei Ihnen machen – etwa 25 Personen, gern mit Menü und Weinbegleitung. Geht das, und was würde es ungefähr kosten? Viele Grüße, Sandra Becker",
topic: "Neues Herbstmenü mit Trüffel ab nächster Woche",
},
palettes: [
{ key: "glut", label: "Glut", dark: true, bg: "#14110f", fg: "#f3ece2", muted: "#a89c8c", surface: "#1f1a17", accent: "#d9a25f" },
{ key: "leinen", label: "Leinen", bg: "#f4efe6", fg: "#1f1a16", muted: "#6f655b", surface: "#ebe3d6", accent: "#a3472d" },
{ key: "olive", label: "Olive", dark: true, bg: "#181d17", fg: "#ece8dc", muted: "#9aa08f", surface: "#222920", accent: "#c9b26b" },
],
fonts: [
{ key: "klassisch", label: "Fraunces & Manrope" },
{ key: "modern", label: "Instrument Serif & Sans" },
],
sections: [
{
key: "brand",
label: "Marke",
fields: [
{ key: "name", label: "Name" },
{ key: "tagline", label: "Untertitel" },
],
},
{
key: "hero",
label: "Startbereich",
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "image", label: "Bild", type: "image" },
{ key: "cta", label: "Button" },
{ key: "hours", label: "Öffnungszeiten (kurz)" },
],
},
{
key: "marquee",
label: "Laufband",
optional: true,
fields: [{ key: "items", label: "Begriffe", type: "list", itemLabel: "Begriff", fields: [{ key: "text", label: "Text" }] }],
},
{
key: "about",
label: "Über uns",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "signature", label: "Unterschrift" },
{ key: "image", label: "Bild groß", type: "image" },
{ key: "imageSecondary", label: "Bild klein", type: "image" },
],
},
{
key: "menu",
label: "Speisekarte",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{
key: "dishes",
label: "Gerichte",
type: "list",
itemLabel: "Gericht",
fields: [
{ key: "category", label: "Kategorie (wird zum Reiter)" },
{ key: "name", label: "Name" },
{ key: "description", label: "Beschreibung", type: "textarea" },
{ key: "price", label: "Preis" },
],
},
],
},
{
key: "gallery",
label: "Galerie",
optional: true,
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{
key: "images",
label: "Bilder",
type: "list",
itemLabel: "Bild",
fields: [
{ key: "src", label: "Bild", type: "image" },
{ key: "caption", label: "Bildunterschrift" },
],
},
],
},
{
key: "quote",
label: "Gästestimme",
optional: true,
fields: [
{ key: "text", label: "Zitat", type: "textarea" },
{ key: "author", label: "Von" },
],
},
{
key: "reservation",
label: "Reservierung & Kontakt",
fields: [
{ key: "eyebrow", label: "Überzeile" },
{ key: "title", label: "Überschrift", type: "textarea", hint: RICH_HINT },
{ key: "text", label: "Text", type: "textarea" },
{ key: "address", label: "Adresse" },
{ key: "phone", label: "Telefon" },
{ key: "email", label: "E-Mail" },
{
key: "hours",
label: "Öffnungszeiten",
type: "list",
itemLabel: "Zeile",
fields: [
{ key: "days", label: "Tage" },
{ key: "time", label: "Uhrzeit" },
],
},
],
},
],
defaults: {
brand: { name: "Lumen", tagline: "Osteria & Weinbar" },
hero: {
eyebrow: "Osteria & Weinbar · seit 2009",
title: "Wo Abende\n*länger* werden.",
text: "Saisonale italienische Küche, natürliche Weine und ein Raum, in dem man gern bleibt – mitten in der Altstadt.",
image: unsplash("1517248135467-4c7edcad34c4"),
cta: "Tisch reservieren",
hours: "Heute geöffnet · 17 – 23 Uhr",
},
marquee: {
items: [{ text: "Hausgemachte Pasta" }, { text: "Natürliche Weine" }, { text: "Regionale Zutaten" }, { text: "Offene Küche" }],
},
about: {
eyebrow: "Über uns",
title: "Wir kochen, was die Saison uns *schenkt.*",
text: "Jeden Morgen beginnt unser Tag auf dem Markt. Was dort am besten aussieht, landet abends auf dem Teller – ehrlich, langsam gekocht und mit viel Liebe zum Detail. Dazu Weine von kleinen Winzern, die wir persönlich kennen.",
signature: "Marco & Lena, Gastgeber",
image: unsplash("1577219491135-ce391730fb2c", 1400),
imageSecondary: unsplash("1551218808-94e220e084d2", 800),
},
menu: {
eyebrow: "Speisekarte",
title: "Unsere *Karte*",
text: "Sie wechselt mit der Saison. Fragen Sie gern nach unseren Tagesempfehlungen.",
dishes: [
{ category: "Antipasti", name: "Vitello Tonnato", description: "Zart rosa Kalbsrücken, Thunfischcreme, Kapernäpfel", price: "16" },
{ category: "Antipasti", name: "Burrata", description: "Cremige Burrata, Erbstomaten, Basilikumöl", price: "14" },
{ category: "Antipasti", name: "Carpaccio di Manzo", description: "Rinderfilet, Rucola, 24 Monate gereifter Parmesan", price: "17" },
{ category: "Primi", name: "Tagliatelle al Ragù", description: "Hausgemachte Tagliatelle, sechs Stunden geschmortes Ragù", price: "19" },
{ category: "Primi", name: "Risotto ai Funghi", description: "Carnaroli-Reis, Steinpilze, frischer Thymian", price: "21" },
{ category: "Primi", name: "Ravioli di Ricotta", description: "Ricotta, braune Salbeibutter, Zitronenabrieb", price: "18" },
{ category: "Secondi", name: "Branzino", description: "Wolfsbarsch vom Grill, Fenchel, Blutorange", price: "29" },
{ category: "Secondi", name: "Tagliata di Manzo", description: "Rinderhüfte, Rosmarinkartoffeln, Rucola", price: "32" },
{ category: "Dolci", name: "Tiramisù", description: "Nach dem Rezept der Nonna", price: "9" },
{ category: "Dolci", name: "Panna Cotta", description: "Bourbon-Vanille, Waldbeeren", price: "8" },
],
},
gallery: {
eyebrow: "Einblicke",
title: "Ein Abend\nbei *uns.*",
images: [
{ src: unsplash("1414235077428-338989a2e8c0", 1400), caption: "Der Gastraum" },
{ src: unsplash("1504674900247-0877df9cc836", 1400), caption: "Aus der Küche" },
{ src: unsplash("1510812431401-41d2bd2722f3", 1400), caption: "Natürliche Weine" },
{ src: unsplash("1555396273-367ea4eb4db5", 1400), caption: "Unsere Bar" },
{ src: unsplash("1424847651672-bf20a4b0982b", 1400), caption: "Teilen erwünscht" },
{ src: unsplash("1559339352-11d035aa65de", 1400), caption: "Die Sommerterrasse" },
],
},
quote: {
text: "Man kommt wegen der Pasta und bleibt wegen der Atmosphäre. Für uns der schönste Abend der Woche.",
author: "Julia & Tom, Stammgäste",
},
reservation: {
eyebrow: "Reservierung",
title: "Reservieren Sie\nIhren *Tisch.*",
text: "Für Gruppen ab acht Personen oder private Feiern rufen Sie uns am besten direkt an.",
address: "Weinsteige 12, 70180 Stuttgart",
phone: "0711 123 45 67",
email: "ciao@lumen-osteria.de",
hours: [
{ days: "Dienstag – Donnerstag", time: "17 – 23 Uhr" },
{ days: "Freitag & Samstag", time: "17 – 24 Uhr" },
{ days: "Sonntag & Montag", time: "Ruhetag" },
],
},
},
})
export type RestaurantData = typeof restaurant.defaults

View file

@ -0,0 +1,22 @@
import { Fraunces, Instrument_Sans, Instrument_Serif, Manrope } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – die Vorlagen-Seiten binden alle Vorlagen ein; der Browser lädt nur die Schriften,
// die die angezeigte Variante tatsächlich nutzt.
const fraunces = Fraunces({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-fraunces", preload: false })
const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope", preload: false })
const instrumentSerif = Instrument_Serif({ subsets: ["latin"], weight: "400", style: ["normal", "italic"], variable: "--font-instrument-serif", preload: false })
const instrumentSans = Instrument_Sans({ subsets: ["latin"], variable: "--font-instrument-sans", preload: false })
export const fonts: Record<string, FontPair> = {
klassisch: {
className: `${fraunces.variable} ${manrope.variable}`,
display: "var(--font-fraunces)",
body: "var(--font-manrope)",
},
modern: {
className: `${instrumentSerif.variable} ${instrumentSans.variable}`,
display: "var(--font-instrument-serif)",
body: "var(--font-instrument-sans)",
},
}

View file

@ -0,0 +1,386 @@
"use client"
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react"
import { useRef, useState, type InputHTMLAttributes } from "react"
import { ArrowRightIcon } 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 { HorizontalScroll, Marquee, ParallaxImage, Reveal, RevealWords, Rich, ease, useScrollNav, wordmarkSize } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { restaurant, type RestaurantData } from "./definition"
import { fonts } from "./fonts"
const em = "italic text-t-accent"
const eyebrow = "mb-6 text-[11px] font-medium uppercase tracking-[0.32em] text-t-accent"
const container = "mx-auto w-full max-w-[1400px] px-6 md:px-10"
export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
const { brand, hero, marquee, about, menu, gallery, quote, reservation } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#karte", label: "Karte", visible: show("menu") },
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
{ href: "#galerie", label: "Einblicke", visible: show("gallery") },
{ href: "#reservieren", label: "Kontakt", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={restaurant} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
<Nav name={brand.name} cta={hero.cta} links={links} />
<Hero hero={hero} />
{show("marquee") && (
<section 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">
<span className="px-8 md:px-14">{item.text}</span>
<span className="text-2xl not-italic text-t-accent md:text-3xl">✦</span>
</span>
))}
</Marquee>
</section>
)}
{show("about") && (
<section 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
text={about.title}
emClassName={em}
className="font-t-display text-[clamp(2.5rem,5.2vw,4.75rem)] leading-[1.02] tracking-[-0.015em]"
/>
<Reveal delay={0.15}>
<p className="mt-10 max-w-lg text-lg leading-relaxed text-t-muted">{about.text}</p>
</Reveal>
<Reveal delay={0.25}>
<p className="mt-10 font-t-display text-2xl italic">— {about.signature}</p>
</Reveal>
</div>
<div className="relative md:col-span-5 md:col-start-8">
<ParallaxImage src={about.image} offset={50} className="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" />
<Reveal delay={0.2} className="absolute -bottom-12 -left-4 w-[46%] md:-left-28">
<div className="relative aspect-square overflow-hidden border-[10px] border-t-bg">
<TImage src={about.imageSecondary} sizes="(min-width: 768px) 20vw, 45vw" />
</div>
</Reveal>
</div>
</section>
)}
{show("menu") && <Menu menu={menu} />}
{show("gallery") && (
<div id="galerie">
<HorizontalScroll
trackClassName="items-start gap-5 px-6 md:gap-8 md:px-10"
header={
<div className={cn(container, "mb-10 flex items-end justify-between md:mb-14")}>
<div>
<p className={eyebrow}>{gallery.eyebrow}</p>
<h2 className="font-t-display text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.98]">
<Rich text={gallery.title} emClassName={em} />
</h2>
</div>
<p className="hidden text-sm text-t-muted md:block">Scrollen zum Entdecken →</p>
</div>
}
>
{gallery.images.map((image, i) => (
<figure key={i} className={cn("group shrink-0", i % 2 === 1 && "mt-12 md:mt-20")}>
<div className="relative h-[42vh] w-[74vw] overflow-hidden md:h-[50vh] md:w-[32vw]">
<TImage
src={image.src}
alt={image.caption}
sizes="(min-width: 768px) 32vw, 74vw"
className="transition-transform duration-[1.4s] ease-out group-hover:scale-105"
/>
</div>
<figcaption className="mt-4 flex items-baseline gap-3 text-sm text-t-muted">
<span className="font-t-display text-base italic text-t-accent">{String(i + 1).padStart(2, "0")}</span>
{image.caption}
</figcaption>
</figure>
))}
</HorizontalScroll>
</div>
)}
{show("quote") && (
<section 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>
<RevealWords
as="p"
text={quote.text}
className="mt-8 font-t-display text-[clamp(1.9rem,4vw,3.5rem)] italic leading-[1.15] tracking-[-0.01em]"
/>
<Reveal delay={0.3}>
<p className="mt-10 text-[11px] uppercase tracking-[0.32em] text-t-muted">{quote.author}</p>
</Reveal>
</section>
)}
<section id="reservieren" className="border-t border-t-fg/10">
<div className={cn(container, "grid gap-16 py-28 md:grid-cols-2 md:gap-24 md:py-40")}>
<div>
<Reveal><p className={eyebrow}>{reservation.eyebrow}</p></Reveal>
<RevealWords
text={reservation.title}
emClassName={em}
className="font-t-display text-[clamp(2.5rem,5vw,4.5rem)] leading-[1.0] tracking-[-0.015em]"
/>
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{reservation.text}</p>
<dl className="mt-12 divide-y divide-t-fg/10 border-y border-t-fg/10">
{reservation.hours.map((row, i) => (
<div key={i} className="flex items-baseline justify-between gap-6 py-4">
<dt className="text-t-muted">{row.days}</dt>
<dd className="font-t-display text-xl">{row.time}</dd>
</div>
))}
</dl>
<div className="mt-10 grid gap-1.5 text-sm">
<p className="text-t-muted">{reservation.address}</p>
<a href={`tel:${reservation.phone.replace(/\s/g, "")}`} className="w-fit hover:text-t-accent">{reservation.phone}</a>
<a href={`mailto:${reservation.email}`} className="w-fit hover:text-t-accent">{reservation.email}</a>
</div>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="bg-t-surface p-8 md:p-12"
success={
<div className="py-10">
<p className="font-t-display text-5xl italic text-t-accent">Grazie!</p>
<p className="mt-4 text-t-muted">Wir bestätigen Ihre Reservierung in Kürze per E-Mail.</p>
</div>
}
>
<p className="font-t-display text-3xl">Tischanfrage</p>
<div className="mt-8 grid gap-7 sm:grid-cols-2">
<Line label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
<Line label="Datum" name="date" type="date" required />
<Line label="Uhrzeit" name="time" type="time" defaultValue="19:00" required />
<Line label="Personen" name="guests" type="number" min={1} max={12} defaultValue={2} />
<Line label="Telefon" name="phone" type="tel" autoComplete="tel" />
<Line label="Anmerkungen" name="note" placeholder="Allergien, Anlass, Wunschplatz …" className="sm:col-span-2" />
</div>
<button
type="submit"
className="group mt-10 flex w-full items-center justify-center gap-3 rounded-full bg-t-accent py-4 text-sm font-medium text-t-accent-fg transition-transform hover:scale-[1.01]"
>
Anfrage senden
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="overflow-hidden border-t border-t-fg/10 pt-16">
<div className={cn(container, "flex flex-col gap-8 text-sm text-t-muted md:flex-row md:items-center md:justify-between")}>
<p>{brand.name} · {brand.tagline} · {reservation.address}</p>
<div className="flex gap-8">
<a href="#" className="hover:text-t-fg">Instagram</a>
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
<motion.p
aria-hidden
initial={{ y: "35%", opacity: 0 }}
whileInView={{ y: "0%", opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.4, ease }}
style={{ fontSize: wordmarkSize(brand.name) }}
className="mt-10 select-none whitespace-nowrap text-center font-t-display italic leading-[0.8] tracking-[-0.03em] text-t-accent"
>
{brand.name}
</motion.p>
</footer>
</TemplateShell>
)
}
function Nav({ name, cta, links }: { name: string; 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 border-b transition-colors duration-500",
scrolled ? "border-t-fg/10 bg-t-bg/80 backdrop-blur-xl" : "border-transparent"
)}
>
<nav className={cn(container, "flex h-20 items-center justify-between")}>
<a href="#top" className="font-t-display text-2xl italic tracking-tight">{name}</a>
<div className="hidden items-center gap-10 text-sm md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="group relative text-t-fg/75 transition-colors hover:text-t-fg">
{link.label}
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-0 bg-t-accent transition-transform duration-500 group-hover:scale-x-100" />
</a>
))}
</div>
<a
href="#reservieren"
className="rounded-full border border-t-fg/25 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 Hero({ hero }: { hero: RestaurantData["hero"] }) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const y = useTransform(scrollYProgress, [0, 1], ["0%", "28%"])
const opacity = useTransform(scrollYProgress, [0, 0.65], [1, 0])
return (
<section ref={ref} id="top" className="relative flex min-h-svh items-end overflow-hidden">
<motion.div className="absolute inset-0" style={{ y }}>
<motion.div className="absolute inset-0" initial={{ scale: 1.18 }} animate={{ scale: 1 }} transition={{ duration: 2.6, ease }}>
<TImage src={hero.image} eager />
</motion.div>
</motion.div>
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-t-bg/30 to-t-bg/70" />
<motion.div style={{ opacity }} className={cn(container, "relative pt-40 pb-16 md:pb-24")}>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.3 }}
className={eyebrow}
>
{hero.eyebrow}
</motion.p>
<RevealWords
as="h1"
onMount
delay={0.45}
text={hero.title}
emClassName={em}
className="font-t-display text-[clamp(3.4rem,10vw,10rem)] leading-[0.9] tracking-[-0.025em]"
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 1.1 }}
className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between"
>
<p className="max-w-md text-base leading-relaxed text-t-fg/80 md:text-lg">{hero.text}</p>
<div className="flex flex-wrap items-center gap-6">
<span className="flex items-center gap-2.5 text-sm text-t-fg/75">
<span className="size-2 rounded-full bg-emerald-400 shadow-[0_0_12px] shadow-emerald-400/70" />
{hero.hours}
</span>
<a
href="#reservieren"
className="group inline-flex items-center gap-3 rounded-full bg-t-accent px-7 py-4 text-sm font-medium text-t-accent-fg transition-transform hover:scale-[1.03]"
>
{hero.cta}
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</a>
</div>
</motion.div>
</motion.div>
</section>
)
}
function Menu({ menu }: { menu: RestaurantData["menu"] }) {
const categoryOf = (dish: { category: string }) => dish.category.trim() || "Weitere"
const categories = [...new Set(menu.dishes.map(categoryOf))]
const [selected, setSelected] = useState(0)
const current = categories[Math.min(selected, categories.length - 1)]
return (
<section id="karte" className="bg-t-surface py-28 md:py-40">
<div className="mx-auto max-w-[1100px] px-6 md:px-10">
<div className="text-center">
<Reveal><p className={eyebrow}>{menu.eyebrow}</p></Reveal>
<RevealWords
text={menu.title}
emClassName={em}
className="font-t-display text-[clamp(2.75rem,6vw,5.5rem)] leading-none tracking-[-0.02em]"
/>
<Reveal delay={0.15}>
<p className="mx-auto mt-6 max-w-md text-t-muted">{menu.text}</p>
</Reveal>
</div>
<Reveal delay={0.2} className="mt-14 flex flex-wrap justify-center gap-2">
<div role="tablist" aria-label={menu.eyebrow} className="flex flex-wrap justify-center gap-2">
{categories.map((category, i) => (
<button
key={category}
type="button"
role="tab"
aria-selected={category === current}
onClick={() => setSelected(i)}
className={cn(
"relative cursor-pointer rounded-full px-5 py-2.5 text-sm transition-colors duration-300",
category === current ? "text-t-accent-fg" : "text-t-fg/65 hover:text-t-fg"
)}
>
{category === current && (
<motion.span
layoutId="restaurant-menu-tab"
className="absolute inset-0 rounded-full bg-t-accent"
transition={{ type: "spring", bounce: 0.18, duration: 0.6 }}
/>
)}
<span className="relative">{category}</span>
</button>
))}
</div>
</Reveal>
<AnimatePresence mode="wait">
<motion.ul
key={current}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.5, ease }}
className="mt-16 grid gap-x-16 gap-y-11 md:grid-cols-2"
>
{menu.dishes.filter((dish) => categoryOf(dish) === current).map((dish, i) => (
<li key={i}>
<div className="flex items-baseline gap-4">
<h3 className="font-t-display text-2xl leading-tight">{dish.name}</h3>
<span className="flex-1 -translate-y-1 border-b border-dotted border-t-fg/25" />
<span className="font-t-display text-xl text-t-accent">{dish.price}</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-t-muted">{dish.description}</p>
</li>
))}
</motion.ul>
</AnimatePresence>
</div>
</section>
)
}
function Line({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return (
<label className={cn("block", className)}>
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">{label}</span>
<input
{...props}
className="mt-2 w-full border-b border-t-fg/20 bg-transparent py-2.5 text-base outline-none transition-colors placeholder:text-t-muted/60 focus:border-t-accent"
/>
</label>
)
}

View file

@ -0,0 +1,106 @@
import { defineTemplate, describeSections, unsplash } from "../../lib/content"
const defaults = {
brand: { name: "Flowdesk", tagline: "Kundenservice, der mitdenkt" },
hero: {
badge: "Neu: KI-Antwortvorschläge",
title: "Support, der sich\n*von selbst* erledigt.",
text: "Flowdesk bündelt E-Mails, Chats und Tickets in einem Posteingang – und beantwortet Routinefragen automatisch, in eurem Ton.",
cta: "Kostenlos testen",
ctaSecondary: "Demo ansehen",
note: "14 Tage gratis · Keine Kreditkarte nötig",
},
logos: {
title: "Über 2.000 Teams arbeiten bereits mit Flowdesk",
items: [{ name: "Nordwerk" }, { name: "Lumio" }, { name: "Kraftbau" }, { name: "Velora" }, { name: "Brightfield" }, { name: "Okuma" }, { name: "Tessin & Co" }],
},
features: {
eyebrow: "Funktionen",
title: "Alles, was dein Team *schneller* macht.",
items: [
{ title: "Ein Posteingang für alles", text: "E-Mail, Website-Chat, WhatsApp und Telefonnotizen an einem Ort – mit Zuweisung und Status." },
{ title: "KI-Antworten", text: "Vorschläge in Sekunden, gelernt aus euren besten Antworten." },
{ title: "Automationen", text: "Regeln, die sortieren, zuweisen und nachfassen." },
{ title: "Live-Analysen", text: "Antwortzeiten, Zufriedenheit und Themen auf einen Blick – ohne Excel-Export." },
{ title: "Integrationen", text: "Shop, CRM und Kalender in wenigen Klicks verbunden." },
{ title: "DSGVO-konform", text: "Server in Frankfurt, AV-Vertrag inklusive." },
],
},
steps: {
eyebrow: "So geht's",
title: "In drei Schritten *live.*",
items: [
{ title: "Verbinden", text: "E-Mail, Website-Chat und WhatsApp in wenigen Minuten anbinden." },
{ title: "Anlernen", text: "Flowdesk lernt aus euren Antworten und Hilfeartikeln." },
{ title: "Zurücklehnen", text: "Routinefragen laufen automatisch – dein Team kümmert sich um den Rest." },
],
},
pricing: {
eyebrow: "Preise",
title: "Faire Preise, die *mitwachsen.*",
discount: "20",
plans: [
{ name: "Starter", price: "29", text: "Für kleine Teams", features: "2 Nutzer\n1.000 Tickets pro Monat\nE-Mail & Chat", highlight: "" },
{ name: "Team", price: "79", text: "Für wachsende Teams", features: "10 Nutzer\nUnbegrenzte Tickets\nKI-Antworten\nAutomationen", highlight: "ja" },
{ name: "Business", price: "199", text: "Für Unternehmen", features: "Unbegrenzte Nutzer\nSSO & Audit-Log\nPersönlicher Ansprechpartner", highlight: "" },
],
},
testimonials: {
title: "Teams lieben *Flowdesk.*",
items: [
{ quote: "Unsere Antwortzeit ist von sechs Stunden auf zwölf Minuten gefallen. Das Team hat endlich Luft für die kniffligen Fälle.", author: "Sarah Lange", role: "Head of Support, Velora", image: unsplash("1544005313-94ddf0286df2", 400) },
{ quote: "Eingerichtet an einem Nachmittag. Seitdem beantwortet Flowdesk zwei Drittel der Anfragen selbst.", author: "Jonas Becker", role: "Gründer, Kraftbau", image: unsplash("1552058544-f2b08422138a", 400) },
{ quote: "Endlich ein Tool, das sich nicht nach Enterprise-Monster anfühlt.", author: "Mira Hoffmann", role: "COO, Lumio", image: unsplash("1517841905240-472988babdf9", 400) },
{ quote: "Die Auswertungen nutzen wir jede Woche im Team-Meeting. Wir sehen sofort, wo es hakt.", author: "David Kühn", role: "Teamlead Service, Nordwerk", image: unsplash("1607990281513-2c110a25bd8c", 400) },
],
},
faq: {
title: "Fragen & *Antworten*",
items: [
{ question: "Wie lange dauert die Einrichtung?", answer: "Die meisten Teams sind in unter einer Stunde startklar. Beim Umzug von einem anderen Tool helfen wir kostenlos." },
{ question: "Antwortet die KI ohne Kontrolle?", answer: "Nur wenn ihr das wollt. Standardmäßig schlägt Flowdesk Antworten vor, und euer Team gibt sie frei." },
{ question: "Wo liegen unsere Daten?", answer: "Ausschließlich auf Servern in Frankfurt am Main. Einen AV-Vertrag gibt es für jeden Tarif." },
{ question: "Kann ich jederzeit kündigen?", answer: "Ja. Monatliche Tarife sind monatlich kündbar, Jahrestarife zum Ende der Laufzeit." },
],
},
cta: {
title: "Bereit für *ruhigere* Support-Tage?",
text: "Starte kostenlos – in fünf Minuten beantwortet Flowdesk die ersten Anfragen.",
button: "Jetzt kostenlos starten",
},
}
export const saas = defineTemplate({
key: "saas",
name: "Launchpad",
industry: "Startup, SaaS & Tech",
description: "Modern und präzise: animiertes Produkt-Mockup mit 3D-Neigung, Bento-Features, Terminal-Ablauf, Preise mit Monat/Jahr-Umschalter und FAQ.",
cover: unsplash("1460925895917-afdab827c52f", 1200),
samples: {
inquiry: "Hallo, wir sind ein Onlineshop mit sechs Support-Mitarbeitenden und rund 3.000 Tickets im Monat (E-Mail und Chat). Was würde Flowdesk für uns kosten, und wie läuft der Umzug von unserem bisherigen Tool? Beste Grüße, Nina Albers",
topic: "Launch: KI-Antwortvorschläge jetzt für alle Teams",
},
palettes: [
{ key: "violett", label: "Violett", dark: true, bg: "#07070b", fg: "#eceef5", muted: "#8b8fa3", surface: "#12121a", accent: "#7c5cff" },
{ key: "aurora", label: "Aurora", dark: true, bg: "#050a09", fg: "#e9f3ef", muted: "#86a097", surface: "#0f1816", accent: "#2ee6a6" },
{ key: "hell", label: "Hell", bg: "#fbfbfd", fg: "#0b0d14", muted: "#5f6577", surface: "#f0f1f6", accent: "#4f46e5" },
],
fonts: [
{ key: "geist", label: "Geist" },
{ key: "sora", label: "Sora & Inter" },
],
sections: describeSections(defaults, {
brand: "Produkt",
hero: "Startbereich",
logos: { label: "Kundenlogos", optional: true, items: { items: "Logo" } },
features: { label: "Funktionen", optional: true, items: { items: "Funktion" } },
steps: { label: "So geht's", optional: true, items: { items: "Schritt" } },
pricing: { label: "Preise", optional: true, items: { plans: "Tarif" } },
testimonials: { label: "Kundenstimmen", optional: true, items: { items: "Stimme" } },
faq: { label: "FAQ", optional: true, items: { items: "Frage" } },
cta: "Abschluss",
}),
defaults,
})
export type SaasData = typeof saas.defaults

View file

@ -0,0 +1,12 @@
import { Geist, Geist_Mono, Sora } from "next/font/google"
import type { FontPair } from "../../lib/types"
// preload: false – siehe restaurant/fonts.ts. Geist Mono steht in beiden Paaren für Code/Terminal bereit.
const geist = Geist({ subsets: ["latin"], variable: "--font-saas-geist", preload: false })
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-saas-mono", preload: false })
const sora = Sora({ subsets: ["latin"], variable: "--font-saas-sora", preload: false })
export const fonts: Record<string, FontPair> = {
geist: { className: `${geist.variable} ${geistMono.variable}`, display: "var(--font-saas-geist)", body: "var(--font-saas-geist)" },
sora: { className: `${sora.variable} ${geistMono.variable}`, display: "var(--font-saas-sora)", body: "var(--font-inter)" },
}

View file

@ -0,0 +1,559 @@
"use client"
import { AnimatePresence, motion, useInView, useMotionValue, useSpring, useTransform } from "motion/react"
import { useEffect, useRef, useState, type PointerEvent } from "react"
import {
ArrowRightIcon,
BotIcon,
ChartNoAxesColumnIcon,
CheckIcon,
InboxIcon,
LockIcon,
MailIcon,
MessageCircleIcon,
PhoneIcon,
PlugIcon,
SparklesIcon,
WorkflowIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { saas, type SaasData } from "./definition"
import { fonts } from "./fonts"
const em = "bg-gradient-to-r from-t-accent to-t-fg bg-clip-text text-transparent"
const container = "mx-auto w-full max-w-[1200px] px-5 md:px-8"
const heading = "font-t-display font-semibold tracking-[-0.035em]"
const mono = { fontFamily: "var(--font-saas-mono), ui-monospace, monospace" }
const card = "rounded-3xl border border-t-fg/10 bg-t-surface/60"
const featureIcons = [InboxIcon, BotIcon, WorkflowIcon, ChartNoAxesColumnIcon, PlugIcon, LockIcon]
function useTypewriter(text: string, active: boolean, speed = 28) {
const [count, setCount] = useState(0)
useEffect(() => {
if (!active) return
const timer = setInterval(() => setCount((c) => (c >= text.length ? c : c + 2)), speed)
return () => clearInterval(timer)
}, [active, text.length, speed])
return text.slice(0, count)
}
function Eyebrow({ children }: { children: string }) {
return <p className="mb-4 text-sm font-medium text-t-accent">{children}</p>
}
export function SaasTemplate({ content }: TemplateProps<SaasData>) {
const { brand, hero, logos, features, steps, pricing, testimonials, faq, cta } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#funktionen", label: "Funktionen", visible: show("features") },
{ href: "#preise", label: "Preise", visible: show("pricing") },
{ href: "#faq", label: "FAQ", visible: show("faq") },
].filter((link) => link.visible)
return (
<TemplateShell def={saas} content={content} fonts={fonts[content.fonts] ?? fonts.geist}>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-[900px] bg-[radial-gradient(50rem_30rem_at_50%_-5%,color-mix(in_oklab,var(--t-accent)_35%,transparent),transparent_70%)]" />
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[900px] [mask-image:linear-gradient(to_bottom,black,transparent)] bg-[size:56px_56px] bg-[linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_6%,transparent)_1px,transparent_1px)]"
/>
<Nav name={brand.name} cta={hero.cta} links={links} />
<section id="top" className={cn(container, "relative pt-36 pb-10 text-center md:pt-44")}>
<motion.a
href="#funktionen"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
className="inline-flex items-center gap-2 rounded-full border border-t-fg/10 bg-t-surface/70 px-4 py-1.5 text-sm backdrop-blur"
>
<SparklesIcon className="size-4 text-t-accent" /> {hero.badge} <ArrowRightIcon className="size-3.5 text-t-muted" />
</motion.a>
<RevealWords
as="h1"
onMount
delay={0.15}
text={hero.title}
emClassName={em}
className={cn(heading, "mx-auto mt-8 max-w-4xl text-[clamp(2.75rem,7vw,5.75rem)] leading-[1.02]")}
/>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
<p className="mx-auto mt-7 max-w-2xl text-lg leading-relaxed text-t-muted md:text-xl">{hero.text}</p>
<div className="mt-9 flex flex-wrap justify-center gap-3">
<a href="#preise" className="rounded-full bg-t-accent px-7 py-3.5 font-medium text-t-accent-fg shadow-[0_10px_40px_-10px_var(--t-accent)] transition-transform hover:-translate-y-0.5">
{hero.cta}
</a>
<a href="#funktionen" className="rounded-full border border-t-fg/15 px-7 py-3.5 font-medium transition-colors hover:bg-t-surface">
{hero.ctaSecondary}
</a>
</div>
<p className="mt-5 text-sm text-t-muted">{hero.note}</p>
</motion.div>
<ProductMockup name={brand.name} />
</section>
{show("logos") && (
<section 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}>
{logos.items.map((logo, i) => (
<span key={i} className={cn("px-10 text-2xl text-t-fg/50", ["font-semibold tracking-tight", "font-light italic", "font-bold uppercase tracking-widest text-lg", "font-medium"][i % 4])}>
{logo.name}
</span>
))}
</Marquee>
</div>
</section>
)}
{show("features") && (
<section 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]")} />
</div>
<div className="mt-14 grid gap-4 md:grid-cols-3">
{features.items.map((item, i) => {
const Icon = featureIcons[i % featureIcons.length]
const wide = i % 4 === 0 || i % 4 === 3
return (
<Reveal key={i} delay={(i % 3) * 0.08} className={cn(wide && "md:col-span-2")}>
<article className={cn(card, "group flex h-full min-h-72 flex-col overflow-hidden p-7 transition-colors hover:border-t-accent/40")}>
<div className="relative mb-6 flex-1">
<FeatureVisual index={i} />
</div>
<h3 className="flex items-center gap-2 text-lg font-semibold">
<Icon className="size-5 text-t-accent" /> {item.title}
</h3>
<p className="mt-2 text-t-muted">{item.text}</p>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("steps") && <Steps steps={steps} name={brand.name} />}
{show("pricing") && <Pricing pricing={pricing} />}
{show("testimonials") && (
<section 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) => (
<Reveal key={i} delay={(i % 2) * 0.1} className="mb-4 break-inside-avoid">
<figure className={cn(card, "p-7")}>
<blockquote className="text-lg leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<span className="relative size-11 overflow-hidden rounded-full bg-t-surface">
<TImage src={item.image} alt={item.author} sizes="44px" />
</span>
<span className="text-sm">
<span className="block font-semibold">{item.author}</span>
<span className="text-t-muted">{item.role}</span>
</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</section>
)}
{show("faq") && (
<section 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}
className="mt-12 divide-y divide-t-fg/10 border-y border-t-fg/10"
questionClassName="py-6 text-lg font-medium"
answerClassName="pb-6 leading-relaxed text-t-muted"
/>
</section>
)}
<section className={cn(container, "pb-24")}>
<Reveal>
<div className="relative overflow-hidden rounded-[2.5rem] border border-t-fg/10 bg-t-surface px-6 py-20 text-center md:py-28">
<div aria-hidden className="absolute inset-0 bg-[radial-gradient(40rem_20rem_at_50%_120%,color-mix(in_oklab,var(--t-accent)_45%,transparent),transparent_70%)]" />
<RevealWords text={cta.title} emClassName={em} className={cn(heading, "relative mx-auto max-w-3xl text-[clamp(2.25rem,5vw,4.25rem)] leading-[1.05]")} />
<p className="relative mx-auto mt-5 max-w-xl text-lg text-t-muted">{cta.text}</p>
<a href="#preise" className="relative mt-9 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-medium text-t-accent-fg">
{cta.button} <ArrowRightIcon className="size-4" />
</a>
</div>
</Reveal>
</section>
<footer className={cn(container, "flex flex-col gap-6 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p><span className="font-semibold 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>
<a href="#" className="hover:text-t-fg">AGB</a>
</div>
</footer>
</TemplateShell>
)
}
function Logo({ name }: { name: string }) {
return (
<a href="#top" className="flex items-center gap-2.5 font-semibold tracking-tight">
<span className="size-7 rounded-lg bg-gradient-to-br from-t-accent to-t-fg/80 shadow-[0_0_20px_-4px_var(--t-accent)]" />
{name}
</a>
)
}
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
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-14 max-w-[1100px] items-center justify-between gap-6 rounded-full border px-4 transition-all duration-500",
scrolled ? "border-t-fg/10 bg-t-bg/75 backdrop-blur-xl" : "border-transparent"
)}
>
<Logo name={name} />
<div className="hidden items-center gap-8 text-sm text-t-muted md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="transition-colors hover:text-t-fg">{link.label}</a>
))}
</div>
<div className="flex items-center gap-2">
<a href="#" className="hidden rounded-full px-4 py-2 text-sm text-t-muted hover:text-t-fg sm:block">Login</a>
<a href="#preise" className="rounded-full bg-t-fg px-4 py-2 text-sm font-medium text-t-bg">{cta}</a>
</div>
</nav>
</motion.header>
)
}
const tickets = [
{ name: "Anna Weber", text: "Wo bleibt meine Bestellung #4821?", time: "2 Min", icon: MailIcon },
{ name: "Ben Schulz", text: "Kann ich die Größe noch ändern?", time: "8 Min", icon: MessageCircleIcon },
{ name: "Clara Yilmaz", text: "Rückruf wegen Rechnung", time: "15 Min", icon: PhoneIcon },
{ name: "David Ott", text: "Danke, alles angekommen!", time: "1 Std", icon: MailIcon },
]
function ProductMockup({ name }: { name: string }) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" })
const x = useMotionValue(0)
const y = useMotionValue(0)
const rotateX = useSpring(useTransform(y, [-0.5, 0.5], [7, -7]), { stiffness: 120, damping: 20 })
const rotateY = useSpring(useTransform(x, [-0.5, 0.5], [-9, 9]), { stiffness: 120, damping: 20 })
const reply = useTypewriter(
"Hallo Anna, deine Bestellung #4821 ist heute Morgen rausgegangen und kommt morgen bis 12 Uhr an. Hier ist dein Tracking-Link – melde dich gern, falls noch etwas offen ist!",
inView
)
function onMove(event: PointerEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect()
x.set((event.clientX - rect.left) / rect.width - 0.5)
y.set((event.clientY - rect.top) / rect.height - 0.5)
}
return (
<div
ref={ref}
onPointerMove={onMove}
onPointerLeave={() => {
x.set(0)
y.set(0)
}}
style={{ perspective: 1600 }}
className="relative mx-auto mt-16 max-w-5xl text-left md:mt-20"
>
<div aria-hidden className="absolute inset-x-10 top-10 bottom-0 rounded-[3rem] bg-t-accent/35 blur-3xl" />
<motion.div
style={{ rotateX, rotateY }}
initial={{ opacity: 0, y: 80 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.2, ease, delay: 0.5 }}
className="relative overflow-hidden rounded-2xl border border-t-fg/10 bg-t-bg shadow-[0_50px_100px_-30px_rgba(0,0,0,0.5)]"
>
<div className="flex items-center gap-2 border-b border-t-fg/10 px-4 py-3">
<span className="size-3 rounded-full bg-red-400/80" />
<span className="size-3 rounded-full bg-amber-400/80" />
<span className="size-3 rounded-full bg-emerald-400/80" />
<span className="mx-auto rounded-md bg-t-surface px-3 py-1 text-xs text-t-muted" style={mono}>app.{name.toLowerCase().replace(/[^a-z0-9]/g, "")}.de/inbox</span>
</div>
<div className="grid min-h-[22rem] md:grid-cols-[170px_1fr_1.25fr]">
<aside className="hidden space-y-1 border-r border-t-fg/10 p-3 text-sm md:block">
{[["Posteingang", "24"], ["Mir zugewiesen", "6"], ["Automationen", ""], ["Berichte", ""]].map(([label, count], i) => (
<p key={label} className={cn("flex justify-between rounded-lg px-3 py-2", i === 0 ? "bg-t-surface font-medium" : "text-t-muted")}>
{label}
{count && <span className="rounded-full bg-t-accent px-1.5 text-xs text-t-accent-fg">{count}</span>}
</p>
))}
</aside>
<ul className="border-r border-t-fg/10">
{tickets.map((ticket, i) => (
<motion.li
key={ticket.name}
initial={{ opacity: 0, x: -12 }}
animate={inView ? { opacity: 1, x: 0 } : {}}
transition={{ delay: 0.8 + i * 0.12 }}
className={cn("flex gap-3 border-b border-t-fg/5 px-4 py-3.5", i === 0 && "bg-t-accent/10")}
>
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-t-surface text-sm font-semibold">{ticket.name.charAt(0)}</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between text-sm font-medium">
{ticket.name}
<span className="text-xs font-normal text-t-muted">{ticket.time}</span>
</span>
<span className="flex items-center gap-1.5 truncate text-xs text-t-muted">
<ticket.icon className="size-3 shrink-0" /> {ticket.text}
</span>
</span>
</motion.li>
))}
</ul>
<div className="hidden flex-col gap-3 p-5 md:flex">
<p className="w-fit max-w-[85%] rounded-2xl rounded-bl-md bg-t-surface px-4 py-3 text-sm">Hallo, wo bleibt meine Bestellung #4821? Ich brauche sie bis Freitag.</p>
<div className="rounded-2xl border border-t-accent/40 bg-t-accent/5 p-4">
<p className="flex items-center gap-1.5 text-xs font-medium text-t-accent">
<SparklesIcon className="size-3.5" /> KI-Vorschlag
</p>
<p className="mt-2 min-h-[5.5rem] text-sm leading-relaxed">
{reply}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-t-accent align-middle" />
</p>
<div className="mt-3 flex gap-2">
<span className="rounded-lg bg-t-accent px-3 py-1.5 text-xs font-medium text-t-accent-fg">Senden</span>
<span className="rounded-lg border border-t-fg/15 px-3 py-1.5 text-xs">Bearbeiten</span>
</div>
</div>
<div className="mt-auto grid grid-cols-2 gap-2 text-xs">
<p className="rounded-xl bg-t-surface p-3"><span className="block text-lg font-semibold">2 Min</span><span className="text-t-muted">Ø Antwortzeit</span></p>
<p className="rounded-xl bg-t-surface p-3"><span className="block text-lg font-semibold">63 %</span><span className="text-t-muted">automatisch gelöst</span></p>
</div>
</div>
</div>
</motion.div>
</div>
)
}
function FeatureVisual({ index }: { index: number }) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true })
const typed = useTypewriter("Gern! Ich habe dir einen neuen Termin für Donnerstag reserviert …", inView && index % 6 === 1, 45)
const variant = index % 6
return (
<div ref={ref} className="flex h-full min-h-32 items-center justify-center">
{variant === 0 && (
<div className="w-full max-w-sm space-y-2">
{[MailIcon, MessageCircleIcon, PhoneIcon].map((Icon, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ delay: i * 0.15 }}
className="flex items-center gap-3 rounded-xl border border-t-fg/10 bg-t-bg px-3 py-2.5 text-sm"
>
<Icon className="size-4 text-t-accent" />
<span className="h-2 flex-1 rounded-full bg-t-fg/10" />
<span className="h-2 w-10 rounded-full bg-t-accent/40" />
</motion.div>
))}
</div>
)}
{variant === 1 && <p className="w-full rounded-2xl rounded-bl-md bg-t-bg p-4 text-sm leading-relaxed">{typed}<span className="ml-0.5 inline-block h-3.5 w-1 animate-pulse bg-t-accent align-middle" /></p>}
{variant === 2 && (
<div className="w-full space-y-2.5">
{["Rechnungen → Buchhaltung", "VIP-Kunden priorisieren", "Nach 24 h nachfassen"].map((rule, i) => (
<div key={rule} className="flex items-center justify-between rounded-xl bg-t-bg px-3 py-2.5 text-sm">
{rule}
<motion.span className="relative h-5 w-9 rounded-full bg-t-fg/15" animate={inView ? { backgroundColor: "var(--t-accent)" } : {}} transition={{ delay: 0.4 + i * 0.3 }}>
<motion.span className="absolute top-0.5 left-0.5 size-4 rounded-full bg-white" animate={inView ? { x: 16 } : {}} transition={{ delay: 0.4 + i * 0.3 }} />
</motion.span>
</div>
))}
</div>
)}
{variant === 3 && (
<div className="flex h-32 w-full items-end gap-2">
{[38, 52, 45, 70, 62, 84, 78, 96].map((height, i) => (
<motion.span
key={i}
className="flex-1 rounded-t-md bg-gradient-to-t from-t-accent/30 to-t-accent"
initial={{ height: 0 }}
animate={inView ? { height: `${height}%` } : {}}
transition={{ duration: 0.8, ease, delay: i * 0.06 }}
/>
))}
</div>
)}
{variant === 4 && (
<div className="grid grid-cols-4 gap-3">
{[MailIcon, MessageCircleIcon, PhoneIcon, PlugIcon, InboxIcon, WorkflowIcon, BotIcon, ChartNoAxesColumnIcon].map((Icon, i) => (
<motion.span
key={i}
initial={{ opacity: 0, scale: 0.6 }}
animate={inView ? { opacity: 1, scale: 1 } : {}}
transition={{ delay: i * 0.06, type: "spring", bounce: 0.4 }}
className="grid size-12 place-items-center rounded-2xl border border-t-fg/10 bg-t-bg"
>
<Icon className="size-5 text-t-accent" />
</motion.span>
))}
</div>
)}
{variant === 5 && (
<div className="text-center">
<span className="mx-auto grid size-16 place-items-center rounded-2xl bg-t-accent text-t-accent-fg shadow-[0_0_40px_-6px_var(--t-accent)]">
<LockIcon className="size-7" />
</span>
<p className="mt-4 text-xs font-medium tracking-widest text-t-muted uppercase">Hosted in Germany</p>
</div>
)}
</div>
)
}
function Steps({ steps, name }: { steps: SaasData["steps"]; name: string }) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" })
const cli = name.toLowerCase().replace(/[^a-z0-9]/g, "") || "app"
const lines = [
`$ ${cli} connect --mail support@shop.de`,
"✓ Postfach verbunden · 2.341 Nachrichten",
`$ ${cli} learn --help-center`,
"✓ 128 Artikel gelernt",
`$ ${cli} autopilot on`,
"✓ 63 % der Anfragen werden automatisch beantwortet",
]
return (
<section className={cn(container, "grid items-center gap-12 py-24 md:py-32 lg:grid-cols-2")}>
<div>
<Reveal><Eyebrow>{steps.eyebrow}</Eyebrow></Reveal>
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<ol className="mt-10 space-y-6">
{steps.items.map((step, i) => (
<Reveal key={i} delay={i * 0.1}>
<li className="flex gap-5">
<span className="grid size-10 shrink-0 place-items-center rounded-full border border-t-accent/40 text-sm font-semibold text-t-accent" style={mono}>{i + 1}</span>
<div>
<p className="text-lg font-semibold">{step.title}</p>
<p className="mt-1 text-t-muted">{step.text}</p>
</div>
</li>
</Reveal>
))}
</ol>
</div>
<div ref={ref} className="overflow-hidden rounded-2xl border border-t-fg/10 bg-[#0b0b10] text-sm text-zinc-200 shadow-2xl" style={mono}>
<div className="flex gap-2 border-b border-white/10 px-4 py-3">
<span className="size-3 rounded-full bg-white/15" />
<span className="size-3 rounded-full bg-white/15" />
<span className="size-3 rounded-full bg-white/15" />
</div>
<div className="space-y-2 p-6">
{lines.map((line, i) => (
<motion.p
key={line}
initial={{ opacity: 0 }}
animate={inView ? { opacity: 1 } : {}}
transition={{ delay: 0.3 + i * 0.45 }}
className={line.startsWith("✓") ? "text-emerald-400" : ""}
>
{line}
</motion.p>
))}
<span className="inline-block h-4 w-2 animate-pulse bg-zinc-300" />
</div>
</div>
</section>
)
}
function Pricing({ pricing }: { pricing: SaasData["pricing"] }) {
const [yearly, setYearly] = useState(true)
const discount = Math.min(90, Math.max(0, Number(pricing.discount) || 0))
return (
<section id="preise" className={cn(container, "py-24 md:py-32")}>
<div className="mx-auto max-w-2xl text-center">
<Reveal><Eyebrow>{pricing.eyebrow}</Eyebrow></Reveal>
<RevealWords text={pricing.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<div className="mt-8 inline-flex rounded-full border border-t-fg/10 bg-t-surface p-1 text-sm">
{[false, true].map((option) => (
<button
key={String(option)}
type="button"
aria-pressed={yearly === option}
onClick={() => setYearly(option)}
className={cn("relative cursor-pointer rounded-full px-5 py-2", yearly === option ? "text-t-bg" : "text-t-muted")}
>
{yearly === option && <motion.span layoutId="saas-billing" className="absolute inset-0 rounded-full bg-t-fg" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
<span className="relative">{option ? `Jährlich · −${discount} %` : "Monatlich"}</span>
</button>
))}
</div>
</div>
<div className="mt-14 grid gap-4 lg:grid-cols-3">
{pricing.plans.map((plan, i) => {
const featured = Boolean(plan.highlight.trim())
const base = Number(plan.price.replace(",", ".")) || 0
const price = Math.round(yearly ? base * (1 - discount / 100) : base)
return (
<Reveal key={i} delay={i * 0.08}>
<article
className={cn(
"relative flex h-full flex-col rounded-3xl border p-8",
featured ? "border-t-accent bg-t-surface shadow-[0_0_60px_-20px_var(--t-accent)]" : "border-t-fg/10 bg-t-surface/50"
)}
>
{featured && <span className="absolute -top-3 left-8 rounded-full bg-t-accent px-3 py-1 text-xs font-medium text-t-accent-fg">Am beliebtesten</span>}
<h3 className="text-lg font-semibold">{plan.name}</h3>
<p className="text-sm text-t-muted">{plan.text}</p>
<p className="mt-6 flex items-end gap-1">
<span className="relative h-14 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={price}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.4, ease }}
className={cn(heading, "block text-5xl")}
>
{price} €
</motion.span>
</AnimatePresence>
</span>
<span className="pb-2 text-sm text-t-muted">/ Monat</span>
</p>
<ul className="mt-8 flex-1 space-y-3 text-sm">
{splitList(plan.features, "\n").map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<CheckIcon className="size-4 text-t-accent" /> {feature}
</li>
))}
</ul>
<a href="#" className={cn("mt-8 rounded-full py-3 text-center text-sm font-medium transition-opacity hover:opacity-85", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-fg text-t-bg")}>
{plan.name} wählen
</a>
</article>
</Reveal>
)
})}
</div>
</section>
)
}

View file

@ -0,0 +1,56 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { ArrowUpRightIcon, SparklesIcon } from "lucide-react"
import { useAiAccess } from "../../ai/access-context"
import { AI_DEMO_SLUGS, AI_DEMOS, demoHref } from "../../ai/demos"
import type { TemplateContent } from "../../lib/types"
const pill = "flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 font-sans text-[11px] font-medium tracking-wide text-white/90 backdrop-blur-md"
const position = "fixed bottom-[calc(1rem+var(--t-safe-bottom,0px))] left-4 z-[60]"
// Entwurfs-Hinweis unten links; mit freigeschalteten KI-Demos zugleich deren Startmenü
export function DemoBadge({ content }: { content: TemplateContent }) {
const access = useAiAccess()
const [open, setOpen] = useState(false)
const demos = access ? AI_DEMO_SLUGS.filter((slug) => content.ai[AI_DEMOS[slug].feature]) : []
const label = (
<>
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
Entwurf · Since3
</>
)
if (!access || demos.length === 0) return <div className={`pointer-events-none ${position} ${pill}`}>{label}</div>
return (
<div className={`${position} flex flex-col items-start gap-2 font-sans`}>
<AnimatePresence>
{open && (
<motion.nav
aria-label="KI-Demos"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="w-64 rounded-2xl bg-black/80 p-1.5 text-white shadow-2xl backdrop-blur-md"
>
<p className="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wider text-white/50">KI-Demos für Ihren Betrieb</p>
{demos.map((slug) => (
<a key={slug} href={demoHref(access, slug)} className="flex items-center justify-between rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-white/10">
{AI_DEMOS[slug].label}
<ArrowUpRightIcon className="size-4 opacity-60" />
</a>
))}
</motion.nav>
)}
</AnimatePresence>
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className={`${pill} cursor-pointer`}>
{label}
<span className="ml-1 flex items-center gap-1 rounded-full bg-white/15 px-2 py-0.5">
<SparklesIcon className="size-3" /> KI-Demos
</span>
</button>
</div>
)
}

View file

@ -0,0 +1,43 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState, type ReactNode } from "react"
import { ease } from "./motion"
// Formulare der Vorlagen senden nichts – nach dem Absenden erscheint eine Bestätigung mit Demo-Hinweis
export function DemoForm({ children, success, className }: { children: ReactNode; success: ReactNode; className?: string }) {
const [sent, setSent] = useState(false)
return (
<AnimatePresence mode="wait" initial={false}>
{sent ? (
<motion.div
key="sent"
className={className}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
role="status"
>
{success}
<p className="mt-4 text-sm opacity-60">
Demo-Hinweis: Auf Ihrer fertigen Webseite landet diese Anfrage direkt in Ihrem Postfach.
</p>
</motion.div>
) : (
<motion.form
key="form"
className={className}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.4, ease }}
onSubmit={(event) => {
event.preventDefault()
setSent(true)
}}
>
{children}
</motion.form>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,58 @@
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"
import { cn } from "@/lib/utils"
// Formularfelder der Vorlagen in vier Grundstilen; Farben kommen aus der Umgebung (currentColor, t-accent)
export type FieldVariant = "line" | "box" | "soft" | "glass"
const labelStyle: Record<FieldVariant, string> = {
line: "text-[11px] uppercase tracking-[0.22em] opacity-60",
box: "text-xs font-medium opacity-70",
soft: "px-1 text-xs font-medium opacity-70",
glass: "text-xs font-medium opacity-70",
}
const controlStyle: Record<FieldVariant, string> = {
line: "mt-2 w-full border-b border-current/20 bg-transparent py-2.5 outline-none transition-colors focus:border-t-accent",
box: "mt-1.5 w-full rounded-xl border border-current/15 bg-transparent px-4 py-3 outline-none transition-colors focus:border-t-accent",
soft: "mt-1.5 w-full rounded-2xl bg-t-surface px-4 py-3.5 outline-none ring-2 ring-transparent transition focus:ring-t-accent/50",
glass: "mt-1.5 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none transition-colors focus:border-t-accent",
}
type Shared = { label: string; variant?: FieldVariant; className?: string }
function Wrap({ label, variant, className, children }: Shared & { variant: FieldVariant; children: ReactNode }) {
return (
<label className={cn("block", className)}>
<span className={labelStyle[variant]}>{label}</span>
{children}
</label>
)
}
export function TField({ label, variant = "box", className, ...props }: Shared & InputHTMLAttributes<HTMLInputElement>) {
return (
<Wrap label={label} variant={variant} className={className}>
<input {...props} className={controlStyle[variant]} />
</Wrap>
)
}
export function TArea({ label, variant = "box", className, rows = 4, ...props }: Shared & TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<Wrap label={label} variant={variant} className={className}>
<textarea {...props} rows={rows} className={cn(controlStyle[variant], "resize-none")} />
</Wrap>
)
}
export function TSelect({ label, variant = "box", className, options, ...props }: Shared & SelectHTMLAttributes<HTMLSelectElement> & { options: string[] }) {
return (
<Wrap label={label} variant={variant} className={className}>
<select {...props} className={cn(controlStyle[variant], "[&>option]:text-zinc-900")}>
{options.map((option) => (
<option key={option}>{option}</option>
))}
</select>
</Wrap>
)
}

View file

@ -0,0 +1,38 @@
"use client"
import { useSyncExternalStore } from "react"
function subscribe(onChange: () => void) {
const timer = setInterval(onChange, 1000)
return () => clearInterval(timer)
}
// Aktuelle Zeit im Sekundentakt; auf dem Server null, damit nichts beim Hydrieren abweicht
export function useNow() {
return useSyncExternalStore(subscribe, () => Math.floor(Date.now() / 1000) * 1000, () => null)
}
// "2026-11-30" gilt bis Tagesende (lokale Zeit), "2026-11-19T09:00" ab genau dieser Uhrzeit
export function useCountdown(target: string) {
const now = useNow()
const end = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(target) ? `${target}T23:59:59` : target)
if (now === null || Number.isNaN(end)) return null
const diff = Math.max(0, end - now)
return {
days: Math.floor(diff / 86_400_000),
hours: Math.floor(diff / 3_600_000) % 24,
minutes: Math.floor(diff / 60_000) % 60,
seconds: Math.floor(diff / 1000) % 60,
done: diff === 0,
}
}
// "08:30" → Minuten seit Mitternacht, sonst null
export function minutesOf(time: string) {
const match = time.trim().match(/^(\d{1,2}):(\d{2})$/)
return match ? Number(match[1]) * 60 + Number(match[2]) : null
}
export const splitList = (value: string, separator: string | RegExp = ",") =>
value.split(separator).map((part) => part.trim()).filter(Boolean)

View file

@ -0,0 +1,43 @@
import Image from "next/image"
import { cn } from "@/lib/utils"
function isImageUrl(src: string) {
try {
const { protocol } = new URL(src)
return protocol === "https:" || protocol === "http:"
} catch {
return false
}
}
// Bild als Füllung des (relativ positionierten) Elternelements. Unsplash läuft ü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
alt?: string
className?: string
sizes?: string
eager?: boolean
}) {
if (!isImageUrl(src)) {
return (
<div
aria-hidden
className={cn("absolute inset-0 bg-[linear-gradient(135deg,var(--t-surface),color-mix(in_oklab,var(--t-muted)_35%,var(--t-surface)))]", className)}
/>
)
}
return (
<Image
src={src}
alt={alt}
fill
sizes={sizes}
unoptimized={!src.startsWith("https://images.unsplash.com/")}
loading={eager ? "eager" : undefined}
fetchPriority={eager ? "high" : undefined}
className={cn("object-cover", className)}
/>
)
}

View file

@ -0,0 +1,334 @@
"use client"
import {
animate,
AnimatePresence,
motion,
useInView,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useScroll,
useTransform,
type MotionValue,
} from "motion/react"
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"
import { PlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "./media"
export const ease = [0.22, 1, 0.36, 1] as const
const inView = { once: true, margin: "0px 0px -10% 0px" } as const
type Token = { word: string; em: boolean }
// Zeilen per "\n", *Wörter in Sternchen* werden hervorgehoben (kursiv/Akzent, je nach Vorlage)
export function parseRich(text: string): Token[][] {
let em = false
return text.split("\n").map((line) =>
line.split(" ").filter(Boolean).map((raw) => {
let word = raw
const opens = word.startsWith("*")
if (opens) word = word.slice(1)
const closes = word.endsWith("*")
if (closes) word = word.slice(0, -1)
if (opens) em = true
const token = { word, em }
if (closes) em = false
return token
})
)
}
// Navigation: bekommt nach dem ersten Scrollen einen Hintergrund, versteckt sich beim Runterscrollen
export function useScrollNav() {
const { scrollY } = useScroll()
const [state, setState] = useState({ scrolled: false, hidden: false })
useMotionValueEvent(scrollY, "change", (y) => {
const next = { scrolled: y > 40, hidden: y > (scrollY.getPrevious() ?? 0) && y > 240 }
setState((current) => (current.scrolled === next.scrolled && current.hidden === next.hidden ? current : next))
})
return state
}
// Schriftgröße für einen Schriftzug über die volle Breite, abhängig von der Länge des Namens
export function wordmarkSize(name: string, max = 26) {
return `${Math.min(max, 140 / Math.max(name.length, 1)).toFixed(2)}vw`
}
export function Rich({ text, emClassName }: { text: string; emClassName?: string }) {
return parseRich(text).map((tokens, l) => (
<Fragment key={l}>
{l > 0 && <br />}
{tokens.map((token, i) => (
<Fragment key={i}>
{i > 0 && " "}
{token.em ? <em className={emClassName}>{token.word}</em> : token.word}
</Fragment>
))}
</Fragment>
))
}
export function Reveal({ children, className, delay = 0, y = 32 }: {
children: ReactNode
className?: string
delay?: number
y?: number
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.9, ease, delay }}
>
{children}
</motion.div>
)
}
const textTags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p }
// Wörter gleiten zeilenweise aus einer Maske nach oben
export function RevealWords({ text, as = "h2", className, emClassName, delay = 0, onMount = false }: {
text: string
as?: keyof typeof textTags
className?: string
emClassName?: string
delay?: number
onMount?: boolean
}) {
const Tag = textTags[as]
const lines = parseRich(text)
const trigger = onMount ? { animate: "show" } : { whileInView: "show", viewport: inView }
return (
<Tag className={className} initial="hidden" {...trigger}>
<span className="sr-only">{text.replace(/\*/g, "")}</span>
{lines.map((tokens, l) => {
const offset = lines.slice(0, l).reduce((sum, line) => sum + line.length, 0)
return (
<span key={l} aria-hidden className="block">
{tokens.map((token, i) => (
<Fragment key={i}>
{i > 0 && " "}
<span className="mb-[-0.14em] inline-block overflow-hidden pb-[0.14em] align-top">
<motion.span
className={cn("inline-block", token.em && emClassName)}
variants={{
hidden: { y: "105%" },
show: { y: "0%", transition: { duration: 1.05, ease, delay: delay + (offset + i) * 0.055 } },
}}
>
{token.word}
</motion.span>
</span>
</Fragment>
))}
</span>
)
})}
</Tag>
)
}
function ScrollWord({ progress, range, children }: { progress: MotionValue<number>; range: [number, number]; children: string }) {
const opacity = useTransform(progress, range, [0.14, 1])
return <motion.span style={{ opacity }}>{children}</motion.span>
}
// Wörter leuchten beim Scrollen nacheinander auf
export function ScrollWords({ text, className }: { text: string; className?: string }) {
const ref = useRef<HTMLParagraphElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.45"] })
const words = text.split(/\s+/).filter(Boolean)
return (
<p ref={ref} className={className}>
{words.map((word, i) => (
<Fragment key={i}>
{i > 0 && " "}
<ScrollWord progress={scrollYProgress} range={[i / words.length, (i + 1) / words.length]}>
{word}
</ScrollWord>
</Fragment>
))}
</p>
)
}
export function ParallaxImage({ src, alt, className, imageClassName, offset = 60, sizes, eager }: {
src: string
alt?: string
className?: string
imageClassName?: string
offset?: number
sizes?: string
eager?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const y = useTransform(scrollYProgress, [0, 1], [-offset, offset])
return (
<div ref={ref} className={cn("relative overflow-hidden", className)}>
<motion.div className="absolute inset-x-0" style={{ y, top: -offset, bottom: -offset }}>
<TImage src={src} alt={alt} sizes={sizes} eager={eager} className={imageClassName} />
</motion.div>
</div>
)
}
// Endlos-Laufband; jede Kopie enthält den Inhalt doppelt, damit auch breite Bildschirme gefüllt sind
export function Marquee({ children, className, duration = 40, reverse = false }: {
children: ReactNode
className?: string
duration?: number
reverse?: boolean
}) {
return (
<div className={cn("flex overflow-hidden", className)}>
{[0, 1].map((copy) => (
<motion.div
key={copy}
aria-hidden={copy === 1}
className="flex shrink-0"
animate={{ x: reverse ? ["-100%", "0%"] : ["0%", "-100%"] }}
transition={{ duration, ease: "linear", repeat: Infinity }}
>
{[0, 1].map((repeat) => (
<div key={repeat} className="flex shrink-0 items-center">{children}</div>
))}
</motion.div>
))}
</div>
)
}
// Zählt deutsch formatierte Zahlen ("1.200", "4,9") hoch, sobald sie sichtbar werden
export function Counter({ value, className }: { value: string; className?: string }) {
const ref = useRef<HTMLSpanElement>(null)
const visible = useInView(ref, inView)
const reduceMotion = useReducedMotion()
useEffect(() => {
const target = parseFloat(value.replace(/\./g, "").replace(",", "."))
if (!visible || reduceMotion || Number.isNaN(target)) return
const decimals = value.split(",")[1]?.length ?? 0
const format = (n: number) => n.toLocaleString("de-DE", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const controls = animate(0, target, {
duration: 1.8,
ease,
onUpdate: (n) => {
if (ref.current) ref.current.textContent = format(n)
},
})
return () => controls.stop()
}, [visible, reduceMotion, value])
return <span ref={ref} className={className}>{value}</span>
}
// Schritt-Wechsel in Formularen mit mehreren Schritten (Buchung, Bewerbung, Quiz)
export function StepPanel({ step, children, className }: { step: number | string; children: ReactNode; className?: string }) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
className={className}
initial={{ opacity: 0, x: 28 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -28 }}
transition={{ duration: 0.35, ease }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
export function Accordion({ items, className, itemClassName, questionClassName, answerClassName }: {
items: { question: string; answer: string }[]
className?: string
itemClassName?: string
questionClassName?: string
answerClassName?: string
}) {
const [open, setOpen] = useState<number | null>(0)
return (
<div className={className}>
{items.map((item, i) => (
<div key={i} className={itemClassName}>
<button
type="button"
aria-expanded={open === i}
onClick={() => setOpen(open === i ? null : i)}
className={cn("flex w-full cursor-pointer items-center justify-between gap-6 text-left", questionClassName)}
>
<span>{item.question}</span>
<PlusIcon className={cn("size-5 shrink-0 transition-transform duration-300", open === i && "rotate-45")} />
</button>
<AnimatePresence initial={false}>
{open === i && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease }}
className="overflow-hidden"
>
<p className={answerClassName}>{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
)
}
// Vertikales Scrollen schiebt eine Bildreihe horizontal durch (Sektion bleibt dabei stehen)
export function HorizontalScroll({ children, header, className, trackClassName }: {
children: ReactNode
header?: ReactNode
className?: string
trackClassName?: string
}) {
const sectionRef = useRef<HTMLElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const distance = useMotionValue(0)
const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start start", "end end"] })
const x = useTransform(() => -scrollYProgress.get() * distance.get())
useEffect(() => {
const section = sectionRef.current
const track = trackRef.current
if (!section || !track) return
const observer = new ResizeObserver(() => {
const overflow = Math.max(0, track.scrollWidth - document.documentElement.clientWidth)
distance.set(overflow)
section.style.height = `calc(100vh + ${overflow}px)`
})
observer.observe(track)
observer.observe(document.documentElement)
return () => observer.disconnect()
}, [distance])
return (
<section ref={sectionRef} className={cn("relative h-screen", className)}>
<div className="sticky top-0 flex h-screen flex-col justify-center overflow-hidden">
{header}
<motion.div ref={trackRef} style={{ x }} className={cn("flex w-max", trackClassName)}>
{children}
</motion.div>
</div>
</section>
)
}

View file

@ -0,0 +1,48 @@
"use client"
import { MotionConfig } from "motion/react"
import { 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"
// 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.
// 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
content: TemplateContent
fonts: FontPair
className?: string
children: ReactNode
}) {
useEffect(() => {
const root = document.documentElement
const previous = root.style.scrollBehavior
root.style.scrollBehavior = "smooth"
return () => {
root.style.scrollBehavior = previous
}
}, [])
const style = { ...themeStyle(def, content), "--t-display": fonts.display, "--t-body": fonts.body } as CSSProperties
return (
<MotionConfig reducedMotion="user">
<div
style={style}
className={cn(
fonts.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",
className
)}
>
{children}
<DemoBadge content={content} />
<ChatWidget content={content} />
</div>
</MotionConfig>
)
}

View file

@ -0,0 +1,168 @@
import type { CSSProperties } from "react"
import type { AiSettings, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition } from "./types"
const MAX_TEXT = 4000
const MAX_ITEMS = 40
const HEX_COLOR = /^#[0-9a-f]{6}$/i
export const RICH_HINT = "Enter = neue Zeile, *Wort* = hervorgehoben"
// Leitet den Datentyp der Vorlage aus ihren Standardinhalten ab
export function defineTemplate<D extends TemplateData>(def: TemplateDefinition<D>) {
return def
}
export function unsplash(id: string, width = 2000) {
return `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${width}&q=80`
}
const FIELD_LABELS: Record<string, string> = {
name: "Name", tagline: "Untertitel", suffix: "Zusatz", role: "Rolle", eyebrow: "Überzeile", badge: "Hinweis",
title: "Überschrift", text: "Text", lead: "Einleitung", note: "Kleingedrucktes", cta: "Button", ctaSecondary: "Zweiter Button",
image: "Bild", imageSecondary: "Zweites Bild", imageThird: "Drittes Bild", src: "Bild", caption: "Bildunterschrift",
price: "Preis", period: "Zeitraum", duration: "Dauer", author: "Von", quote: "Zitat", label: "Beschriftung", value: "Wert",
time: "Uhrzeit", day: "Tag", location: "Ort", place: "Ort", year: "Jahr", phone: "Telefon", email: "E-Mail",
address: "Adresse", hours: "Öffnungszeiten", category: "Kategorie", description: "Beschreibung", meta: "Details",
features: "Merkmale (eine pro Zeile)", tasks: "Aufgaben (eine pro Zeile)", question: "Frage", answer: "Antwort",
options: "Antworten (mit | trennen)", level: "Intensität (1–3)", trainer: "Coach", company: "Firma", speaker: "Speaker",
room: "Raum", highlight: "Hervorheben (ja = hervorgehoben)", left: "Restplätze (leer = keine Anzeige)", area: "Bereich",
type: "Art", rooms: "Zimmer", rating: "Bewertung", reviews: "Bewertungen (Text)", count: "Anzahl", number: "Nummer",
open: "Öffnet (HH:MM, leer = geschlossen)", close: "Schließt (HH:MM)", deadline: "Aktionsende (JJJJ-MM-TT)",
deadlineLabel: "Text vor dem Countdown", success: "Text nach dem Absenden", slots: "Uhrzeiten (mit Komma trennen)",
roles: "Wechselnde Jobtitel (mit Komma trennen)", experience: "Erfahrungsstufen (mit Komma trennen)", bullets: "Vorteile (eine pro Zeile)",
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",
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)/
function humanize(key: string) {
const spaced = key.replace(/([A-Z])/g, " $1").toLowerCase()
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
function describeField(key: string, sample: string): FieldDef {
const label = FIELD_LABELS[key] ?? humanize(key)
if (IMAGE_KEY.test(key)) return { key, label, type: "image" }
if (key === "title") return { key, label, type: "textarea", hint: RICH_HINT }
if (TEXTAREA_KEYS.has(key) || sample.includes("\n") || sample.length > 90) return { key, label, type: "textarea" }
return { key, label }
}
type SectionMeta = string | { label: string; optional?: boolean; items?: Record<string, string> }
// Editor-Beschreibung aus den Standardinhalten ableiten; nur Sektionsnamen und Listen-Bezeichnungen sind nötig
export function describeSections<D extends TemplateData>(defaults: D, meta: Record<keyof D & string, SectionMeta>): SectionDef[] {
return Object.entries(defaults).map(([key, section]) => {
const info = typeof meta[key] === "string" ? { label: meta[key] as string } : (meta[key] as Exclude<SectionMeta, string>)
const fields = Object.entries(section).map(([fieldKey, value]): FieldDef | ListFieldDef => {
if (!Array.isArray(value)) return describeField(fieldKey, value)
const shape = value[0] ?? {}
return {
key: fieldKey,
label: FIELD_LABELS[fieldKey] ?? humanize(fieldKey),
type: "list",
itemLabel: info.items?.[fieldKey] ?? "Eintrag",
fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)),
}
})
return { key, label: info.label, optional: info.optional, fields }
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function text(value: unknown, fallback: string) {
return typeof value === "string" ? value.slice(0, MAX_TEXT) : fallback
}
function mergeItem(shape: ListItem, input: unknown): ListItem {
const source = isRecord(input) ? input : {}
return Object.fromEntries(Object.keys(shape).map((key) => [key, text(source[key], "")]))
}
function mergeSection(defaults: SectionData, input: unknown): SectionData {
const source = isRecord(input) ? input : {}
const result: SectionData = {}
for (const [key, fallback] of Object.entries(defaults)) {
const value = source[key]
if (Array.isArray(fallback)) {
const shape = fallback[0] ?? {}
result[key] = Array.isArray(value) ? value.slice(0, MAX_ITEMS).map((item) => mergeItem(shape, item)) : fallback
} else {
result[key] = text(value, fallback)
}
}
return result
}
// Gespeicherte Inhalte immer gegen die aktuelle Vorlage auflösen: neue Felder bekommen ihren
// Standardwert, entfernte oder fremde Felder fallen weg. So überstehen Varianten Änderungen an der Vorlage.
export function resolveContent<D extends TemplateData>(def: TemplateDefinition<D>, raw: unknown): TemplateContent<D> {
const source = isRecord(raw) ? raw : {}
const data = isRecord(source.data) ? source.data : {}
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 : {}
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 : "",
hidden: [...new Set(hidden)],
ai: {
chat: ai.chat === true,
phone: ai.phone === true,
assistant: ai.assistant === true,
content: ai.content === true,
name: text(ai.name, "").slice(0, 60),
greeting: text(ai.greeting, "").slice(0, 400),
knowledge: typeof ai.knowledge === "string" ? ai.knowledge.slice(0, 8000) : "",
} satisfies AiSettings,
data: Object.fromEntries(
Object.entries(def.defaults).map(([key, section]) => [key, mergeSection(section, data[key])])
) as 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
}

View file

@ -0,0 +1,27 @@
// Nachrichten zwischen Editor und Vorschau-iframe (gleiche Origin, siehe live-preview.tsx)
export const PREVIEW_READY = "since3:preview-ready"
export const PREVIEW_CONTENT = "since3:preview-content"
export const DEVICES = {
desktop: { label: "Desktop", width: 1440 },
tablet: { label: "Tablet", width: 820 },
mobile: { label: "Mobil", width: 390 },
} as const
export type Device = keyof typeof DEVICES
export type ShareState = "shared" | "expired" | "private"
export function shareState(token: string | null, expiresAt: string | null): ShareState {
if (!token) return "private"
return expiresAt && new Date(expiresAt).getTime() <= Date.now() ? "expired" : "shared"
}
export const dateFormat = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeZone: "Europe/Berlin" })
export const SHARE_EXPIRY_OPTIONS = [
{ days: 0, label: "Unbegrenzt" },
{ days: 7, label: "7 Tage" },
{ days: 30, label: "30 Tage" },
{ days: 90, label: "90 Tage" },
] as const

View file

@ -0,0 +1,33 @@
import { beauty } from "../catalog/beauty/definition"
import { coach } from "../catalog/coach/definition"
import { event } from "../catalog/event/definition"
import { fitness } from "../catalog/fitness/definition"
import { handwerk } from "../catalog/handwerk/definition"
import { immobilien } from "../catalog/immobilien/definition"
import { kampagne } from "../catalog/kampagne/definition"
import { kanzlei } from "../catalog/kanzlei/definition"
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 type { TemplateDefinition } from "./types"
// Reihenfolge = Reihenfolge im Admin: erst Branchen, dann Seiten-Typen für bestimmte Situationen
export const templates: TemplateDefinition[] = [
restaurant,
handwerk,
beauty,
kanzlei,
praxis,
fitness,
saas,
immobilien,
karriere,
kampagne,
event,
coach,
]
export function getTemplate(key: string) {
return templates.find((template) => template.key === key)
}

View file

@ -0,0 +1,82 @@
export type Palette = {
key: string
label: string
dark?: boolean
bg: string
fg: string
muted: string
surface: string
accent: string
}
// Schriftpaar einer Vorlage; die next/font-Objekte leben im fonts.ts der jeweiligen Vorlage
export type FontPair = {
className: string
display: string
body: string
}
export type FieldDef = {
key: string
label: string
type?: "text" | "textarea" | "image"
hint?: string
}
export type ListFieldDef = {
key: string
label: string
type: "list"
itemLabel: string
fields: FieldDef[]
}
export type SectionDef = {
key: string
label: string
// Optionale Sektionen lassen sich im Editor ausblenden
optional?: boolean
fields: (FieldDef | ListFieldDef)[]
}
export type ListItem = Record<string, string>
export type SectionData = Record<string, string | ListItem[]>
export type TemplateData = Record<string, SectionData>
export type TemplateDefinition<D extends TemplateData = TemplateData> = {
key: string
name: string
industry: string
description: string
cover: string
palettes: Palette[]
fonts: { key: string; label: string }[]
sections: SectionDef[]
// Inhalte einer frischen Variante; legt zugleich die erlaubte Struktur fest (siehe resolveContent)
defaults: D
// Vorbelegung der KI-Demos (Angebots-Assistent, Content-Generator)
samples: { inquiry: string; topic: string }
}
// KI-Funktionen einer Variante; Wissen kommt aus den Inhalten plus `knowledge`
export type AiSettings = {
chat: boolean
phone: boolean
assistant: boolean
content: boolean
name: string
greeting: string
knowledge: string
}
export type TemplateContent<D extends TemplateData = TemplateData> = {
palette: string
fonts: string
// Eigene Akzentfarbe (#rrggbb), leer = Akzent der Palette
accent: string
hidden: string[]
ai: AiSettings
data: D
}
export type TemplateProps<D extends TemplateData> = { content: TemplateContent<D> }

View file

@ -0,0 +1,20 @@
import "server-only"
import { cache } from "react"
import { createClient } from "@/lib/supabase/server"
import { resolveContent } from "../lib/content"
import { getTemplate } from "../lib/registry"
const TOKEN = /^[A-Za-z0-9_-]{20,64}$/
// Öffentlich (siehe proxy.ts): nur die eine Variante zum Token, abgelaufene Links ergeben null
export const getSharedVariant = cache(async (token: string) => {
if (!TOKEN.test(token)) return null
const supabase = await createClient()
const { data } = await supabase.rpc("get_shared_template_variant", { p_token: token }).maybeSingle()
const shared = data as { template_key: string; name: string; content: unknown } | null
const def = shared ? getTemplate(shared.template_key) : undefined
if (!shared || !def) return null
return { name: shared.name, def, content: resolveContent(def, shared.content) }
})

View file

@ -0,0 +1,41 @@
import "server-only"
import * as z from "zod"
import { createClient } from "@/lib/supabase/server"
export type VariantRow = {
id: string
name: string
template_key: string
client_id: string | null
content: unknown
share_token: string | null
share_expires_at: string | null
updated_at: string
}
export type VariantListItem = Omit<VariantRow, "content">
export async function getVariants() {
const supabase = await createClient()
const { data, error } = await supabase
.from("template_variants")
.select("id, name, template_key, client_id, share_token, share_expires_at, updated_at")
.order("updated_at", { ascending: false })
if (error) throw new Error(`Failed to load template variants: ${error.message}`)
return data as VariantListItem[]
}
export async function getVariant(id: string) {
if (!z.string().uuid().safeParse(id).success) return null
const supabase = await createClient()
const { data } = await supabase
.from("template_variants")
.select("id, name, template_key, client_id, content, share_token, share_expires_at, updated_at")
.eq("id", id)
.maybeSingle()
return data as VariantRow | null
}

View file

@ -0,0 +1,29 @@
"use client"
import { useState, useTransition } from "react"
import { PencilIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { createVariant } from "../../actions/variants"
export function CreateVariantButton({ templateKey }: { templateKey: string }) {
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
return (
<>
<Button
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await createVariant(templateKey)
if (result?.error) setError(result.error)
})
}
>
<PencilIcon /> {pending ? "Wird angelegt …" : "Anpassen"}
</Button>
{error && <p className="w-full text-xs text-destructive">{error}</p>}
</>
)
}

View file

@ -0,0 +1,91 @@
"use client"
import { ExternalLinkIcon } from "lucide-react"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { AI_DEMO_SLUGS, AI_DEMOS } from "../../../ai/demos"
import type { AiSettings } from "../../../lib/types"
import { Toggle } from "./toggle"
const demoText = {
telefon: "Sprach-Demo im Browser: Anrufen, Fragen stellen, Termin anfragen.",
assistent: "Kundenanfrage rein, Antwort-E-Mail oder Angebotsentwurf raus.",
content: "Posts und Anzeigen im Ton des Kunden, als echte Vorschau.",
}
function Heading({ title, text }: { title: string; text?: string }) {
return (
<div className="mb-3">
<h3 className="text-sm font-medium">{title}</h3>
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
</div>
)
}
export function AiPanel({ variantId, ai, onChange }: { variantId: string; ai: AiSettings; onChange: (patch: Partial<AiSettings>) => void }) {
return (
<div className="flex flex-col gap-8">
<p className="rounded-lg bg-surface px-3 py-2.5 text-xs text-muted-foreground">
Die KI kennt alle Inhalte dieser Variante. Über den geteilten Link sind höchstens 150 KI-Anfragen pro Tag möglich.
Änderungen hier greifen nach dem Speichern.
</p>
<section>
<Heading title="Chat-Assistent" text="Chat-Fenster unten rechts auf der Webseite." />
<div className="rounded-lg border">
<Toggle checked={ai.chat} onChange={(chat) => onChange({ chat })}>Auf der Webseite anzeigen</Toggle>
</div>
<div className="mt-4 flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="ai-name">Name des Assistenten</FieldLabel>
<Input id="ai-name" value={ai.name} placeholder="z. B. Mia" maxLength={60} onChange={(event) => onChange({ name: event.target.value })} />
</Field>
<Field>
<FieldLabel htmlFor="ai-greeting">Begrüßung</FieldLabel>
<Textarea id="ai-greeting" rows={2} value={ai.greeting} placeholder="Leer = automatische Begrüßung mit Firmennamen" onChange={(event) => onChange({ greeting: event.target.value })} />
</Field>
</div>
</section>
<section>
<Heading title="Zusatzwissen" text="Was nicht auf der Webseite steht: Preise, FAQ, Parken, Zahlungsarten, Regeln." />
<Field>
<Textarea
rows={6}
value={ai.knowledge}
aria-label="Zusatzwissen"
placeholder={"Parkplätze im Hof\nKartenzahlung möglich\nHunde willkommen"}
onChange={(event) => onChange({ knowledge: event.target.value })}
/>
<FieldDescription>Stichpunkte reichen. Die KI erfindet nichts dazu.</FieldDescription>
</Field>
</section>
<section>
<Heading title="KI-Demos" text="Freigeschaltete Demos erscheinen im geteilten Link im Entwurfs-Badge unten links." />
<div className="flex flex-col divide-y rounded-lg border">
{AI_DEMO_SLUGS.map((slug) => {
const feature = AI_DEMOS[slug].feature
return (
<div key={slug}>
<Toggle checked={ai[feature]} onChange={(checked) => onChange({ [feature]: checked })}>
<span className="block">{AI_DEMOS[slug].label}</span>
<span className="block text-xs text-muted-foreground">{demoText[slug]}</span>
</Toggle>
<a
href={`/ki/${variantId}/${slug}`}
target="_blank"
rel="noreferrer"
className="mx-3 mb-2.5 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ExternalLinkIcon className="size-3" /> Intern testen
</a>
</div>
)
})}
</div>
</section>
</div>
)
}

View file

@ -0,0 +1,133 @@
"use client"
import { useId } from "react"
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import type { FieldDef, ListFieldDef, ListItem, TemplateData, TemplateDefinition } from "../../../lib/types"
const MAX_ITEMS = 40
export function ContentPanel({ def, data, hidden, onChange }: {
def: TemplateDefinition
data: TemplateData
hidden: string[]
onChange: (section: string, field: string, value: string | ListItem[]) => void
}) {
return (
<div className="flex flex-col gap-2">
{def.sections.map((section, index) => (
<Collapsible key={section.key} defaultOpen={index === 0} className="rounded-lg border">
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium [&[data-panel-open]>svg]:rotate-180">
<span className="flex items-center gap-2">
{section.label}
{hidden.includes(section.key) && (
<span className="rounded bg-surface px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground">ausgeblendet</span>
)}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-4 border-t px-3 py-4">
{section.fields.map((field) =>
field.type === "list" ? (
<ListEditor
key={field.key}
field={field}
items={(data[section.key]?.[field.key] as ListItem[] | undefined) ?? []}
onChange={(items) => onChange(section.key, field.key, items)}
/>
) : (
<FieldInput
key={field.key}
field={field}
value={(data[section.key]?.[field.key] as string | undefined) ?? ""}
onChange={(value) => onChange(section.key, field.key, value)}
/>
)
)}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
)
}
function FieldInput({ field, value, onChange }: { field: FieldDef; value: string; onChange: (value: string) => void }) {
const id = useId()
return (
<Field>
<FieldLabel htmlFor={id}>{field.label}</FieldLabel>
{field.type === "textarea" ? (
<Textarea id={id} value={value} rows={3} onChange={(event) => onChange(event.target.value)} />
) : (
<Input
id={id}
value={value}
type={field.type === "image" ? "url" : "text"}
placeholder={field.type === "image" ? "https://images.unsplash.com/…" : undefined}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === "image" && value && (
<div aria-hidden className="h-20 rounded-md border bg-surface bg-cover bg-center" style={{ backgroundImage: `url("${value}")` }} />
)}
{field.hint && <FieldDescription>{field.hint}</FieldDescription>}
</Field>
)
}
function ListEditor({ field, items, onChange }: { field: ListFieldDef; items: ListItem[]; onChange: (items: ListItem[]) => void }) {
function update(index: number, key: string, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [key]: value } : item)))
}
function move(index: number, offset: number) {
const next = [...items]
const [item] = next.splice(index, 1)
next.splice(index + offset, 0, item)
onChange(next)
}
return (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{field.label}</p>
{items.map((item, index) => (
<div key={index} className="rounded-md border bg-surface/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{field.itemLabel} {index + 1}</span>
<div className="flex">
<Button variant="ghost" size="icon-sm" aria-label="Nach oben" disabled={index === 0} onClick={() => move(index, -1)}>
<ChevronUpIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Nach unten" disabled={index === items.length - 1} onClick={() => move(index, 1)}>
<ChevronDownIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`${field.itemLabel} entfernen`} onClick={() => onChange(items.filter((_, i) => i !== index))}>
<Trash2Icon />
</Button>
</div>
</div>
<div className="flex flex-col gap-3">
{field.fields.map((sub) => (
<FieldInput key={sub.key} field={sub} value={item[sub.key] ?? ""} onChange={(value) => update(index, sub.key, value)} />
))}
</div>
</div>
))}
<Button
variant="outline"
size="sm"
disabled={items.length >= MAX_ITEMS}
onClick={() => onChange([...items, Object.fromEntries(field.fields.map((sub) => [sub.key, ""]))])}
>
<PlusIcon /> {field.itemLabel} hinzufügen
</Button>
</div>
)
}

View file

@ -0,0 +1,112 @@
"use client"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { TemplateContent, TemplateDefinition } from "../../../lib/types"
import { Toggle } from "./toggle"
type ThemePatch = Partial<Pick<TemplateContent, "palette" | "fonts" | "accent" | "hidden">>
function Heading({ title, text }: { title: string; text?: string }) {
return (
<div className="mb-3">
<h3 className="text-sm font-medium">{title}</h3>
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
</div>
)
}
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
return (
<div className="flex flex-col gap-8">
<section>
<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>
))}
</div>
</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>
)}
</div>
</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"
)}
>
{pair.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],
})
}
>
{section.label}
</Toggle>
))}
</div>
</section>
</div>
)
}

View file

@ -0,0 +1,95 @@
"use client"
import { useEffect, useEffectEvent, useRef, useState } from "react"
import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DEVICES, PREVIEW_CONTENT, PREVIEW_READY, type Device } from "../../../lib/preview"
import type { TemplateContent } from "../../../lib/types"
const icons = { desktop: MonitorIcon, tablet: TabletIcon, mobile: SmartphoneIcon }
// Die Vorlage läuft in echter Gerätebreite im iframe (damit Media Queries greifen) und wird
// passend herunterskaliert. Änderungen gehen per postMessage hinein, ohne Neuladen.
export function PreviewFrame({ templateKey, variantId, content }: { templateKey: string; variantId: string; content: TemplateContent }) {
const frameRef = useRef<HTMLIFrameElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
const [device, setDevice] = useState<Device>("desktop")
const post = useEffectEvent(() => {
frameRef.current?.contentWindow?.postMessage({ type: PREVIEW_CONTENT, content }, window.location.origin)
})
useEffect(() => {
post()
}, [content])
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin === window.location.origin && event.source === frameRef.current?.contentWindow && event.data?.type === PREVIEW_READY) {
post()
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height }))
observer.observe(container)
return () => observer.disconnect()
}, [])
const deviceWidth = DEVICES[device].width
const inset = device === "desktop" ? 0 : 16
const scale = size.width ? Math.min(1, (size.width - inset * 2) / deviceWidth) : 1
return (
<section className="flex min-h-[75vh] flex-col overflow-hidden rounded-xl border bg-surface lg:min-h-0">
<div className="flex items-center justify-between gap-2 border-b bg-card px-2 py-1.5">
<div className="flex gap-1">
{(Object.keys(DEVICES) as Device[]).map((key) => {
const Icon = icons[key]
return (
<button
key={key}
type="button"
aria-pressed={device === key}
onClick={() => setDevice(key)}
className={cn(
"flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors",
device === key ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="size-3.5" />
{DEVICES[key].label}
</button>
)
})}
</div>
<span className="font-mono text-[11px] text-muted-foreground">
{deviceWidth}px · {Math.round(scale * 100)}%
</span>
</div>
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden">
{size.width > 0 && (
<iframe
ref={frameRef}
src={`/preview/${templateKey}?live=1&variant=${variantId}`}
title="Vorschau der Variante"
className={cn("absolute origin-top-left border-0 bg-white", inset > 0 && "rounded-2xl shadow-xl ring-1 ring-black/10")}
style={{
width: deviceWidth,
height: (size.height - inset * 2) / scale,
top: inset,
left: Math.max(0, (size.width - deviceWidth * scale) / 2),
transform: `scale(${scale})`,
}}
/>
)}
</div>
</section>
)
}

View file

@ -0,0 +1,159 @@
"use client"
import { useRouter } from "next/navigation"
import { useState, useSyncExternalStore, useTransition } from "react"
import { CopyPlusIcon, 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"
import { dateFormat, SHARE_EXPIRY_OPTIONS } from "../../../lib/preview"
import type { EditorVariant } from "../../views/variant-editor"
const selectClass = "h-10 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
const subscribe = () => () => {}
function Heading({ title, text }: { title: string; text?: string }) {
return (
<div className="mb-3">
<h3 className="text-sm font-medium">{title}</h3>
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
</div>
)
}
export function SharePanel({ variant, clients, clientId, onClientChange, dirty }: {
variant: EditorVariant
clients: { id: string; name: string }[]
clientId: string | null
onClientChange: (clientId: string | null) => void
dirty: boolean
}) {
const router = useRouter()
const origin = useSyncExternalStore(subscribe, () => window.location.origin, () => "")
const [share, setShare] = useState({ token: variant.shareToken, expiresAt: variant.shareExpiresAt, expired: variant.shareState === "expired" })
// -1 = bestehendes Ablaufdatum beibehalten
const [expiry, setExpiry] = useState<number>(variant.shareExpiresAt ? -1 : 0)
const [confirmDelete, setConfirmDelete] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
function run(action: () => Promise<{ error: string } | unknown>) {
startTransition(async () => {
const result = await action()
if (result && typeof result === "object" && "error" in result) setError(String(result.error))
else setError(null)
})
}
function applyShare(enabled: boolean, options: { regenerate?: boolean; days?: number } = {}) {
run(async () => {
const days = options.days ?? (expiry < 0 ? 0 : expiry)
const result = await updateShare(variant.id, { enabled, expiresInDays: days, regenerate: options.regenerate })
if (!("error" in result)) {
setShare({ ...result, expired: false })
setExpiry(result.expiresAt ? days : 0)
}
return result
})
}
const url = share.token ? `${origin}/demo/${share.token}` : ""
return (
<div className="flex flex-col gap-8">
<section>
<Heading title="Client" text="Optional – ordnet die Variante einem Kunden zu. Wird mit „Speichern“ übernommen." />
<select
value={clientId ?? ""}
onChange={(event) => onClientChange(event.target.value || null)}
className={selectClass}
aria-label="Client"
>
<option value="">Kein Client (Pitch)</option>
{clients.map((client) => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
</section>
<section>
<Heading title="Link teilen" text="Kunden öffnen den Link ohne Login. Er zeigt immer den zuletzt gespeicherten Stand." />
{share.token ? (
<div className="flex flex-col gap-3">
{share.expired && (
<p className="rounded-md bg-signal-warning/12 px-3 py-2 text-xs text-signal-warning">
Dieser Link ist abgelaufen. Wähle eine neue Gültigkeit, um ihn wieder zu aktivieren.
</p>
)}
<CopyField label="Öffentlicher Link" value={url} />
<label className="flex flex-col gap-1.5 text-sm">
Gültigkeit
<select
value={expiry}
disabled={pending}
onChange={(event) => applyShare(true, { days: Number(event.target.value) })}
className={selectClass}
>
{expiry < 0 && share.expiresAt && (
<option value={-1}>bis {dateFormat.format(new Date(share.expiresAt))}</option>
)}
{SHARE_EXPIRY_OPTIONS.map((option) => (
<option key={option.days} value={option.days}>{option.label}</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2">
<a href={url} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "outline", size: "sm" })}>
<ExternalLinkIcon /> Öffnen
</a>
<Button variant="outline" size="sm" disabled={pending} onClick={() => applyShare(true, { regenerate: true })}>
<RefreshCwIcon /> Neuer Link
</Button>
<Button variant="ghost" size="sm" disabled={pending} onClick={() => applyShare(false)}>
Freigabe beenden
</Button>
</div>
<p className="text-xs text-muted-foreground">„Neuer Link“ macht den bisherigen Link ungültig.</p>
</div>
) : (
<Button disabled={pending} onClick={() => applyShare(true)}>Link erstellen</Button>
)}
</section>
<section>
<Heading title="Variante" />
<div className="flex flex-col gap-2">
<Button variant="outline" disabled={pending} onClick={() => run(() => duplicateVariant(variant.id))}>
<CopyPlusIcon /> Duplizieren
</Button>
{dirty && <p className="text-xs text-muted-foreground">Dupliziert wird der gespeicherte Stand.</p>}
{confirmDelete ? (
<div className="flex gap-2">
<Button
variant="destructive"
className="flex-1"
disabled={pending}
onClick={() =>
run(async () => {
const result = await deleteVariant(variant.id)
if ("success" in result) router.push("/admin/templates")
return result
})
}
>
Endgültig löschen
</Button>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Abbrechen</Button>
</div>
) : (
<Button variant="ghost" className="text-destructive" onClick={() => setConfirmDelete(true)}>
<Trash2Icon /> Löschen
</Button>
)}
</div>
</section>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)
}

View file

@ -0,0 +1,21 @@
"use client"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
export function Toggle({ checked, onChange, children }: { checked: boolean; onChange: (checked: boolean) => void; children: ReactNode }) {
return (
<label className="flex cursor-pointer items-center justify-between gap-3 px-3 py-2.5 text-sm">
<span className="min-w-0">{children}</span>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn("relative h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors", checked ? "bg-primary" : "bg-ink-300 dark:bg-ink-700")}
>
<span className={cn("absolute top-0.5 left-0.5 size-4 rounded-full bg-background shadow transition-transform", checked && "translate-x-4")} />
</button>
</label>
)
}

View file

@ -0,0 +1,34 @@
"use client"
import { useEffect, useState } from "react"
import type { AiAccess } from "../../ai/demos"
import { resolveContent } from "../../lib/content"
import { PREVIEW_CONTENT, PREVIEW_READY } from "../../lib/preview"
import { getTemplate } from "../../lib/registry"
import type { TemplateContent } from "../../lib/types"
import { TemplateRenderer } from "./template-renderer"
// Läuft im iframe des Editors und übernimmt ungespeicherte Änderungen per postMessage
export function LivePreview({ templateKey, initialContent, access }: {
templateKey: string
initialContent: TemplateContent
access: AiAccess | null
}) {
const [content, setContent] = useState(initialContent)
useEffect(() => {
const def = getTemplate(templateKey)
if (!def) return
function onMessage(event: MessageEvent) {
if (event.origin !== window.location.origin || event.data?.type !== PREVIEW_CONTENT || !def) return
setContent(resolveContent(def, event.data.content))
}
window.addEventListener("message", onMessage)
window.parent.postMessage({ type: PREVIEW_READY }, window.location.origin)
return () => window.removeEventListener("message", onMessage)
}, [templateKey])
return <TemplateRenderer templateKey={templateKey} content={content} access={access} />
}

View file

@ -0,0 +1,50 @@
"use client"
import type { ComponentType } from "react"
import { AiAccessProvider } from "../../ai/access-context"
import type { AiAccess } from "../../ai/demos"
import { BeautyTemplate } from "../../catalog/beauty/template"
import { CoachTemplate } from "../../catalog/coach/template"
import { EventTemplate } from "../../catalog/event/template"
import { FitnessTemplate } from "../../catalog/fitness/template"
import { HandwerkTemplate } from "../../catalog/handwerk/template"
import { ImmobilienTemplate } from "../../catalog/immobilien/template"
import { KampagneTemplate } from "../../catalog/kampagne/template"
import { KanzleiTemplate } from "../../catalog/kanzlei/template"
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 type { TemplateContent } from "../../lib/types"
type TemplateComponent = ComponentType<{ content: TemplateContent }>
const components: Record<string, TemplateComponent> = {
restaurant: RestaurantTemplate as TemplateComponent,
handwerk: HandwerkTemplate as TemplateComponent,
beauty: BeautyTemplate as TemplateComponent,
kanzlei: KanzleiTemplate as TemplateComponent,
praxis: PraxisTemplate as TemplateComponent,
fitness: FitnessTemplate as TemplateComponent,
saas: SaasTemplate as TemplateComponent,
immobilien: ImmobilienTemplate as TemplateComponent,
karriere: KarriereTemplate as TemplateComponent,
kampagne: KampagneTemplate as TemplateComponent,
event: EventTemplate as TemplateComponent,
coach: CoachTemplate as TemplateComponent,
}
export function TemplateRenderer({ templateKey, content, access = null }: {
templateKey: string
content: TemplateContent
access?: AiAccess | null
}) {
const Template = components[templateKey]
if (!Template) return null
return (
<AiAccessProvider access={access}>
<Template content={content} />
</AiAccessProvider>
)
}

View file

@ -0,0 +1,125 @@
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 { getVariants } from "../../queries/variants"
import { CreateVariantButton } from "../components/create-variant-button"
const shareBadge: Record<ShareState, { label: string; variant: "success" | "warning" | "neutral" }> = {
shared: { label: "Geteilt", variant: "success" },
expired: { label: "Abgelaufen", variant: "warning" },
private: { label: "Privat", variant: "neutral" },
}
const TemplatesView = async () => {
const [variants, clients] = await Promise.all([getVariants(), getClients()])
const clientNames = new Map(clients.map((client) => [client.id, client.name]))
return (
<div className="flex w-full flex-col gap-10">
<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.
</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>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Varianten</h2>
{variants.length === 0 ? (
<p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
Noch keine Varianten. Wähle oben eine Vorlage und klicke auf „Anpassen“.
</p>
) : (
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Vorlage</TableHead>
<TableHead className="hidden md:table-cell">Client</TableHead>
<TableHead>Link</TableHead>
<TableHead className="hidden md:table-cell">Geändert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.map((variant) => {
const badge = shareBadge[shareState(variant.share_token, variant.share_expires_at)]
return (
<TableRow key={variant.id}>
<TableCell>
<Link href={`/admin/templates/${variant.id}`} className="font-medium hover:underline">
{variant.name}
</Link>
</TableCell>
<TableCell>{getTemplate(variant.template_key)?.name ?? variant.template_key}</TableCell>
<TableCell className="hidden md:table-cell">
{(variant.client_id && clientNames.get(variant.client_id)) ?? "—"}
</TableCell>
<TableCell><Badge variant={badge.variant}>{badge.label}</Badge></TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{dateFormat.format(new Date(variant.updated_at))}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</section>
</div>
)
}
export default TemplatesView

View file

@ -0,0 +1,171 @@
"use client"
import Link from "next/link"
import { useEffect, useEffectEvent, useState, useTransition } from "react"
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { saveVariant } from "../../actions/variants"
import type { ShareState } from "../../lib/preview"
import { getTemplate } from "../../lib/registry"
import type { ListItem, TemplateContent } from "../../lib/types"
import { AiPanel } from "../components/editor/ai-panel"
import { ContentPanel } from "../components/editor/content-panel"
import { DesignPanel } from "../components/editor/design-panel"
import { PreviewFrame } from "../components/editor/preview-frame"
import { SharePanel } from "../components/editor/share-panel"
export type EditorVariant = {
id: string
name: string
templateKey: string
clientId: string | null
content: TemplateContent
shareToken: string | null
shareExpiresAt: string | null
shareState: ShareState
}
const tabs = [
{ key: "content", label: "Inhalte" },
{ key: "design", label: "Design" },
{ key: "ai", label: "KI" },
{ key: "share", label: "Teilen" },
] as const
type Tab = (typeof tabs)[number]["key"]
function snapshot(name: string, clientId: string | null, content: TemplateContent) {
return JSON.stringify({ name, clientId, content })
}
export function VariantEditor({ variant, clients }: { variant: EditorVariant; clients: { id: string; name: string }[] }) {
const def = getTemplate(variant.templateKey)
const [name, setName] = useState(variant.name)
const [clientId, setClientId] = useState(variant.clientId)
const [content, setContent] = useState(variant.content)
const [saved, setSaved] = useState(() => snapshot(variant.name, variant.clientId, variant.content))
const [tab, setTab] = useState<Tab>("content")
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const current = snapshot(name, clientId, content)
const dirty = current !== saved
function save() {
startTransition(async () => {
const result = await saveVariant(variant.id, { name, clientId, content })
if ("error" in result) {
setError(result.error)
return
}
setError(null)
setSaved(current)
})
}
const onShortcut = useEffectEvent((event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault()
if (dirty && !pending) save()
}
})
useEffect(() => {
window.addEventListener("keydown", onShortcut)
return () => window.removeEventListener("keydown", onShortcut)
}, [])
useEffect(() => {
if (!dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener("beforeunload", warn)
return () => window.removeEventListener("beforeunload", warn)
}, [dirty])
if (!def) return null
function updateField(section: string, field: string, value: string | ListItem[]) {
setContent((c) => ({ ...c, data: { ...c.data, [section]: { ...c.data[section], [field]: value } } }))
}
return (
<div className="flex flex-col gap-4 lg:h-[calc(100dvh-4rem)]">
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/templates"
aria-label="Zurück zu den Vorlagen"
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
onClick={(event) => {
if (dirty && !window.confirm("Ungespeicherte Änderungen verwerfen?")) event.preventDefault()
}}
>
<ArrowLeftIcon />
</Link>
<div className="min-w-0 flex-1">
<input
value={name}
onChange={(event) => setName(event.target.value)}
aria-label="Name der Variante"
className="w-full truncate rounded-md bg-transparent text-xl font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:text-2xl"
/>
<p className="text-xs text-muted-foreground">Vorlage „{def.name}“ · {def.industry}</p>
</div>
<span className={cn("text-xs", dirty ? "text-signal-warning" : "text-muted-foreground")}>
{pending ? "Speichert …" : dirty ? "Ungespeicherte Änderungen" : "Gespeichert"}
</span>
<a
href={`/preview/${def.key}?variant=${variant.id}`}
target="_blank"
rel="noreferrer"
title="Zeigt den gespeicherten Stand"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ExternalLinkIcon /> Vollbild
</a>
<Button size="sm" onClick={save} disabled={!dirty || pending}>
Speichern
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(320px,400px)_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden rounded-xl border bg-card">
<div role="tablist" className="flex gap-1 border-b p-1.5">
{tabs.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
onClick={() => setTab(item.key)}
className={cn(
"flex-1 cursor-pointer rounded-md px-3 py-1.5 text-sm transition-colors",
tab === item.key ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
{item.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{tab === "content" && (
<ContentPanel def={def} data={content.data} hidden={content.hidden} onChange={updateField} />
)}
{tab === "design" && (
<DesignPanel def={def} content={content} onChange={(patch) => setContent((c) => ({ ...c, ...patch }))} />
)}
{tab === "ai" && (
<AiPanel variantId={variant.id} ai={content.ai} onChange={(patch) => setContent((c) => ({ ...c, ai: { ...c.ai, ...patch } }))} />
)}
{tab === "share" && (
<SharePanel variant={variant} clients={clients} clientId={clientId} onClientChange={setClientId} dirty={dirty} />
)}
</div>
</aside>
<PreviewFrame templateKey={def.key} variantId={variant.id} content={content} />
</div>
</div>
)
}