Merge pull request #19 from Since-3/feature/added-intenral-tools

added internal account code and content
This commit is contained in:
Rajpreet0 2026-10-11 19:06:09 +02:00 • committed by GitHub
commit fa5af631d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 5618 additions and 72 deletions

View file

@ -0,0 +1,10 @@
import { redirect } from "next/navigation"
import { verifySession } from "@/lib/supabase/dal"
import InternalAccountsView from "@/modules/admin/ui/views/internal-accounts-view"
export default async function InternalAccountsPage() {
const session = await verifySession()
if (!session.isAdmin) redirect("/client/dashboard")
return <InternalAccountsView />
}

View file

@ -0,0 +1,101 @@
-- Interne Zugänge von Since3 (eigene Hosting-, Tool- und Domain-Accounts). Nur für Admins.
-- Gleiches Prinzip wie bei den Kunden-Passwörtern (credentials): In der Tabelle steht nie das Passwort,
-- nur die Vault-Referenz. Schreiben und Klartext nur über die Funktionen unten, jeder Abruf wird protokolliert.
create table public.internal_credentials (
id uuid primary key default gen_random_uuid(),
service text not null, -- Schlüssel aus SERVICES (services-data.ts)
title text not null default '', -- z. B. "Since3 Team"; leer = Name des Dienstes
username text not null,
url text not null default '', -- Login-Adresse (optional)
plan text not null default 'free', -- 'free' | 'paid'
price numeric(10,2), -- Euro, nur bei 'paid'
billing_cycle text, -- 'monthly' | 'yearly' | 'once', nur bei 'paid'
vault_secret_id uuid not null, -- -> vault.secrets
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
updated_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- Kostenlos = weder Preis noch Abrechnung; bezahlt = beides
constraint internal_credentials_cost_check check (
(plan = 'free' and price is null and billing_cycle is null)
or (plan = 'paid' and price is not null and price >= 0 and billing_cycle in ('monthly', 'yearly', 'once'))
)
);
create trigger set_updated_at before update on public.internal_credentials
for each row execute function public.set_updated_at();
-- Vault-Eintrag beim Löschen mit aufräumen (gleiche Funktion wie bei credentials)
create trigger internal_credentials_delete_vault_secret
after delete on public.internal_credentials
for each row execute function public.delete_vault_secret();
-- Lesen (ohne Passwort) und Löschen nur Admins; Anlegen/Ändern nur über die Funktionen
alter table public.internal_credentials enable row level security;
create policy admin_select on public.internal_credentials for select to authenticated
using ((select public.is_admin()));
create policy admin_delete on public.internal_credentials for delete to authenticated
using ((select public.is_admin()));
create or replace function public.create_internal_credential(
p_service text, p_title text, p_username text, p_url text,
p_plan text, p_price numeric, p_billing_cycle text, p_password text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare v_secret uuid; v_id uuid;
begin
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
v_secret := vault.create_secret(p_password, 'internal_' || gen_random_uuid()::text, p_service);
insert into public.internal_credentials
(service, title, username, url, plan, price, billing_cycle, vault_secret_id, updated_by)
values
(p_service, coalesce(p_title, ''), p_username, coalesce(p_url, ''), p_plan,
case when p_plan = 'paid' then p_price end,
case when p_plan = 'paid' then p_billing_cycle end,
v_secret, auth.uid())
returning id into v_id;
return v_id;
end $$;
-- p_password = NULL oder leer -> Passwort bleibt unverändert
create or replace function public.update_internal_credential(
p_id uuid, p_service text, p_title text, p_username text, p_url text,
p_plan text, p_price numeric, p_billing_cycle text, p_password text default null
) returns void language plpgsql security definer set search_path = '' as $$
declare v_secret uuid;
begin
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
update public.internal_credentials
set service = p_service, title = coalesce(p_title, ''), username = p_username,
url = coalesce(p_url, ''), plan = p_plan,
price = case when p_plan = 'paid' then p_price end,
billing_cycle = case when p_plan = 'paid' then p_billing_cycle end,
updated_by = auth.uid()
where id = p_id
returning vault_secret_id into v_secret;
if not found then raise exception 'internal credential not found'; end if;
if p_password is not null and p_password <> '' then
perform vault.update_secret(v_secret, p_password);
end if;
end $$;
-- Klartext nur für Admins; landet im selben Zugriffsprotokoll wie die Kunden-Passwörter
create or replace function public.reveal_internal_credential(p_id uuid)
returns text language plpgsql security definer set search_path = '' as $$
declare v_secret uuid; v_pw text;
begin
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
select vault_secret_id into v_secret from public.internal_credentials where id = p_id;
if not found then raise exception 'internal credential not found'; end if;
select decrypted_secret into v_pw from vault.decrypted_secrets where id = v_secret;
insert into public.credential_access_log (credential_id, user_id, action)
values (p_id, auth.uid(), 'reveal_internal');
return v_pw;
end $$;
revoke execute on function public.create_internal_credential(text, text, text, text, text, numeric, text, text) from public, anon;
revoke execute on function public.update_internal_credential(uuid, text, text, text, text, text, numeric, text, text) from public, anon;
revoke execute on function public.reveal_internal_credential(uuid) from public, anon;
grant execute on function public.create_internal_credential(text, text, text, text, text, numeric, text, text) to authenticated;
grant execute on function public.update_internal_credential(uuid, text, text, text, text, text, numeric, text, text) to authenticated;
grant execute on function public.reveal_internal_credential(uuid) to authenticated;

View file

@ -0,0 +1,173 @@
"use server"
import { SERVICES } from "@/modules/shared/data/services-data"
import * as z from "zod"
import { BILLING_CYCLES, type BillingCycle, type Plan } from "../lib/internal-costs";
import { verifySession } from "@/lib/supabase/dal";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
const PATH = "/admin/internal-accounts"
const BaseSchema = z.object({
service: z.string().refine((id) => SERVICES.some((service) => service.id === id), { error: "Unbekannter Dienst." }),
title: z.string().trim().max(120, { error: "Bezeichnung ist zu lang." }),
username: z.string().trim().min(1, { error: "Benutzername ist erforderlich." }).max(200),
url: z
.string()
.trim()
.max(500)
.refine((value) => value === "" || /^https?:\/\//i.test(value), { error: "Login-Adresse bitte mit https:// angeben." }),
});
const CreateSchema = BaseSchema.extend({
password: z.string().min(1, { error: "Passwort ist erforderlich." })
})
const UpdateSchema = BaseSchema.extend({
id: z.string().uuid(),
password: z.string().optional(),
})
// "12,99", "1.234,50" oder "12.99" → Zahl
const toNumber = (value: string) => Number(value.includes(",") ? value.replace(/\./g, "").replace(",", ".") : value)
const CostSchema = z.discriminatedUnion(
"plan",
[
z.object({ plan: z.literal("free") }),
z.object({
plan: z.literal("paid"),
price: z
.string()
.trim()
.min(1, { error: "Bitte einen Preis angeben." })
.transform(toNumber)
.refine((value) => Number.isFinite(value) && value >= 0 && value < 100_000_000, {
error: "Bitte einen gültigen Preis angeben, z. B. 12,99.",
}),
billingCycle: z.enum(BILLING_CYCLES, { error: "Bitte die Abrechnung wählen." }),
}),
],
{ error: "Bitte kostenlos oder bezahlt wählen." }
)
const IdSchema = z.string().uuid()
export type InternalCredentialState = { error: string } | { success: true } | undefined
async function isAdmin() {
const session = await verifySession()
return session.isAdmin
}
function readFields(formData: FormData) {
return {
service: formData.get("service"),
title: formData.get("title") ?? "",
username: formData.get("username"),
url: formData.get("url") ?? "",
}
}
type CostParams = { p_plan: Plan; p_price: number | null; p_billing_cycle: BillingCycle | null }
// Kosten als RPC-Parameter; kostenlos = kein Preis und keine Abrechnung.
// Expliziter Rückgabetyp, damit `"error" in cost` sauber eingrenzt.
function readCost(formData: FormData): CostParams | { error: string } {
const parsed = CostSchema.safeParse({
plan: formData.get("plan"),
price: formData.get("price") ?? "",
billingCycle: formData.get("billingCycle"),
})
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte die Kosten prüfen." }
return parsed.data.plan === "paid"
? { p_plan: "paid", p_price: Math.round(parsed.data.price * 100) / 100, p_billing_cycle: parsed.data.billingCycle }
: { p_plan: "free", p_price: null, p_billing_cycle: null }
}
export async function createInternalCredential(
_prevState: InternalCredentialState,
formData: FormData
): Promise<InternalCredentialState> {
if (!(await isAdmin())) return { error: "Keine Berechtigung." }
const parsed = CreateSchema.safeParse({ ...readFields(formData), password: formData.get("password") })
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen." }
const cost = readCost(formData)
if ("error" in cost) return cost
const { service, title, username, url, password } = parsed.data
const supabase = await createClient()
const { error } = await supabase.rpc("create_internal_credential", {
p_service: service,
p_title: title,
p_username: username,
p_url: url,
...cost,
p_password: password,
})
if (error) return { error: "Eintrag konnte nicht gespeichert werden." }
revalidatePath(PATH)
return { success: true }
}
export async function updateInternalCredential(
_prevState: InternalCredentialState,
formData: FormData
): Promise<InternalCredentialState> {
if (!(await isAdmin())) return { error: "Keine Berechtigung." }
// Leeres Passwortfeld = Passwort bleibt, wie es ist
const password = String(formData.get("password") ?? "")
const parsed = UpdateSchema.safeParse({
...readFields(formData),
id: formData.get("id"),
password: password === "" ? undefined : password,
})
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen." }
const cost = readCost(formData)
if ("error" in cost) return cost
const supabase = await createClient()
const { error } = await supabase.rpc("update_internal_credential", {
p_id: parsed.data.id,
p_service: parsed.data.service,
p_title: parsed.data.title,
p_username: parsed.data.username,
p_url: parsed.data.url,
...cost,
p_password: parsed.data.password ?? null,
})
if (error) return { error: "Eintrag konnte nicht aktualisiert werden." }
revalidatePath(PATH)
return { success: true }
}
export async function revealInternalCredential(id: string): Promise<{ password: string } | { error: string }> {
if (!(await isAdmin()) || !IdSchema.safeParse(id).success) return { error: "Keine Berechtigung." }
const supabase = await createClient()
const { data, error } = await supabase.rpc("reveal_internal_credential", { p_id: id })
if (error || typeof data !== "string") return { error: "Passwort konnte nicht abgerufen werden." }
return { password: data }
}
export async function deleteInternalCredential(id: string): Promise<InternalCredentialState> {
if (!(await isAdmin())) return { error: "Keine Berechtigung." }
if (!IdSchema.safeParse(id).success) return { error: "Ungültiger Eintrag." }
const supabase = await createClient()
const { error } = await supabase.from("internal_credentials").delete().eq("id", id)
if (error) return { error: "Eintrag konnte nicht gelöscht werden." }
revalidatePath(PATH)
return { success: true }
}

View file

@ -0,0 +1,30 @@
import { formatCurrency } from "@/modules/shared/lib/invoice-math";
export const PLANS = ["free", "paid"] as const
export const BILLING_CYCLES = ["monthly", "yearly", "once"] as const
export type Plan = (typeof PLANS)[number]
export type BillingCycle = (typeof BILLING_CYCLES)[number]
export const BILLING_LABELS: Record<BillingCycle, { label: string; suffix: string }> = {
monthly: { label: "Monatlich", suffix: "/ Monat" },
yearly: { label: "Jährlich", suffix: "/ Jahr" },
once: { label: "Einmalig", suffix: "einmalig" },
}
type Cost = { plan: Plan; price: number | null; billing_cycle: BillingCycle | null }
export function formatCost(cost: Cost) {
if (cost.plan === "free" || cost.price === null || cost.billing_cycle === null) return "Kostenlos"
return `${formatCurrency(cost.price)} ${BILLING_LABELS[cost.billing_cycle].suffix}`
}
// Laufende Kosten pro Monat: jährliche Beträge anteilig, einmalige zählen nicht
export function monthlyCost(costs: Cost[]) {
return costs.reduce((sum, cost) => {
if (cost.plan !== "paid" || cost.price === null) return sum
if (cost.billing_cycle === "monthly") return sum + cost.price
if (cost.billing_cycle === "yearly") return sum + cost.price / 12
return sum
}, 0)
}

View file

@ -0,0 +1,30 @@
import "server-only";
import { BillingCycle, Plan } from "../lib/internal-costs";
import { createClient } from "@/lib/supabase/server";
export type InternalCredential = {
id: string;
service: string;
title: string;
username: string;
url: string;
plan: Plan;
price: number | null;
billing_cycle: BillingCycle | null;
updated_at: string;
}
export async function getInternalCredentials() {
const supabase = await createClient()
const { data, error } = await supabase
.from("internal_credentials")
.select("id, service, title, username, url, plan, price, billing_cycle, updated_at")
.order("service")
.order("title")
if (error) throw new Error("Interne Zugänge konnten nicht geladen werden.")
// Numeric kann je nach Größe als String ankommen
return (data as InternalCredential[]).map((row) => ({ ...row, price: row.price === null ? null : Number(row.price)}))
}

View file

@ -0,0 +1,251 @@
"use client"
import { useActionState, useState, type ReactElement } from "react"
import { EyeIcon, EyeOffIcon, PlusIcon, WandSparklesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import {
createInternalCredential,
updateInternalCredential,
type InternalCredentialState,
} from "@/modules/admin/actions/internal-credentials"
import { BILLING_CYCLES, BILLING_LABELS, PLANS, type Plan } from "@/modules/admin/lib/internal-costs"
import type { InternalCredential } from "@/modules/admin/queries/internal-credentials"
import { SERVICES } from "@/modules/shared/data/services-data"
import { formatAmount } from "@/modules/shared/lib/invoice-math"
import { ServiceLogo } from "@/modules/shared/ui/components/service-logo"
const selectClassName =
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50"
const PLAN_LABELS: Record<Plan, string> = { free: "Kostenlos", paid: "Bezahlt" }
// 20 zufällige Zeichen (Web Crypto) ohne leicht verwechselbare Zeichen wie l/1 oder O/0
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#$%&*+-=?@_"
function generatePassword(length = 20) {
const values = crypto.getRandomValues(new Uint32Array(length))
return Array.from(values, (value) => ALPHABET[value % ALPHABET.length]).join("")
}
function DialogBody({ credential, onDone }: { credential: InternalCredential | null; onDone: () => void }) {
const [serviceId, setServiceId] = useState(credential?.service ?? SERVICES[0].id)
const [plan, setPlan] = useState<Plan>(credential?.plan ?? "free")
const [password, setPassword] = useState("")
const [visible, setVisible] = useState(false)
async function submit(previous: InternalCredentialState, formData: FormData) {
const action = credential ? updateInternalCredential : createInternalCredential
const result = await action(previous, formData)
if (result && "success" in result) onDone()
return result
}
const [state, formAction, pending] = useActionState(submit, undefined)
return (
<>
<DialogHeader className="mb-4">
<DialogTitle>{credential ? "Zugang bearbeiten" : "Neuer interner Zugang"}</DialogTitle>
</DialogHeader>
<form action={formAction}>
{credential && <input type="hidden" name="id" value={credential.id} />}
<FieldGroup>
<Field>
<FieldLabel htmlFor="internal-service">
Dienst <span className="text-destructive">*</span>
</FieldLabel>
<div className="flex items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border border-input bg-background">
<ServiceLogo serviceId={serviceId} size={20} className="size-5 object-contain" />
</div>
<select
id="internal-service"
name="service"
value={serviceId}
onChange={(event) => setServiceId(event.target.value)}
required
className={selectClassName}
>
{SERVICES.map((service) => (
<option key={service.id} value={service.id}>
{service.label}
</option>
))}
</select>
</div>
</Field>
<Field>
<FieldLabel htmlFor="internal-title">Bezeichnung</FieldLabel>
<Input
id="internal-title"
name="title"
defaultValue={credential?.title ?? ""}
placeholder="z. B. Since3 Team-Account (optional)"
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="internal-username">
Benutzername <span className="text-destructive">*</span>
</FieldLabel>
<Input
id="internal-username"
name="username"
defaultValue={credential?.username ?? ""}
required
autoComplete="off"
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="internal-password">
Passwort {!credential && <span className="text-destructive">*</span>}
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="internal-password"
name="password"
type={visible ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
required={!credential}
autoComplete="new-password"
placeholder={credential ? "Leer lassen, um es zu behalten" : undefined}
className="font-mono focus-visible:ring-0"
/>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => setVisible(!visible)}
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => {
setPassword(generatePassword())
setVisible(true)
}}
aria-label="Sicheres Passwort erzeugen"
title="Sicheres Passwort erzeugen"
>
<WandSparklesIcon />
</Button>
</div>
</Field>
<Field>
<FieldLabel htmlFor="internal-url">Login-Adresse</FieldLabel>
<Input
id="internal-url"
name="url"
type="url"
defaultValue={credential?.url ?? ""}
placeholder="https:// (optional)"
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel>Kosten</FieldLabel>
<div className="grid grid-cols-2 gap-1 rounded-lg border border-input p-1">
{PLANS.map((option) => (
<label
key={option}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-center text-sm transition-colors has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
plan === option ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
<input
type="radio"
name="plan"
value={option}
checked={plan === option}
onChange={() => setPlan(option)}
className="sr-only"
/>
{PLAN_LABELS[option]}
</label>
))}
</div>
</Field>
{plan === "paid" && (
<div className="grid gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="internal-price">
Preis in € <span className="text-destructive">*</span>
</FieldLabel>
<Input
id="internal-price"
name="price"
inputMode="decimal"
required
placeholder="z. B. 12,99"
defaultValue={credential?.price != null ? formatAmount(credential.price) : ""}
className="tabular-nums focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="internal-billing">Abrechnung</FieldLabel>
<select
id="internal-billing"
name="billingCycle"
defaultValue={credential?.billing_cycle ?? "monthly"}
className={selectClassName}
>
{BILLING_CYCLES.map((cycle) => (
<option key={cycle} value={cycle}>
{BILLING_LABELS[cycle].label}
</option>
))}
</select>
</Field>
</div>
)}
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</>
)
}
export function InternalCredentialDialog({ credential, trigger }: { credential?: InternalCredential; trigger?: ReactElement }) {
const [open, setOpen] = useState(false)
const [instance, setInstance] = useState(0)
// Beim Schließen neu aufbauen, damit das Formular beim nächsten Öffnen leer bzw. aktuell ist
function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) setInstance((key) => key + 1)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={trigger ?? <Button variant="outline" className="cursor-pointer" />}>
{!trigger && (
<>
<PlusIcon className="size-4" />
Neuer Zugang
</>
)}
</DialogTrigger>
<DialogContent>
<DialogBody key={instance} credential={credential ?? null} onDone={() => handleOpenChange(false)} />
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,297 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOffIcon, PencilIcon, SearchIcon, Trash2Icon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { deleteInternalCredential, revealInternalCredential } from "@/modules/admin/actions/internal-credentials"
import { formatCost, type Plan } from "@/modules/admin/lib/internal-costs"
import type { InternalCredential } from "@/modules/admin/queries/internal-credentials"
import { getServiceById } from "@/modules/shared/data/services-data"
import { ServiceLogo } from "@/modules/shared/ui/components/service-logo"
import { InternalCredentialDialog } from "./internal-credential-dialog"
const HIDE_AFTER_MS = 30_000
const FILTERS: { value: Plan | "all"; label: string }[] = [
{ value: "all", label: "Alle" },
{ value: "paid", label: "Bezahlt" },
{ value: "free", label: "Kostenlos" },
]
async function fetchPassword(id: string) {
const result = await revealInternalCredential(id)
if ("error" in result) throw new Error(result.error)
return result.password
}
// Kopiert Text; ist er noch nicht geladen, wartet ClipboardItem auf ihn (sonst blockt Safari nach dem await)
function CopyButton({ label, getText }: { label: string; getText: () => string | Promise<string> }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
const text = getText()
if (typeof text === "string") {
await navigator.clipboard.writeText(text)
} else {
const blob = text.then((value) => new Blob([value], { type: "text/plain" }))
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })])
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// Abgebrochen oder vom Browser nicht erlaubt
}
}
return (
<Button type="button" variant="ghost" size="icon-sm" onClick={copy} aria-label={`${label} kopieren`} title={`${label} kopieren`}>
{copied ? <CheckIcon className="size-4 text-signal-success" /> : <CopyIcon className="size-4" />}
</Button>
)
}
function SecretCell({ id }: { id: string }) {
const [password, setPassword] = useState<string | null>(null)
const [pending, setPending] = useState(false)
// Angezeigtes Passwort nach 30 Sekunden wieder verbergen
useEffect(() => {
if (password === null) return
const timer = setTimeout(() => setPassword(null), HIDE_AFTER_MS)
return () => clearTimeout(timer)
}, [password])
async function toggle() {
if (password !== null) {
setPassword(null)
return
}
setPending(true)
try {
setPassword(await fetchPassword(id))
} catch {
// Fehler: Passwort bleibt verborgen
} finally {
setPending(false)
}
}
return (
<div className="flex items-center gap-1">
<span className="font-mono text-small break-all sm:min-w-32">{password ?? "••••••••"}</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggle}
disabled={pending}
aria-label={password !== null ? "Passwort verbergen" : "Passwort anzeigen"}
>
{password !== null ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
</Button>
<CopyButton label="Passwort" getText={() => password ?? fetchPassword(id)} />
</div>
)
}
function CostLabel({ credential }: { credential: InternalCredential }) {
return credential.plan === "paid" ? (
<span className="tabular-nums whitespace-nowrap">{formatCost(credential)}</span>
) : (
<Badge variant="neutral">Kostenlos</Badge>
)
}
function DeleteDialog({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
async function handleDelete() {
setPending(true)
const result = await deleteInternalCredential(id)
setPending(false)
if (result && "error" in result) {
setError(result.error)
return
}
setOpen(false)
router.refresh()
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Löschen" />}>
<Trash2Icon className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>Zugang löschen</DialogTitle>
<DialogDescription>
Möchtest du den Zugang <span className="font-medium text-foreground">{name}</span> wirklich löschen?
Das Passwort wird dabei auch aus dem Vault entfernt.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function InternalCredentialsTable({ credentials }: { credentials: InternalCredential[] }) {
const [query, setQuery] = useState("")
const [filter, setFilter] = useState<Plan | "all">("all")
const term = query.trim().toLowerCase()
const visible = credentials.filter(
(credential) =>
(filter === "all" || credential.plan === filter) &&
(!term ||
[credential.title, getServiceById(credential.service).label, credential.username, credential.url]
.join(" ")
.toLowerCase()
.includes(term))
)
if (credentials.length === 0) {
return (
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
Noch keine internen Zugänge hinterlegt.
</p>
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-1 rounded-lg border p-1">
{FILTERS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={filter === option.value}
onClick={() => setFilter(option.value)}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-sm transition-colors",
filter === option.value ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
{option.label}
</button>
))}
</div>
<label className="relative sm:w-80">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Suchen, z. B. Vercel"
aria-label="Zugänge durchsuchen"
className="pl-9"
/>
</label>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Zugang</TableHead>
<TableHead className="hidden md:table-cell">Benutzername</TableHead>
<TableHead>Passwort</TableHead>
<TableHead className="hidden lg:table-cell">Kosten</TableHead>
<TableHead className="w-20 sm:w-24" />
</TableRow>
</TableHeader>
<TableBody>
{visible.map((credential) => {
const service = getServiceById(credential.service)
const name = credential.title.trim() || service.label
return (
<TableRow key={credential.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" />
</div>
<div className="flex min-w-0 flex-col">
<span className="flex items-center gap-1.5 font-medium">
{name}
{credential.url && (
<a
href={credential.url}
target="_blank"
rel="noreferrer"
aria-label={`${name} öffnen`}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLinkIcon className="size-3.5" />
</a>
)}
</span>
{credential.title.trim() && <span className="text-small text-muted-foreground">{service.label}</span>}
<span className="text-small text-muted-foreground break-all md:hidden">{credential.username}</span>
<span className="mt-1 text-small lg:hidden">
<CostLabel credential={credential} />
</span>
</div>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center gap-1">
<span className="text-muted-foreground break-all">{credential.username}</span>
<CopyButton label="Benutzername" getText={() => credential.username} />
</div>
</TableCell>
<TableCell>
<SecretCell id={credential.id} />
</TableCell>
<TableCell className="hidden lg:table-cell">
<CostLabel credential={credential} />
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<InternalCredentialDialog
credential={credential}
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Bearbeiten">
<PencilIcon className="size-4" />
</Button>
}
/>
<DeleteDialog id={credential.id} name={name} />
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{visible.length === 0 && <p className="text-center text-sm text-muted-foreground">Kein Zugang gefunden.</p>}
</div>
)
}

View file

@ -0,0 +1,44 @@
import { monthlyCost } from "@/modules/admin/lib/internal-costs"
import { getInternalCredentials } from "@/modules/admin/queries/internal-credentials"
import { InternalCredentialDialog } from "@/modules/admin/ui/components/internal-credential-dialog"
import { InternalCredentialsTable } from "@/modules/admin/ui/components/internal-credentials-table"
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
function Stat({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<div className="flex min-w-0 flex-col gap-1 rounded-lg border border-border p-4">
<span className="text-small text-muted-foreground">{label}</span>
<span className="text-xl font-semibold tabular-nums break-words sm:text-2xl">{value}</span>
{detail && <span className="text-small text-muted-foreground">{detail}</span>}
</div>
)
}
export default async function InternalAccountsView() {
const credentials = await getInternalCredentials()
const perMonth = monthlyCost(credentials)
const paid = credentials.filter((credential) => credential.plan === "paid").length
return (
<div className="flex w-full flex-col gap-6 md:gap-8">
<div className="flex w-full flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold md:text-3xl">Internal Accounts</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Zugänge von Since3 selbst – verschlüsselt im Supabase Vault und nur für Admins sichtbar.
Jedes Anzeigen oder Kopieren eines Passworts wird protokolliert.
</p>
</div>
<InternalCredentialDialog />
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="Laufende Kosten" value={`${formatCurrency(perMonth)} / Monat`} detail={`≈ ${formatCurrency(perMonth * 12)} im Jahr`} />
<Stat label="Bezahlte Dienste" value={String(paid)} detail={`von ${credentials.length} Zugängen`} />
<Stat label="Kostenlose Dienste" value={String(credentials.length - paid)} />
</div>
<InternalCredentialsTable credentials={credentials} />
</div>
)
}

View file

@ -3,6 +3,7 @@ import {
FoldersIcon,
InfoIcon,
KeyIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutTemplateIcon,
LucideIcon,
@ -34,6 +35,11 @@ export const adminMenu: MenuItem[] = [
url: "/admin/templates",
icon: LayoutTemplateIcon
},
{
title: "Internal Accounts",
url: "/admin/internal-accounts",
icon: KeyRoundIcon
},
{
title: "Settings",
url: "/admin/settings",

View file

@ -1,8 +1,28 @@
import { brandName } from "../lib/content"
import type { TemplateContent, TemplateDefinition } from "../lib/types"
import type { FieldDef, ListFieldDef, SectionData, TemplateContent, TemplateDefinition } from "../lib/types"
const clean = (value: string) => value.replace(/\*/g, "").replace(/\s*\n\s*/g, " · ").trim()
type PageSection = { label: string; fields: (FieldDef | ListFieldDef)[]; data: SectionData | undefined; hidden: boolean }
// Feste Sektionen der Vorlage und – im Baukasten – die Bausteine in ihrer Reihenfolge
function pageSections(def: TemplateDefinition, content: TemplateContent): PageSection[] {
const fixed = def.sections.map((section) => ({
label: section.label,
fields: section.fields,
data: content.data[section.key],
hidden: content.hidden.includes(section.key),
}))
const library = new Map(def.builder?.library.map((block) => [block.type, block]))
const blocks = content.blocks.flatMap((block) => {
const definition = library.get(block.type)
if (!definition) return []
const menu = typeof block.data.menu === "string" ? block.data.menu.trim() : ""
return [{ label: menu || definition.label, fields: definition.fields, data: block.data, hidden: block.hidden }]
})
return [...fixed, ...blocks]
}
export function businessName(def: TemplateDefinition, content: TemplateContent) {
return brandName(content) ?? def.name
}
@ -11,9 +31,9 @@ export function businessName(def: TemplateDefinition, content: TemplateContent)
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
for (const section of pageSections(def, content)) {
const data = section.data
if (!data || section.hidden) continue
const lines: string[] = []
for (const field of section.fields) {
@ -43,8 +63,8 @@ export function buildKnowledge(def: TemplateDefinition, content: TemplateContent
// 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]
for (const section of pageSections(def, content)) {
const data = section.data
if (!data) continue
for (const field of section.fields) {
const value = data[field.key]

View file

@ -0,0 +1,124 @@
import { unsplash } from "../../lib/content"
import { defineBuilder } from "../blocks/builder"
export const atelier = defineBuilder({
key: "atelier",
name: "Atelier",
industry: "Universell: Manufaktur & Boutique",
description: "Edel wie ein Magazin: Cover-Schriftzug, Bild öffnet sich wie Jalousien, Bilder färben sich unter der Maus ein, Kollektion im Querlauf und ein Wort voller Bild. Aus Bausteinen – frei änderbar.",
cover: unsplash("1555041469-a586c61ea9bc", 1200),
samples: {
inquiry: "Guten Tag, wir suchen einen Esstisch aus Eiche für acht Personen, gern mit passender Bank – nach Maß für unser neues Haus. Ist so etwas möglich, und wie lange wäre die Lieferzeit? Herzliche Grüße, Familie Brandt",
topic: "Hinter den Kulissen: So entsteht Sessel Ove",
},
palettes: [
{ key: "elfenbein", label: "Elfenbein", bg: "#f4f0e8", fg: "#1a1714", muted: "#756d63", surface: "#e9e2d6", accent: "#8c3b2a" },
{ key: "salbei", label: "Salbei", bg: "#eef0ea", fg: "#1b211d", muted: "#66705f", surface: "#e0e5da", accent: "#4f6b4f" },
{ key: "tinte", label: "Tinte", dark: true, bg: "#14110f", fg: "#efe8de", muted: "#9a8f82", surface: "#1f1b18", accent: "#c99a5b" },
],
fonts: [
{ key: "cormorant", label: "Cormorant & Manrope" },
{ key: "garamond", label: "EB Garamond & Host Grotesk" },
],
brand: {
name: "Lindqvist",
tagline: "Manufaktur für Möbel & Objekte · seit 1924",
email: "atelier@lindqvist.de",
phone: "+49 40 3861 2290",
address: "Bei den Mühren 4, 20457 Hamburg",
logo: "",
},
seed: [
{ type: "nav-masthead", data: { edition: "Ausgabe Nº 12 · Herbst/Winter", cta: "Termin vereinbaren", href: "#besuch" } },
{
type: "hero-editorial",
data: {
eyebrow: "Manufaktur seit 1924",
title: "Gemacht,\num zu *bleiben.*",
text: "Möbel und Objekte aus massivem Holz und feinen Stoffen, gefertigt von Hand in unserer Hamburger Werkstatt – für Räume, die Geschichten erzählen.",
cta: "Kollektion entdecken",
href: "#kollektion",
image: unsplash("1555041469-a586c61ea9bc", 1600),
caption: "Sofa Mira · Samt in Moosgrün",
},
},
{
type: "text-bild",
data: {
menu: "Manufaktur",
eyebrow: "Die Manufaktur",
title: "Hundert Jahre\n*Handwerk.*",
text: "Was 1924 als kleine Tischlerei begann, ist heute eine Manufaktur mit 22 Meisterinnen und Meistern. Wir arbeiten mit Hölzern aus Norddeutschland, Stoffen aus europäischen Webereien und einer Geduld, die man jedem Stück ansieht.",
quote: "Ein gutes Möbel erkennt man nicht beim Kauf, sondern nach dreißig Jahren.",
signature: "Henrik Lindqvist",
image: unsplash("1426927308491-6380b6a9936f", 1400),
caption: "Hobel und Stecheisen in unserer Werkstatt in der Speicherstadt",
},
},
{
type: "projekte-querlauf",
data: {
menu: "Kollektion",
eyebrow: "Kollektion",
title: "Die *Kollektion*",
items: [
{ title: "Sessel Ove", category: "Samt, Senfgelb", year: "2026", image: unsplash("1586023492125-27b2c045efd7", 1600) },
{ title: "Sofa Mira", category: "Bouclé, Kreide", year: "2026", image: unsplash("1493663284031-b7e3aefcae8e", 1600) },
{ title: "Stuhl Elin", category: "Eiche, Leinen", year: "2025", image: unsplash("1567538096630-e0c55bd6374c", 1600) },
{ title: "Vasen Sora", category: "Steinzeug, handgedreht", year: "2025", image: unsplash("1520408222757-6f9f95d87d5d", 1600) },
{ title: "Schalen Kumo", category: "Keramik, glasiert", year: "2024", image: unsplash("1610701596007-11502861dcfa", 1600) },
],
},
},
{
type: "wortmaske",
data: {
eyebrow: "Was uns antreibt",
word: "Handwerk",
text: "Rund sechzig Arbeitsstunden stecken in jedem Sessel. Keine Maschine ersetzt das Gefühl in den Händen, das weiß, wann eine Fuge sitzt und eine Oberfläche fertig ist.",
image: unsplash("1493106641515-6b5631de4bb9", 2000),
},
},
{
type: "ablauf-spalten",
data: {
menu: "Werkstatt",
eyebrow: "Vom Baum zum Möbel",
title: "In vier *Schritten.*",
items: [
{ title: "Auswahl", text: "Jedes Brett wählen wir selbst aus – aus nachhaltiger Forstwirtschaft in Norddeutschland." },
{ title: "Trocknung", text: "Zwei Jahre lagert das Holz an der Luft, bevor es verarbeitet wird." },
{ title: "Fertigung", text: "Verbindungen werden von Hand gezinkt, geleimt und geschliffen – ganz ohne Metall." },
{ title: "Veredelung", text: "Natürliche Öle und Wachse schützen die Oberfläche und lassen das Holz atmen." },
],
},
},
{
type: "stimmen-zitat",
data: {
eyebrow: "Stimmen",
items: [
{ quote: "Der Tisch steht seit zwanzig Jahren in unserer Küche. Er wird mit jedem Jahr schöner.", author: "Familie Albers", role: "Hamburg" },
{ quote: "Man spürt bei jedem Stück, wie viel Zeit und Liebe darin steckt. Für unsere Projekte gibt es keine bessere Adresse.", author: "Clara Nyström", role: "Innenarchitektin" },
{ quote: "Sessel Ove ist das Möbel, das jeder Besuch zuerst ausprobieren möchte.", author: "Jonathan Weiß", role: "Kunde seit 2019" },
],
},
},
{
type: "kontakt-formular",
data: {
menu: "Besuch",
eyebrow: "Besuch",
title: "Besuchen Sie\nunsere *Werkstatt.*",
text: "Wir zeigen Ihnen gern, wie Ihr Möbel entsteht – nach Vereinbarung auch samstags.",
topics: "Werkstattbesuch, Maßanfertigung, Beratung, Sonstiges",
button: "Termin anfragen",
success: "Danke! Wir melden uns, um Ihren Besuch zu planen.",
hours: "Di–Sa, 10–18 Uhr",
},
},
{ type: "footer-wortmarke", data: { text: "Möbel für Generationen. Gemacht in Hamburg.", cta: "Werkstatt besuchen", href: "#besuch" } },
],
})
export type AtelierData = typeof atelier.defaults

View file

@ -0,0 +1,4 @@
import type { FontPair } from "../../lib/types"
import { pairs } from "../blocks/fonts"
export const fonts: Record<string, FontPair> = { cormorant: pairs.cormorant, garamond: pairs.garamond }

View file

@ -0,0 +1,10 @@
"use client"
import type { TemplateProps } from "../../lib/types"
import { BlockPage } from "../blocks/page"
import { atelier, type AtelierData } from "./definition"
import { fonts } from "./fonts"
export function AtelierTemplate({ content }: TemplateProps<AtelierData>) {
return <BlockPage def={atelier} content={content} fonts={fonts[content.fonts] ?? fonts.cormorant} />
}

View file

@ -0,0 +1,157 @@
import { unsplash } from "../../lib/content"
import { defineBuilder } from "../blocks/builder"
export const aurora = defineBuilder({
key: "aurora",
name: "Aurora",
industry: "Universell: Moderne Dienstleister",
description: "Leuchtend und modern: Farbwolken und Lichtkugel mit kreisenden Schlagworten, Bento-Kacheln mit Lichtkegel, Zeitstrahl, endlose Stimmen-Wand und Pakete mit Lichtrand. Aus Bausteinen – frei änderbar.",
cover: unsplash("1614850523459-c2f4c699c52e", 1200),
samples: {
inquiry: "Hallo Lumen-Team, wir sind ein Maschinenbauer mit 80 Mitarbeitenden und wollen unsere Angebotserstellung mit KI automatisieren. Können Sie uns dabei begleiten – und was würde ein erster Workshop kosten? Beste Grüße, Jan Petersen",
topic: "5 Prozesse, die jedes Unternehmen 2026 automatisieren sollte",
},
palettes: [
{ key: "nacht", label: "Nacht", dark: true, bg: "#070913", fg: "#eef0ff", muted: "#8d93b5", surface: "#10142a", accent: "#7c5cff" },
{ key: "perle", label: "Perle", bg: "#f7f7fb", fg: "#11121c", muted: "#5f6380", surface: "#ececf6", accent: "#5b5bf7" },
{ key: "polar", label: "Polar", dark: true, bg: "#04120f", fg: "#e9fff8", muted: "#7fa79b", surface: "#0b1f1a", accent: "#2ee6a8" },
],
fonts: [
{ key: "funnel", label: "Funnel Display & Funnel Sans" },
{ key: "park", label: "Parkinsans & Rethink Sans" },
],
brand: {
name: "Lumen",
tagline: "Beratung für digitale Exzellenz",
email: "hallo@lumen-beratung.de",
phone: "+49 30 6098 4410",
address: "Torstraße 109, 10119 Berlin",
logo: "",
},
seed: [
{ type: "nav-glas", data: { cta: "Gespräch buchen", href: "#kontakt" } },
{
type: "hero-aurora",
data: {
badge: "Neu: KI-Workshops für Teams",
title: "Wir machen Unternehmen\nbereit für *morgen.*",
text: "Strategie, Design und Technologie aus einer Hand – für Unternehmen, die nicht abwarten, sondern gestalten wollen.",
cta: "Kostenloses Erstgespräch",
href: "#kontakt",
ctaSecondary: "Leistungen ansehen",
hrefSecondary: "#leistungen",
keywords: "Strategie, Design, KI, Automatisierung, Wachstum, Daten",
},
},
{
type: "logos",
data: {
title: "Mehr als 120 Unternehmen vertrauen uns",
items: [
{ name: "Nordwerk", logo: "" },
{ name: "Velora", logo: "" },
{ name: "Kraftbau", logo: "" },
{ name: "Brightfield", logo: "" },
{ name: "Okuma", logo: "" },
{ name: "Tessin & Co", logo: "" },
{ name: "Lumio", logo: "" },
],
},
},
{
type: "leistungen-bento",
data: {
eyebrow: "Leistungen",
title: "Alles, was Wachstum\n*möglich* macht.",
text: "Wir begleiten Sie von der ersten Idee bis zum messbaren Ergebnis – mit einem festen Team, das Ihr Geschäft versteht.",
items: [
{ title: "Strategie & Beratung", text: "Wir finden heraus, wo Ihr größter Hebel liegt, und machen daraus einen Fahrplan mit klaren Zielen.", image: unsplash("1531297484001-80022131f5a1", 1400) },
{ title: "Design & Marke", text: "Auftritte, die man sich merkt – online wie offline.", image: "" },
{ title: "Software & KI", text: "Maßgeschneiderte Tools, die Ihrem Team Arbeit abnehmen.", image: "" },
{ title: "Automatisierung", text: "Wiederkehrende Abläufe laufen von selbst – zuverlässig und nachvollziehbar.", image: "" },
{ title: "Daten & Analyse", text: "Dashboards, die zeigen, was wirklich zählt.", image: "" },
{ title: "Wachstum & Marketing", text: "Kampagnen, die messbar neue Kundschaft bringen.", image: "" },
],
},
},
{
type: "ablauf-linie",
data: {
eyebrow: "So arbeiten wir",
title: "Vom ersten Gespräch\nzum *Ergebnis.*",
items: [
{ title: "Kennenlernen", text: "Im kostenlosen Erstgespräch verstehen wir Ihr Geschäft, Ihre Ziele und Ihre größten Hürden.", duration: "Woche 1" },
{ title: "Analyse & Strategie", text: "Wir priorisieren nach Wirkung und Aufwand und legen gemeinsam einen klaren Fahrplan fest.", duration: "2–3 Wochen" },
{ title: "Umsetzung", text: "In kurzen Sprints entstehen Ergebnisse, die Sie sofort sehen und testen können.", duration: "6–12 Wochen" },
{ title: "Wachstum", text: "Wir messen, lernen und verbessern laufend – damit der Erfolg bleibt.", duration: "fortlaufend" },
],
},
},
{
type: "zahlen",
data: {
eyebrow: "Ergebnisse",
title: "Zahlen, die *überzeugen.*",
items: [
{ value: "120", suffix: "+", label: "erfolgreiche Projekte" },
{ value: "38", suffix: " %", label: "mehr Effizienz im Schnitt" },
{ value: "4,9", suffix: "/5", label: "Kundenzufriedenheit" },
{ value: "12", suffix: "", label: "Expertinnen und Experten" },
],
},
},
{
type: "stimmen-wand",
data: {
title: "Was unsere Kunden *sagen.*",
items: [
{ quote: "Lumen hat in sechs Wochen geschafft, woran wir zwei Jahre gescheitert sind. Unser Angebotsprozess läuft heute fast von selbst.", author: "Mira Schulz", role: "COO, Velora", image: unsplash("1534528741775-53994a69daeb", 300) },
{ quote: "Klar in der Sprache, schnell in der Umsetzung und immer erreichbar. So wünscht man sich einen Partner.", author: "David Moreno", role: "Gründer, Brightfield", image: unsplash("1507003211169-0a1dd7228f2d", 300) },
{ quote: "Die Workshops waren der Wendepunkt. Unser Team arbeitet heute ganz anders – und lieber.", author: "Lena Brandt", role: "Head of Operations, Nordwerk", image: unsplash("1494790108377-be9c29b29330", 300) },
{ quote: "Endlich Dashboards, die wir wirklich nutzen. Jede Entscheidung basiert heute auf Daten statt Bauchgefühl.", author: "Jonas Becker", role: "Geschäftsführer, Kraftbau", image: unsplash("1472099645785-5658abf4ff4e", 300) },
{ quote: "Professionell, herzlich und mit beeindruckendem technischem Tiefgang.", author: "Amara Okafor", role: "CEO, Okuma", image: unsplash("1531123897727-8f129e1688ce", 300) },
{ quote: "Wir haben unsere Kosten um ein Drittel gesenkt und gleichzeitig die Kundenzufriedenheit erhöht.", author: "Tobias Lehmann", role: "CFO, Tessin & Co", image: unsplash("1560250097-0b93528c311a", 300) },
],
},
},
{
type: "pakete",
data: {
title: "Klare Pakete.\n*Faire* Preise.",
text: "Alle Pakete mit persönlicher Ansprechperson und monatlichem Bericht. Preise zzgl. MwSt.",
plans: [
{ name: "Start", price: "2.900 €", period: "einmalig", text: "Für den ersten Schritt", features: "Workshop mit Ihrem Team\nAnalyse & Potenziale\nKonkreter Fahrplan", ribbon: "", cta: "Anfragen" },
{ name: "Wachstum", price: "5.900 €", period: "pro Monat", text: "Für Teams, die skalieren", features: "Alles aus Start\nFestes Umsetzungsteam\nWöchentliche Sprints\nMonatliches Reporting", ribbon: "Beliebt", cta: "Anfragen" },
{ name: "Partner", price: "Individuell", period: "", text: "Für langfristige Partnerschaften", features: "Alles aus Wachstum\nStrategische Begleitung\nPriorisierter Support\nQuartalsweise Workshops", ribbon: "", cta: "Gespräch vereinbaren" },
],
},
},
{
type: "faq",
data: {
title: "Häufige *Fragen*",
text: "Ihre Frage ist nicht dabei? Schreiben Sie uns – wir antworten innerhalb eines Werktags.",
items: [
{ question: "Für welche Unternehmen arbeitet Lumen?", answer: "Für den Mittelstand ebenso wie für Start-ups – überall dort, wo Teams schneller, digitaler und näher an ihren Kunden werden wollen." },
{ question: "Wie schnell sehen wir Ergebnisse?", answer: "Erste Ergebnisse liefern wir meist nach vier bis sechs Wochen. Jedes Projekt startet mit klaren, messbaren Zielen." },
{ question: "Arbeiten Sie remote oder vor Ort?", answer: "Beides. Workshops machen wir gern bei Ihnen, die Umsetzung läuft meist remote – mit festen wöchentlichen Terminen." },
{ question: "Was passiert mit unseren Daten?", answer: "Wir arbeiten DSGVO-konform mit Servern in der EU und schließen mit jedem Kunden einen Vertrag zur Auftragsverarbeitung." },
],
},
},
{
type: "cta-portal",
data: {
menu: "Kontakt",
eyebrow: "Bereit?",
title: "Lassen Sie uns\nGroßes *starten.*",
text: "Ein 30-minütiges Erstgespräch – kostenlos und unverbindlich.",
cta: "Gespräch buchen",
href: "mailto:hallo@lumen-beratung.de",
},
},
{ type: "footer-schlicht", data: { city: "Berlin", note: "Mit Sorgfalt gemacht in Berlin." } },
],
})
export type AuroraData = typeof aurora.defaults

View file

@ -0,0 +1,4 @@
import type { FontPair } from "../../lib/types"
import { pairs } from "../blocks/fonts"
export const fonts: Record<string, FontPair> = { funnel: pairs.funnel, park: pairs.park }

View file

@ -0,0 +1,10 @@
"use client"
import type { TemplateProps } from "../../lib/types"
import { BlockPage } from "../blocks/page"
import { aurora, type AuroraData } from "./definition"
import { fonts } from "./fonts"
export function AuroraTemplate({ content }: TemplateProps<AuroraData>) {
return <BlockPage def={aurora} content={content} fonts={fonts[content.fonts] ?? fonts.funnel} />
}

View file

@ -0,0 +1,40 @@
import { unsplash } from "../../lib/content"
import { defineBuilder } from "../blocks/builder"
// Leer starten: nur ein Grundgerüst, alles Weitere wird im Editor aus Bausteinen zusammengeklickt
export const baukasten = defineBuilder({
key: "baukasten",
name: "Baukasten",
industry: "Eigene Seite aus Bausteinen",
description: "Selbst zusammenklicken: über 30 Premium-Bausteine für Start, Leistungen, Projekte, Stimmen, Kontakt und mehr – frei kombinierbar, sortierbar und mit jeder Palette nutzbar.",
cover: unsplash("1557672172-298e090bd0f1", 1200),
samples: {
inquiry: "Guten Tag, wir sind ein Familienunternehmen und möchten unsere Website komplett erneuern – modern, hochwertig und gut auffindbar. Wie läuft so ein Projekt bei Ihnen ab und mit welchen Kosten müssen wir rechnen? Viele Grüße, Sandra Keller",
topic: "Neu bei uns: persönliche Beratung jetzt auch per Video-Call",
},
palettes: [
{ key: "weiss", label: "Weiß", bg: "#fafaf8", fg: "#111111", muted: "#6b6b66", surface: "#efefea", accent: "#111111" },
{ key: "sand", label: "Sand", bg: "#f3eee6", fg: "#1d1a16", muted: "#776f64", surface: "#e8e1d5", accent: "#b0643c" },
{ key: "nacht", label: "Nacht", dark: true, bg: "#0c0c0d", fg: "#f0eeea", muted: "#8a8780", surface: "#18181a", accent: "#e8c27a" },
{ key: "ozean", label: "Ozean", dark: true, bg: "#08131d", fg: "#e8f1f7", muted: "#7f97a8", surface: "#102231", accent: "#4cc3ff" },
{ key: "wald", label: "Wald", bg: "#e9ede6", fg: "#16201a", muted: "#5f6b62", surface: "#dbe2d6", accent: "#2f6b4a" },
{ key: "koralle", label: "Koralle", bg: "#fff6f2", fg: "#2a1410", muted: "#8a6a62", surface: "#fbe6de", accent: "#ff5a3c" },
],
fonts: [
{ key: "grotesk", label: "Host Grotesk" },
{ key: "kino", label: "Noto Serif Display & Host Grotesk" },
{ key: "funnel", label: "Funnel Display & Funnel Sans" },
{ key: "mona", label: "Mona Sans (variabel)" },
],
brand: {
name: "Nordlicht",
tagline: "Ihr Unternehmen in einem Satz",
email: "hallo@nordlicht.de",
phone: "+49 40 1234 5678",
address: "Musterstraße 1, 20095 Hamburg",
logo: "",
},
seed: [{ type: "nav-leiste" }, { type: "hero-vollbild" }, { type: "statement" }, { type: "kontakt-formular" }, { type: "footer-schlicht" }],
})
export type BaukastenData = typeof baukasten.defaults

View file

@ -0,0 +1,4 @@
import type { FontPair } from "../../lib/types"
import { pairs } from "../blocks/fonts"
export const fonts: Record<string, FontPair> = { grotesk: pairs.grotesk, kino: pairs.kino, funnel: pairs.funnel, mona: pairs.mona }

View file

@ -0,0 +1,10 @@
"use client"
import type { TemplateProps } from "../../lib/types"
import { BlockPage } from "../blocks/page"
import { baukasten, type BaukastenData } from "./definition"
import { fonts } from "./fonts"
export function BaukastenTemplate({ content }: TemplateProps<BaukastenData>) {
return <BlockPage def={baukasten} content={content} fonts={fonts[content.fonts] ?? fonts.grotesk} />
}

View file

@ -0,0 +1,151 @@
"use client"
import { AnimatePresence, motion, useInView } from "motion/react"
import { useEffect, useEffectEvent, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { Reveal, ScrollWords, ease } from "../shared/motion"
import type { StatementData, StoryKapitelData, TextBildData } from "./definitions"
import type { BlockProps } from "./types"
import { container, easeInOut, Eyebrow, Heading, InkImage } from "./ui"
export function Statement({ data }: BlockProps<StatementData>) {
return (
<section className={cn(container, "py-28 md:py-44")}>
<div className="grid gap-10 md:grid-cols-12 md:gap-6">
<Reveal className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</Reveal>
<div className="md:col-span-9">
<ScrollWords text={data.text} className="font-t-display text-[clamp(1.9rem,4.2vw,4.25rem)] leading-[1.08] tracking-[-0.02em]" />
{data.author.trim() && (
<Reveal className="mt-12">
<p className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span aria-hidden className="h-px w-10 bg-t-accent" />
<span className="font-medium">{data.author}</span>
<span className="text-t-muted">{data.role}</span>
</p>
</Reveal>
)}
</div>
</div>
</section>
)
}
type Chapter = StoryKapitelData["items"][number]
// Geschichte in Kapiteln: links ein stehendes Bild, das sich mit dem aktiven Kapitel von unten neu aufdeckt
export function StoryKapitel({ data }: BlockProps<StoryKapitelData>) {
const [active, setActive] = useState(0)
const current = Math.min(active, Math.max(0, data.items.length - 1))
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-8 md:grid-cols-12 md:gap-6">
<Reveal className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</Reveal>
<Heading text={data.title} className="md:col-span-9" />
</div>
<div className="mt-12 grid gap-6 md:mt-20 md:grid-cols-12">
<div className="hidden md:col-span-6 md:block">
<div className="sticky top-[12vh] h-[76vh] overflow-hidden rounded-[1.5rem] bg-t-surface">
{data.items.map((item, i) => (
<motion.div
key={i}
className="absolute inset-0"
style={{ zIndex: i }}
initial={false}
animate={{ clipPath: i <= current ? "inset(0% 0% 0% 0%)" : "inset(100% 0% 0% 0%)" }}
transition={{ duration: 1.1, ease: easeInOut }}
>
<motion.div className="absolute inset-0" initial={false} animate={{ scale: i === current ? 1 : 1.12 }} transition={{ duration: 1.6, ease }}>
<TImage src={item.image} alt={item.title} sizes="50vw" />
</motion.div>
</motion.div>
))}
<div aria-hidden className="absolute inset-0 z-[40] bg-gradient-to-t from-black/55 via-transparent to-transparent" />
<div className="absolute bottom-6 left-7 z-[41] overflow-hidden font-t-display text-[clamp(3rem,6vw,6.5rem)] leading-none tracking-[-0.04em] text-white">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={current}
className="block"
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "-100%" }}
transition={{ duration: 0.7, ease: easeInOut }}
>
{data.items[current]?.year}
</motion.span>
</AnimatePresence>
</div>
<div aria-hidden className="absolute right-7 bottom-8 z-[41] flex gap-1.5">
{data.items.map((_, i) => (
<span key={i} className={cn("h-1 rounded-full transition-all duration-500", i === current ? "w-8 bg-white" : "w-1.5 bg-white/50")} />
))}
</div>
</div>
</div>
<div className="flex flex-col md:col-span-5 md:col-start-8">
{data.items.map((item, i) => (
<ChapterText key={i} item={item} active={i === current} onEnter={() => setActive(i)} />
))}
</div>
</div>
</section>
)
}
function ChapterText({ item, active, onEnter }: { item: Chapter; active: boolean; onEnter: () => void }) {
const ref = useRef<HTMLElement>(null)
// Aktiv, sobald das Kapitel die Bildschirmmitte kreuzt
const inView = useInView(ref, { margin: "-45% 0px -45% 0px" })
const enter = useEffectEvent(onEnter)
useEffect(() => {
if (inView) enter()
}, [inView])
return (
<article
ref={ref}
className={cn("flex flex-col justify-center py-10 transition-opacity duration-700 md:min-h-[76vh] md:py-0", active ? "opacity-100" : "md:opacity-30")}
>
<div className="relative mb-8 aspect-[4/5] overflow-hidden rounded-[1.25rem] bg-t-surface md:hidden">
<TImage src={item.image} alt={item.title} sizes="100vw" />
</div>
<p className="font-t-display text-5xl leading-none tracking-[-0.03em] text-t-accent">{item.year}</p>
<h3 className="mt-6 font-t-display text-3xl tracking-[-0.02em] md:text-4xl">{item.title}</h3>
<p className="mt-4 max-w-md text-lg leading-relaxed text-t-muted">{item.text}</p>
</article>
)
}
export function TextBild({ data }: BlockProps<TextBildData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid items-center gap-12 md:grid-cols-12 md:gap-6">
<Reveal className="md:col-span-5">
<figure>
<InkImage src={data.image} alt={data.caption} sizes="(min-width: 768px) 40vw, 100vw" className="aspect-[3/4] rounded-[1.25rem]" />
{data.caption.trim() && <figcaption className="mt-3 text-xs text-t-muted">{data.caption}</figcaption>}
</figure>
</Reveal>
<div className="md:col-span-6 md:col-start-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
<Reveal delay={0.1}>
<p className="mt-8 text-lg leading-relaxed text-t-fg/80 first-letter:float-left first-letter:mt-1.5 first-letter:mr-3 first-letter:font-t-display first-letter:text-[4.6rem] first-letter:leading-[0.78] first-letter:text-t-accent">
{data.text}
</p>
{data.quote.trim() && (
<blockquote className="mt-10 border-l-2 border-t-accent pl-6 font-t-display text-2xl leading-snug italic md:text-3xl">„{data.quote}“</blockquote>
)}
{data.signature.trim() && <p className="mt-10 font-t-display text-3xl italic">{data.signature}</p>}
</Reveal>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,126 @@
"use client"
import {
motion,
useAnimationFrame,
useInView,
useMotionValue,
useReducedMotionConfig,
useScroll,
useSpring,
useTransform,
useVelocity,
wrap,
type MotionValue,
} from "motion/react"
import { useRef } from "react"
import { cn } from "@/lib/utils"
import { Reveal, wordmarkSize } from "../shared/motion"
import type { LaufbandData, WortmaskeData } from "./definitions"
import type { BlockProps } from "./types"
import { container, Eyebrow } from "./ui"
// ── Text-Laufband ────────────────────────────────────────────────────────────
// Eine Zeile: läuft gleichmäßig, schnelles Scrollen beschleunigt sie (auch rückwärts) und neigt die Schrift
function VelocityRow({ text, speed, boost, skew, outline, className }: {
text: string
speed: number
boost: MotionValue<number>
skew: MotionValue<number>
outline?: boolean
className?: string
}) {
const ref = useRef<HTMLDivElement>(null)
const base = useMotionValue(0)
const direction = useRef(1)
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
const x = useTransform(base, (value) => `${wrap(-50, 0, value)}%`)
useAnimationFrame((_, delta) => {
if (reduce || !visible) return
const factor = boost.get()
if (factor < 0) direction.current = -1
else if (factor > 0) direction.current = 1
let move = direction.current * speed * (delta / 1000)
move += direction.current * move * factor
base.set(base.get() + move)
})
return (
<div ref={ref} className={cn("overflow-hidden", className)}>
<motion.div
style={{ x, skewX: skew }}
className={cn(
"flex w-max font-t-display text-[clamp(3rem,9vw,8.5rem)] leading-[1.05] tracking-[-0.03em] whitespace-nowrap",
outline && "text-transparent [-webkit-text-stroke:1.5px_var(--t-fg)]"
)}
>
{[0, 1].map((copy) => (
<span key={copy} aria-hidden={copy === 1 || undefined} className="flex shrink-0 items-center">
{Array.from({ length: 4 }, (_, i) => (
<span key={i} className="flex items-center pr-[0.4em]">
{text}
<span aria-hidden className="ml-[0.4em] text-[0.5em] text-t-accent [-webkit-text-stroke:0]">✦</span>
</span>
))}
</span>
))}
</motion.div>
</div>
)
}
export function Laufband({ data }: BlockProps<LaufbandData>) {
const { scrollY } = useScroll()
const velocity = useVelocity(scrollY)
const smooth = useSpring(velocity, { damping: 50, stiffness: 400 })
const boost = useTransform(smooth, [-2000, 0, 2000], [-5, 0, 5], { clamp: false })
const skew = useTransform(smooth, [-2500, 0, 2500], [7, 0, -7])
if (!data.text.trim()) return null
return (
<section className="border-y border-t-fg/10 py-8 md:py-12">
<VelocityRow text={data.text} speed={-2} boost={boost} skew={skew} />
{data.secondary.trim() && <VelocityRow text={data.secondary} speed={2} boost={boost} skew={skew} outline className="mt-1 md:mt-3" />}
</section>
)
}
// ── Bild im Schriftzug ───────────────────────────────────────────────────────
const IMAGE_SOURCE = /^(https?:\/\/|\/[^/])/
// Riesiges Wort, durch dessen Buchstaben ein Bild scheint; beim Scrollen wandert der Bildausschnitt mit
export function Wortmaske({ data, brand }: BlockProps<WortmaskeData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const position = useTransform(scrollYProgress, [0, 1], ["50% 0%", "50% 100%"])
const word = data.word.trim() || brand.name
const image = IMAGE_SOURCE.test(data.image.trim())
? `url("${data.image.trim().replace(/["\\\n]/g, "")}")`
: "linear-gradient(135deg, var(--t-accent), var(--t-fg))"
return (
<section ref={ref} className="overflow-hidden py-20 md:py-32">
<div className={container}>
<Eyebrow>{data.eyebrow}</Eyebrow>
</div>
<motion.h2
style={{ backgroundImage: image, backgroundPosition: position, fontSize: wordmarkSize(word, 30) }}
className="mt-6 bg-cover bg-clip-text text-center font-t-display leading-[0.86] font-bold tracking-[-0.05em] whitespace-nowrap text-transparent [-webkit-background-clip:text]"
>
{word}
</motion.h2>
{data.text.trim() && (
<div className={cn(container, "mt-10 grid md:mt-14 md:grid-cols-12")}>
<Reveal className="md:col-span-5 md:col-start-7">
<p className="text-lg leading-relaxed text-t-muted">{data.text}</p>
</Reveal>
</div>
)}
</section>
)
}

View file

@ -0,0 +1,18 @@
import { defineTemplate, describeSections } from "../../lib/content"
import type { BlockSeed, TemplateDefinition } from "../../lib/types"
import { blockLibrary } from "./definitions"
import type { BrandData } from "./types"
type BuilderOptions = Omit<TemplateDefinition, "sections" | "defaults" | "builder"> & { brand: BrandData; seed: BlockSeed[] }
// Vorlage aus Bausteinen: „Marke & Kontakt“ ist die einzige feste Sektion, alles andere sind Bausteine,
// die im Editor frei hinzugefügt, sortiert und entfernt werden. `seed` ist die Startaufstellung.
export function defineBuilder({ brand, seed, ...template }: BuilderOptions) {
const defaults = { brand }
return defineTemplate({
...template,
sections: describeSections(defaults, { brand: "Marke & Kontakt" }),
defaults,
builder: { library: blockLibrary, seed },
})
}

View file

@ -0,0 +1,642 @@
import { defineBlock, unsplash } from "../../lib/content"
// Bausteine des Baukastens: Felder und Standardinhalte. Die Komponenten dazu stehen in registry.tsx.
// Alle Bausteine nutzen nur die Paletten-Farben (t-bg, t-fg, t-accent …) und passen so zu jeder Palette.
// Das Feld „menu“ macht einen Baustein zum Menüpunkt; sein Anker entsteht aus dem Text (z. B. #kontakt).
export const BLOCK_CATEGORIES = [
"Navigation",
"Start",
"Über uns",
"Leistungen",
"Projekte & Galerie",
"Ablauf",
"Vertrauen",
"Stimmen",
"Team",
"Angebot",
"FAQ",
"Kontakt",
"Akzente",
"Abschluss",
] as const
// ── Navigation ───────────────────────────────────────────────────────────────
export const navLeiste = defineBlock({
type: "nav-leiste",
label: "Leiste",
category: "Navigation",
description: "Klassische Navigation über die volle Breite: transparent über dem Startbild, beim Scrollen mit Milchglas, auf dem Handy als Vollbild-Menü.",
defaults: { cta: "Kontakt", href: "#kontakt" },
})
export const navGlas = defineBlock({
type: "nav-glas",
label: "Glas-Pille",
category: "Navigation",
description: "Schwebende Navigation als Glas-Pille mit gleitender Hervorhebung – modern und leicht.",
defaults: { cta: "Gespräch buchen", href: "#kontakt" },
})
export const navMasthead = defineBlock({
type: "nav-masthead",
label: "Magazin-Kopf",
category: "Navigation",
description: "Großer Schriftzug wie auf einem Magazin-Cover; beim Scrollen erscheint eine schmale Leiste.",
defaults: { edition: "Ausgabe Nº 12 · Herbst/Winter", cta: "Termin vereinbaren", href: "#kontakt" },
})
// ── Start ────────────────────────────────────────────────────────────────────
export const heroKino = defineBlock({
type: "hero-kino",
label: "Kino-Fenster",
category: "Start",
description: "Ein Bildfenster öffnet sich beim Scrollen zum Vollbild – filmisch und eindrucksvoll.",
defaults: {
eyebrow: "Seit 1998 · Hamburg",
title: "Ideen, die\n*bleiben.*",
text: "Wir verbinden Erfahrung mit Neugier – und schaffen Lösungen, die heute begeistern und morgen noch tragen.",
cta: "Mehr erfahren",
href: "#leistungen",
image: unsplash("1506905925346-21bda4d32df4", 2400),
},
})
export const heroVollbild = defineBlock({
type: "hero-vollbild",
label: "Vollbild",
category: "Start",
description: "Großes Bild über die ganze Höhe mit langsamem Zoom, Überschrift und zwei Buttons.",
defaults: {
eyebrow: "Willkommen",
title: "Wir bringen Ideen\n*ans Licht.*",
text: "Persönlich, verlässlich und mit Liebe zum Detail – seit über 25 Jahren an der Seite unserer Kundinnen und Kunden.",
cta: "Kontakt aufnehmen",
href: "#kontakt",
ctaSecondary: "Unsere Leistungen",
hrefSecondary: "#leistungen",
image: unsplash("1519681393784-d120267933ba", 2400),
},
})
export const heroAurora = defineBlock({
type: "hero-aurora",
label: "Aurora",
category: "Start",
description: "Leuchtender Farbverlauf, Glas-Effekte und Schlagworte, die eine Lichtkugel umkreisen.",
defaults: {
badge: "Neu: Erstberatung in 48 Stunden",
title: "Die Zukunft beginnt\nmit *einer Idee.*",
text: "Wir verbinden Strategie, Design und Technologie – für Unternehmen, die nicht abwarten, sondern gestalten.",
cta: "Gespräch vereinbaren",
href: "#kontakt",
ctaSecondary: "Leistungen",
hrefSecondary: "#leistungen",
keywords: "Strategie, Design, Technologie, Wachstum, Service",
},
})
export const heroEditorial = defineBlock({
type: "hero-editorial",
label: "Editorial",
category: "Start",
description: "Wie ein Magazin: große Serifenschrift, das Bild öffnet sich wie Jalousien.",
defaults: {
eyebrow: "Ausgabe Nº 01",
title: "Schönheit\nliegt im *Detail.*",
text: "Wir glauben an Dinge, die mit Sorgfalt gemacht sind – und an Räume, die Geschichten erzählen.",
cta: "Entdecken",
href: "#projekte",
image: unsplash("1600607687939-ce8a6c25118c", 1600),
caption: "Wohnraum, Hamburg-Eppendorf",
},
})
export const heroKinetik = defineBlock({
type: "hero-kinetik",
label: "Kinetik",
category: "Start",
description: "Riesiger Schriftzug, dessen Buchstaben auf die Maus reagieren – auf dem Handy wogt er von selbst.",
defaults: {
eyebrow: "Studio für Gestaltung & Strategie",
word: "",
text: "Wir gestalten Marken, Räume und Erlebnisse, die Menschen bewegen – präzise im Detail, mutig im Ganzen.",
cta: "Projekte ansehen",
href: "#projekte",
image: unsplash("1479839672679-a46483c0e7c8", 1600),
},
})
// ── Über uns ─────────────────────────────────────────────────────────────────
export const statement = defineBlock({
type: "statement",
label: "Leitsatz",
category: "Über uns",
description: "Großer Text, dessen Wörter beim Scrollen nacheinander aufleuchten.",
defaults: {
menu: "",
eyebrow: "Über uns",
text: "Gute Arbeit beginnt mit Zuhören. Wir nehmen uns Zeit, verstehen, was Sie bewegt – und entwickeln daraus Lösungen, die nicht nur funktionieren, sondern begeistern.",
author: "Anna Lindberg",
role: "Gründerin",
},
})
export const storyKapitel = defineBlock({
type: "story-kapitel",
label: "Kapitel",
category: "Über uns",
description: "Erzählt eine Geschichte in Kapiteln; das Bild wechselt beim Scrollen mit.",
defaults: {
menu: "Geschichte",
eyebrow: "Unsere Geschichte",
title: "Vom ersten Tag\nbis *heute.*",
items: [
{ year: "1998", title: "Der Anfang", text: "Mit zwei Schreibtischen und einer großen Idee gründen wir unser Studio in einem Hinterhof in Hamburg.", image: unsplash("1497366811353-6870744d04b2", 1400) },
{ year: "2010", title: "Das Wachstum", text: "Aus dem kleinen Team werden 40 Menschen, aus Aufträgen werden langjährige Partnerschaften.", image: unsplash("1521737604893-d14cc237f11d", 1400) },
{ year: "2026", title: "Die Zukunft", text: "Heute verbinden wir Erfahrung mit neuen Ideen – und bleiben dabei, was uns ausmacht: nah, ehrlich, leidenschaftlich.", image: unsplash("1556761175-5973dc0f32e7", 1400) },
],
},
items: { items: "Kapitel" },
})
export const textBild = defineBlock({
type: "text-bild",
label: "Text & Bild",
category: "Über uns",
description: "Editorial mit Initiale, Zitat und Unterschrift; das Bild färbt sich beim Darüberfahren von der Maus aus ein.",
defaults: {
menu: "Über uns",
eyebrow: "Über uns",
title: "Mit Herz\nund *Haltung.*",
text: "Seit über 25 Jahren begleiten wir Menschen und Unternehmen auf ihrem Weg. Wir hören zu, denken mit und packen an – mit einem Team, das seine Arbeit liebt und für jedes Projekt brennt.",
quote: "Wir messen uns nicht an Aufträgen, sondern an Beziehungen, die bleiben.",
signature: "Anna Lindberg",
image: unsplash("1522071820081-009f0129c71c", 1400),
caption: "Unser Team im Hamburger Studio",
},
})
// ── Leistungen ───────────────────────────────────────────────────────────────
export const leistungenPanels = defineBlock({
type: "leistungen-panels",
label: "Panels",
category: "Leistungen",
description: "Bildpaneele, die sich beim Darüberfahren aufziehen – ideal für drei bis fünf Leistungen.",
defaults: {
menu: "Leistungen",
eyebrow: "Leistungen",
title: "Was wir *für Sie* tun.",
items: [
{ title: "Beratung", text: "Wir analysieren, wo Sie stehen, und zeigen, wohin es gehen kann – klar, ehrlich und umsetzbar.", image: unsplash("1552664730-d307ca884978", 1400) },
{ title: "Gestaltung", text: "Von der Idee zum Entwurf: Wir geben Ihren Vorhaben eine Form, die begeistert.", image: unsplash("1497366811353-6870744d04b2", 1400) },
{ title: "Umsetzung", text: "Wir setzen um – termingerecht, im Budget und mit einem Auge für jedes Detail.", image: unsplash("1531297484001-80022131f5a1", 1400) },
{ title: "Betreuung", text: "Auch nach dem Projekt sind wir für Sie da – persönlich und auf kurzem Weg.", image: unsplash("1521737604893-d14cc237f11d", 1400) },
],
},
items: { items: "Leistung" },
})
export const leistungenBento = defineBlock({
type: "leistungen-bento",
label: "Bento mit Lichtkegel",
category: "Leistungen",
description: "Kacheln im Bento-Raster; ein Lichtkegel folgt der Maus über alle Kacheln. Die erste Kachel ist groß, ein Bild ist optional.",
defaults: {
menu: "Leistungen",
eyebrow: "Leistungen",
title: "Alles aus\n*einer Hand.*",
text: "Von der ersten Idee bis zum fertigen Ergebnis – wir begleiten Sie auf dem ganzen Weg.",
items: [
{ title: "Strategie", text: "Wir finden heraus, was Sie wirklich weiterbringt, und machen daraus einen klaren Plan.", image: unsplash("1552664730-d307ca884978", 1200) },
{ title: "Design", text: "Gestaltung, die Ihre Marke unverwechselbar macht.", image: "" },
{ title: "Technologie", text: "Moderne Lösungen, die zuverlässig laufen.", image: "" },
{ title: "Service", text: "Persönliche Betreuung – schnell, freundlich und kompetent.", image: "" },
{ title: "Wachstum", text: "Maßnahmen, die messbar mehr Kundschaft bringen.", image: "" },
{ title: "Qualität", text: "Sorgfalt bis ins Detail – geprüft und dokumentiert.", image: "" },
],
},
items: { items: "Leistung" },
})
export const leistungenKarten = defineBlock({
type: "leistungen-karten",
label: "3D-Karten",
category: "Leistungen",
description: "Karten, die sich zur Maus neigen und einen Glanz werfen – mit Aufzählung je Leistung.",
defaults: {
menu: "Leistungen",
eyebrow: "Leistungen",
title: "Was wir *können.*",
items: [
{ title: "Beratung", text: "Klarheit über Ziele, Wege und Möglichkeiten.", features: "Analyse & Workshop\nStrategie & Fahrplan\nFördermöglichkeiten" },
{ title: "Planung", text: "Durchdachte Konzepte bis ins Detail.", features: "Konzept & Entwurf\nKosten & Zeitplan\nAbstimmung mit Partnern" },
{ title: "Umsetzung", text: "Wir machen aus Plänen Wirklichkeit.", features: "Projektsteuerung\nQualitätssicherung\nTransparente Berichte" },
{ title: "Betreuung", text: "Langfristig an Ihrer Seite.", features: "Feste Ansprechperson\nWartung & Pflege\nSchnelle Hilfe" },
],
},
items: { items: "Leistung" },
})
// ── Projekte & Galerie ───────────────────────────────────────────────────────
export const projekteQuerlauf = defineBlock({
type: "projekte-querlauf",
label: "Querlauf",
category: "Projekte & Galerie",
description: "Beim Scrollen gleiten große Bilder seitlich vorbei – mit Fortschrittsleiste.",
defaults: {
menu: "Projekte",
eyebrow: "Projekte",
title: "Ausgewählte\n*Arbeiten*",
items: [
{ title: "Haus am Wasser", category: "Wohnen", year: "2026", image: unsplash("1613490493576-7fde63acd811", 1600) },
{ title: "Pavillon", category: "Kultur", year: "2025", image: unsplash("1518005020951-eccb494ad742", 1600) },
{ title: "Studio Ost", category: "Arbeiten", year: "2024", image: unsplash("1497366811353-6870744d04b2", 1600) },
{ title: "Villa Sol", category: "Wohnen", year: "2023", image: unsplash("1512917774080-9991f1c4c750", 1600) },
{ title: "Haus Weiß", category: "Wohnen", year: "2022", image: unsplash("1487958449943-2429e8be8625", 1600) },
],
},
items: { items: "Projekt" },
})
export const projekteIndex = defineBlock({
type: "projekte-index",
label: "Index mit Bildspur",
category: "Projekte & Galerie",
description: "Projektliste im Swiss-Stil; die Maus hinterlässt eine Spur aus Projektbildern.",
defaults: {
menu: "Projekte",
eyebrow: "Index",
title: "Ausgewählte *Projekte*",
items: [
{ title: "Haus Weiß", location: "Hamburg", category: "Wohnen", year: "2026", image: unsplash("1487958449943-2429e8be8625", 800) },
{ title: "Pavillon Blau", location: "Berlin", category: "Kultur", year: "2025", image: unsplash("1518005020951-eccb494ad742", 800) },
{ title: "Kubus", location: "Köln", category: "Arbeiten", year: "2024", image: unsplash("1479839672679-a46483c0e7c8", 800) },
{ title: "Villa Sol", location: "Mallorca", category: "Wohnen", year: "2023", image: unsplash("1512917774080-9991f1c4c750", 800) },
{ title: "Atelier Licht", location: "München", category: "Innenraum", year: "2022", image: unsplash("1517705008128-361805f42e86", 800) },
],
},
items: { items: "Projekt" },
})
export const galerieKarussell = defineBlock({
type: "galerie-karussell",
label: "3D-Karussell",
category: "Projekte & Galerie",
description: "Bilder auf einem drehenden Zylinder – dreht sich beim Scrollen und lässt sich mit der Maus oder dem Finger ziehen.",
defaults: {
menu: "Galerie",
eyebrow: "Galerie",
title: "Einblicke in\n*Bewegung.*",
text: "Ziehen oder scrollen Sie, um die Galerie zu drehen.",
images: [
{ src: unsplash("1487958449943-2429e8be8625", 900), caption: "Haus Weiß" },
{ src: unsplash("1518005020951-eccb494ad742", 900), caption: "Pavillon" },
{ src: unsplash("1479839672679-a46483c0e7c8", 900), caption: "Kubus" },
{ src: unsplash("1613490493576-7fde63acd811", 900), caption: "Haus am Wasser" },
{ src: unsplash("1517705008128-361805f42e86", 900), caption: "Atelier" },
{ src: unsplash("1512917774080-9991f1c4c750", 900), caption: "Villa Sol" },
{ src: unsplash("1592078615290-033ee584e267", 900), caption: "Objekt" },
{ src: unsplash("1431576901776-e539bd916ba2", 900), caption: "Fassade" },
],
},
items: { images: "Bild" },
})
// ── Ablauf ───────────────────────────────────────────────────────────────────
export const ablaufLinie = defineBlock({
type: "ablauf-linie",
label: "Zeitstrahl",
category: "Ablauf",
description: "Eine Linie zeichnet sich beim Scrollen und lässt die Schritte nacheinander aufleuchten.",
defaults: {
menu: "Ablauf",
eyebrow: "Ablauf",
title: "So einfach geht's\n*mit uns.*",
items: [
{ title: "Kennenlernen", text: "In einem ersten Gespräch erfahren wir, was Sie vorhaben – kostenlos und unverbindlich.", duration: "Tag 1" },
{ title: "Konzept", text: "Wir entwickeln einen klaren Plan mit Zeitrahmen und transparentem Angebot.", duration: "1–2 Wochen" },
{ title: "Umsetzung", text: "Wir setzen um und halten Sie in jeder Phase auf dem Laufenden.", duration: "nach Plan" },
{ title: "Übergabe", text: "Sie erhalten das Ergebnis – und uns als Ansprechpartner auch danach.", duration: "Ziel erreicht" },
],
},
items: { items: "Schritt" },
})
export const ablaufSpalten = defineBlock({
type: "ablauf-spalten",
label: "Spalten",
category: "Ablauf",
description: "Schritte nebeneinander mit römischen Ziffern und Linien, die sich zeichnen.",
defaults: {
menu: "Ablauf",
eyebrow: "Ablauf",
title: "In vier *Schritten.*",
items: [
{ title: "Zuhören", text: "Wir lernen Sie und Ihr Vorhaben kennen – in Ruhe und ohne Verpflichtung." },
{ title: "Entwerfen", text: "Wir entwickeln Ideen und einen klaren Plan, den Sie mitgestalten." },
{ title: "Umsetzen", text: "Wir setzen sorgfältig und termingerecht um – mit festen Ansprechpartnern." },
{ title: "Begleiten", text: "Wir bleiben an Ihrer Seite – auch lange nach dem Abschluss." },
],
},
items: { items: "Schritt" },
})
// ── Vertrauen ────────────────────────────────────────────────────────────────
export const zahlen = defineBlock({
type: "zahlen",
label: "Kennzahlen",
category: "Vertrauen",
description: "Zahlen zählen beim Erscheinen hoch, feine Linien zeichnen sich. Überschrift optional.",
defaults: {
eyebrow: "",
title: "",
items: [
{ value: "25", suffix: "+", label: "Jahre Erfahrung" },
{ value: "1.200", suffix: "", label: "zufriedene Kunden" },
{ value: "40", suffix: "", label: "Menschen im Team" },
{ value: "4,9", suffix: "/5", label: "Bewertung im Schnitt" },
],
},
items: { items: "Kennzahl" },
})
export const logos = defineBlock({
type: "logos",
label: "Logo-Laufband",
category: "Vertrauen",
description: "Kunden oder Partner als endloses Laufband – mit Logo-Bild oder als Schriftzug.",
defaults: {
title: "Partner und Kunden, die uns vertrauen",
items: [
{ name: "Nordwerk", logo: "" },
{ name: "Velora", logo: "" },
{ name: "Kraftbau", logo: "" },
{ name: "Brightfield", logo: "" },
{ name: "Okuma", logo: "" },
{ name: "Lumio", logo: "" },
],
},
items: { items: "Kunde" },
})
// ── Stimmen ──────────────────────────────────────────────────────────────────
export const stimmenZitat = defineBlock({
type: "stimmen-zitat",
label: "Großes Zitat",
category: "Stimmen",
description: "Ein Zitat groß in Szene gesetzt; wechselt automatisch, mit Fortschrittsanzeige.",
defaults: {
menu: "",
eyebrow: "Stimmen",
items: [
{ quote: "Vom ersten Gespräch bis zum Ergebnis haben wir uns bestens aufgehoben gefühlt. Ehrlich, schnell und mit großem Gespür für das, was uns wichtig ist.", author: "Julia Hartmann", role: "Geschäftsführerin, Hartmann & Co." },
{ quote: "Selten so ein engagiertes Team erlebt. Jede Frage wurde sofort beantwortet, jeder Termin eingehalten.", author: "Markus Weber", role: "Inhaber, Weber Logistik" },
{ quote: "Das Ergebnis hat unsere Erwartungen übertroffen – und unsere Kunden sehen es genauso.", author: "Dr. Sophie Klein", role: "Praxis Dr. Klein" },
],
},
items: { items: "Stimme" },
})
export const stimmenWand = defineBlock({
type: "stimmen-wand",
label: "Stimmen-Wand",
category: "Stimmen",
description: "Spalten mit Kundenstimmen, die endlos gegenläufig laufen und beim Darüberfahren sanft anhalten.",
defaults: {
menu: "Stimmen",
eyebrow: "Stimmen",
title: "Was unsere Kunden *sagen.*",
items: [
{ quote: "Unkompliziert, schnell und richtig gut. Wir haben uns vom ersten Tag an verstanden gefühlt.", author: "Lena Brandt", role: "Inhaberin, Studio Brandt", image: unsplash("1494790108377-be9c29b29330", 300) },
{ quote: "Endlich ein Partner, der mitdenkt, statt nur abzuarbeiten. Klare Empfehlung!", author: "Tobias Lehmann", role: "Geschäftsführer, Lehmann Bau", image: unsplash("1560250097-0b93528c311a", 300) },
{ quote: "Die Zusammenarbeit war professionell und herzlich zugleich. Das Ergebnis spricht für sich.", author: "Mira Schulz", role: "Marketing, Velora", image: unsplash("1534528741775-53994a69daeb", 300) },
{ quote: "Wir haben unsere Ziele in der Hälfte der Zeit erreicht. Das hätten wir nie gedacht.", author: "Jonas Becker", role: "Gründer, Kraftbau", image: unsplash("1472099645785-5658abf4ff4e", 300) },
{ quote: "Jede Frage wurde geduldig beantwortet. Man merkt, dass hier Menschen mit Leidenschaft arbeiten.", author: "Amara Okafor", role: "Praxis am Park", image: unsplash("1531123897727-8f129e1688ce", 300) },
{ quote: "Termingerecht, im Budget und mit Liebe zum Detail – so wünscht man sich das.", author: "Felix Wagner", role: "Wagner & Söhne", image: unsplash("1539571696357-5a69c17a67c6", 300) },
],
},
items: { items: "Stimme" },
})
// ── Team ─────────────────────────────────────────────────────────────────────
export const team = defineBlock({
type: "team",
label: "Team",
category: "Team",
description: "Porträts in Schwarzweiß, die sich beim Darüberfahren von der Maus aus einfärben.",
defaults: {
menu: "Team",
eyebrow: "Team",
title: "Die Menschen\n*dahinter.*",
text: "Ein Team aus Expertinnen und Experten, das für jedes Projekt brennt.",
members: [
{ name: "Anna Lindberg", role: "Gründerin & Geschäftsführung", image: unsplash("1438761681033-6461ffad8d80", 700) },
{ name: "David Moreno", role: "Projektleitung", image: unsplash("1507003211169-0a1dd7228f2d", 700) },
{ name: "Sophie Wagner", role: "Gestaltung", image: unsplash("1508214751196-bcfd4ca60f91", 700) },
{ name: "Lukas Brenner", role: "Technik", image: unsplash("1506794778202-cad84cf45f1d", 700) },
],
},
items: { members: "Person" },
})
// ── Angebot ──────────────────────────────────────────────────────────────────
export const pakete = defineBlock({
type: "pakete",
label: "Pakete",
category: "Angebot",
description: "Pakete im Vergleich; das hervorgehobene bekommt einen umlaufenden Lichtrand.",
defaults: {
menu: "Pakete",
eyebrow: "Pakete",
title: "Klar, fair und\n*transparent.*",
text: "Wählen Sie das Paket, das zu Ihnen passt. Alle Preise zzgl. MwSt.",
href: "#kontakt",
plans: [
{ name: "Basis", price: "990 €", period: "einmalig", text: "Für den Einstieg", features: "Erstgespräch & Analyse\nKonzept mit Empfehlungen\nSupport per E-Mail", ribbon: "", cta: "Anfragen" },
{ name: "Plus", price: "2.490 €", period: "einmalig", text: "Unser Klassiker", features: "Alles aus Basis\nUmsetzung durch unser Team\nPersönliche Ansprechperson\n3 Monate Betreuung", ribbon: "Beliebt", cta: "Anfragen" },
{ name: "Premium", price: "Individuell", period: "", text: "Für große Vorhaben", features: "Alles aus Plus\nPriorisierte Umsetzung\nRegelmäßige Strategie-Termine\n12 Monate Betreuung", ribbon: "", cta: "Gespräch vereinbaren" },
],
},
items: { plans: "Paket" },
})
// ── FAQ ──────────────────────────────────────────────────────────────────────
export const faq = defineBlock({
type: "faq",
label: "FAQ",
category: "FAQ",
description: "Häufige Fragen zum Aufklappen, daneben Überschrift und Kontakt-Hinweis.",
defaults: {
menu: "FAQ",
eyebrow: "FAQ",
title: "Häufige\n*Fragen*",
text: "Ihre Frage ist nicht dabei? Wir antworten gern persönlich.",
items: [
{ question: "Wie läuft die Zusammenarbeit ab?", answer: "Nach einem kostenlosen Erstgespräch erhalten Sie ein transparentes Angebot. Danach begleitet Sie eine feste Ansprechperson durch alle Schritte." },
{ question: "Was kostet ein Projekt?", answer: "Das hängt vom Umfang ab. Nach dem Erstgespräch nennen wir Ihnen einen verbindlichen Festpreis – ohne versteckte Kosten." },
{ question: "Wie schnell können Sie starten?", answer: "In der Regel innerhalb von zwei Wochen. Bei dringenden Anliegen finden wir fast immer eine Lösung." },
{ question: "Arbeiten Sie auch überregional?", answer: "Ja. Wir arbeiten deutschlandweit – vor Ort oder digital, ganz wie es für Sie am besten passt." },
],
},
items: { items: "Frage" },
})
// ── Kontakt ──────────────────────────────────────────────────────────────────
export const ctaPortal = defineBlock({
type: "cta-portal",
label: "Leuchtender Aufruf",
category: "Kontakt",
description: "Große Fläche mit pulsierendem Leuchten und magnetischem Button – der letzte Anstoß.",
defaults: {
menu: "",
eyebrow: "Bereit?",
title: "Lassen Sie uns\netwas *Großes* starten.",
text: "Ein kurzes Gespräch genügt – kostenlos und unverbindlich.",
cta: "Jetzt Kontakt aufnehmen",
href: "#kontakt",
},
})
export const kontaktFormular = defineBlock({
type: "kontakt-formular",
label: "Kontakt mit Formular",
category: "Kontakt",
description: "Überschrift, Kontaktdaten aus „Marke & Kontakt“ und ein elegantes Formular mit Themen-Auswahl.",
defaults: {
menu: "Kontakt",
eyebrow: "Kontakt",
title: "Lassen Sie\nuns *sprechen.*",
text: "Erzählen Sie uns von Ihrem Vorhaben – wir melden uns innerhalb von 24 Stunden.",
topics: "Anfrage, Beratung, Zusammenarbeit, Sonstiges",
button: "Nachricht senden",
success: "Vielen Dank! Wir melden uns in Kürze bei Ihnen.",
hours: "Mo–Fr, 9–18 Uhr",
},
})
// ── Akzente ──────────────────────────────────────────────────────────────────
export const laufband = defineBlock({
type: "laufband",
label: "Text-Laufband",
category: "Akzente",
description: "Großer Text als Laufband, das beim schnellen Scrollen Tempo aufnimmt und sich neigt.",
defaults: {
text: "Ideen · Gestaltung · Umsetzung · Begeisterung ·",
secondary: "Mit Herz und Verstand ·",
},
})
export const wortmaske = defineBlock({
type: "wortmaske",
label: "Bild im Schriftzug",
category: "Akzente",
description: "Ein Bild scheint durch riesige Buchstaben und wandert beim Scrollen mit.",
defaults: {
menu: "",
eyebrow: "Was uns antreibt",
word: "Neugier",
text: "Neugier ist der Anfang von allem. Sie lässt uns Fragen stellen, Gewohntes hinterfragen und Lösungen finden, an die vorher niemand gedacht hat.",
image: unsplash("1501785888041-af3ef285b470", 2000),
},
})
// ── Abschluss ────────────────────────────────────────────────────────────────
export const footerWortmarke = defineBlock({
type: "footer-wortmarke",
label: "Riesen-Schriftzug",
category: "Abschluss",
description: "Abschluss mit Navigation, Kontaktdaten und dem Namen als Schriftzug über die volle Breite.",
defaults: { text: "Ideen, die bleiben.", cta: "Kontakt aufnehmen", href: "#kontakt" },
})
export const footerSchlicht = defineBlock({
type: "footer-schlicht",
label: "Schlicht",
category: "Abschluss",
description: "Klarer Abschluss in Spalten mit Navigation, Kontakt und Ortszeit.",
defaults: { city: "Hamburg", note: "Mit Sorgfalt gemacht." },
})
// Reihenfolge = Reihenfolge im Bausteine-Dialog
export const blockLibrary = [
navLeiste,
navGlas,
navMasthead,
heroKino,
heroVollbild,
heroAurora,
heroEditorial,
heroKinetik,
statement,
storyKapitel,
textBild,
leistungenPanels,
leistungenBento,
leistungenKarten,
projekteQuerlauf,
projekteIndex,
galerieKarussell,
ablaufLinie,
ablaufSpalten,
zahlen,
logos,
stimmenZitat,
stimmenWand,
team,
pakete,
faq,
ctaPortal,
kontaktFormular,
laufband,
wortmaske,
footerWortmarke,
footerSchlicht,
]
export type NavLeisteData = typeof navLeiste.defaults
export type NavGlasData = typeof navGlas.defaults
export type NavMastheadData = typeof navMasthead.defaults
export type HeroKinoData = typeof heroKino.defaults
export type HeroVollbildData = typeof heroVollbild.defaults
export type HeroAuroraData = typeof heroAurora.defaults
export type HeroEditorialData = typeof heroEditorial.defaults
export type HeroKinetikData = typeof heroKinetik.defaults
export type StatementData = typeof statement.defaults
export type StoryKapitelData = typeof storyKapitel.defaults
export type TextBildData = typeof textBild.defaults
export type LeistungenPanelsData = typeof leistungenPanels.defaults
export type LeistungenBentoData = typeof leistungenBento.defaults
export type LeistungenKartenData = typeof leistungenKarten.defaults
export type ProjekteQuerlaufData = typeof projekteQuerlauf.defaults
export type ProjekteIndexData = typeof projekteIndex.defaults
export type GalerieKarussellData = typeof galerieKarussell.defaults
export type AblaufLinieData = typeof ablaufLinie.defaults
export type AblaufSpaltenData = typeof ablaufSpalten.defaults
export type ZahlenData = typeof zahlen.defaults
export type LogosData = typeof logos.defaults
export type StimmenZitatData = typeof stimmenZitat.defaults
export type StimmenWandData = typeof stimmenWand.defaults
export type TeamData = typeof team.defaults
export type PaketeData = typeof pakete.defaults
export type FaqData = typeof faq.defaults
export type CtaPortalData = typeof ctaPortal.defaults
export type KontaktFormularData = typeof kontaktFormular.defaults
export type LaufbandData = typeof laufband.defaults
export type WortmaskeData = typeof wortmaske.defaults
export type FooterWortmarkeData = typeof footerWortmarke.defaults
export type FooterSchlichtData = typeof footerSchlicht.defaults

View file

@ -0,0 +1,44 @@
import {
Anybody,
Cormorant,
EB_Garamond,
Funnel_Display,
Funnel_Sans,
Host_Grotesk,
Hubot_Sans,
Manrope,
Mona_Sans,
Noto_Serif_Display,
Parkinsans,
Rethink_Sans,
} from "next/font/google"
import type { FontPair } from "../../lib/types"
// Schriften der Baukasten-Vorlagen; jede Vorlage wählt in ihrer fonts.ts daraus.
// preload: false – siehe restaurant/fonts.ts. Mona Sans, Hubot Sans und Anybody laden die Breiten-Achse (wdth)
// mit, die der Baustein „Kinetik“ für seine reagierende Schrift nutzt.
const notoDisplay = Noto_Serif_Display({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-blocks-noto", preload: false })
const host = Host_Grotesk({ subsets: ["latin"], variable: "--font-blocks-host", preload: false })
const hubot = Hubot_Sans({ subsets: ["latin"], axes: ["wdth"], variable: "--font-blocks-hubot", preload: false })
const funnelDisplay = Funnel_Display({ subsets: ["latin"], variable: "--font-blocks-funnel-display", preload: false })
const funnelSans = Funnel_Sans({ subsets: ["latin"], variable: "--font-blocks-funnel-sans", preload: false })
// Für Parkinsans kennt next/font keine Fallback-Metriken
const parkinsans = Parkinsans({ subsets: ["latin"], variable: "--font-blocks-parkinsans", preload: false, adjustFontFallback: false })
const rethink = Rethink_Sans({ subsets: ["latin"], variable: "--font-blocks-rethink", preload: false })
const cormorant = Cormorant({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-blocks-cormorant", preload: false })
const manrope = Manrope({ subsets: ["latin"], variable: "--font-blocks-manrope", preload: false })
const garamond = EB_Garamond({ subsets: ["latin"], style: ["normal", "italic"], variable: "--font-blocks-garamond", preload: false })
const mona = Mona_Sans({ subsets: ["latin"], axes: ["wdth"], variable: "--font-blocks-mona", preload: false })
const anybody = Anybody({ subsets: ["latin"], axes: ["wdth"], variable: "--font-blocks-anybody", preload: false })
export const pairs = {
kino: { className: `${notoDisplay.variable} ${host.variable}`, display: "var(--font-blocks-noto)", body: "var(--font-blocks-host)" },
hubot: { className: `${hubot.variable} ${host.variable}`, display: "var(--font-blocks-hubot)", body: "var(--font-blocks-host)" },
grotesk: { className: host.variable, display: "var(--font-blocks-host)", body: "var(--font-blocks-host)" },
funnel: { className: `${funnelDisplay.variable} ${funnelSans.variable}`, display: "var(--font-blocks-funnel-display)", body: "var(--font-blocks-funnel-sans)" },
park: { className: `${parkinsans.variable} ${rethink.variable}`, display: "var(--font-blocks-parkinsans)", body: "var(--font-blocks-rethink)" },
cormorant: { className: `${cormorant.variable} ${manrope.variable}`, display: "var(--font-blocks-cormorant)", body: "var(--font-blocks-manrope)" },
garamond: { className: `${garamond.variable} ${host.variable}`, display: "var(--font-blocks-garamond)", body: "var(--font-blocks-host)" },
mona: { className: mona.variable, display: "var(--font-blocks-mona)", body: "var(--font-blocks-mona)" },
anybody: { className: `${anybody.variable} ${host.variable}`, display: "var(--font-blocks-anybody)", body: "var(--font-blocks-host)" },
} satisfies Record<string, FontPair>

View file

@ -0,0 +1,128 @@
"use client"
import { motion, useScroll, useTransform } from "motion/react"
import { useRef } from "react"
import { cn } from "@/lib/utils"
import { useNow } from "../shared/hooks"
import { wordmarkSize } from "../shared/motion"
import type { FooterSchlichtData, FooterWortmarkeData } from "./definitions"
import type { BlockProps, BrandData } from "./types"
import { Action, Brand, container, telHref } from "./ui"
const label = "text-[11px] tracking-[0.22em] text-t-muted uppercase"
const clock = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
function ContactList({ brand }: { brand: BrandData }) {
return (
<ul className="mt-4 grid gap-2 text-t-fg/80">
{brand.email.trim() && (
<li>
<a href={`mailto:${brand.email}`} className="transition-colors hover:text-t-accent">{brand.email}</a>
</li>
)}
{brand.phone.trim() && (
<li>
<a href={telHref(brand.phone)} className="transition-colors hover:text-t-accent">{brand.phone}</a>
</li>
)}
{brand.address.trim() && <li className="text-t-muted">{brand.address}</li>}
</ul>
)
}
function Legal({ brand, note, className }: { brand: BrandData; note?: string; className?: string }) {
return (
<div className={cn(container, "flex flex-col gap-3 border-t border-t-fg/10 py-6 text-xs text-t-muted md:flex-row md:items-center md:justify-between", className)}>
<span>
© {brand.name}
{note?.trim() && ` · ${note}`}
</span>
<div className="flex gap-6">
<a href="#" className="transition-colors hover:text-t-fg">Impressum</a>
<a href="#" className="transition-colors hover:text-t-fg">Datenschutz</a>
<a href="#top" className="transition-colors hover:text-t-fg">Nach oben ↑</a>
</div>
</div>
)
}
// Abschluss mit Navigation, Kontakt und dem Namen über die volle Breite, der beim Erreichen des Seitenendes aufsteigt
export function FooterWortmarke({ data, brand, links }: BlockProps<FooterWortmarkeData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end end"] })
const y = useTransform(scrollYProgress, [0, 1], ["55%", "0%"])
return (
<footer ref={ref} className="relative overflow-hidden border-t border-t-fg/10 pt-20 md:pt-28">
<div className={cn(container, "grid gap-12 md:grid-cols-12 md:gap-6")}>
<div className="md:col-span-5">
{data.text.trim() && <p className="font-t-display text-[clamp(1.75rem,3vw,2.75rem)] leading-[1.1] tracking-[-0.02em]">{data.text}</p>}
<Action href={data.href} className="mt-8">
{data.cta}
</Action>
</div>
{links.length > 0 && (
<nav className="md:col-span-3 md:col-start-7">
<p className={label}>Navigation</p>
<ul className="mt-4 grid gap-2">
{links.map((link) => (
<li key={link.href}>
<a href={link.href} className="transition-colors hover:text-t-accent">{link.label}</a>
</li>
))}
</ul>
</nav>
)}
<div className={cn("md:col-span-3", links.length === 0 && "md:col-start-7")}>
<p className={label}>Kontakt</p>
<ContactList brand={brand} />
</div>
</div>
<div aria-hidden className="mt-16 overflow-hidden md:mt-20">
<motion.p
style={{ y, fontSize: wordmarkSize(brand.name, 26) }}
className="text-center font-t-display leading-[0.82] tracking-[-0.05em] whitespace-nowrap select-none"
>
{brand.name}
</motion.p>
</div>
<Legal brand={brand} />
</footer>
)
}
export function FooterSchlicht({ data, brand, links }: BlockProps<FooterSchlichtData>) {
const now = useNow()
return (
<footer className="border-t border-t-fg/10">
<div className={cn(container, "grid gap-10 py-14 md:grid-cols-12 md:gap-6 md:py-20")}>
<div className="md:col-span-4">
<Brand brand={brand} className="text-2xl md:text-3xl" />
{brand.tagline.trim() && <p className="mt-3 max-w-xs text-sm text-t-muted">{brand.tagline}</p>}
</div>
{links.length > 0 && (
<nav className="md:col-span-2 md:col-start-6">
<p className={label}>Navigation</p>
<ul className="mt-4 grid gap-2 text-t-fg/80">
{links.map((link) => (
<li key={link.href}>
<a href={link.href} className="transition-colors hover:text-t-accent">{link.label}</a>
</li>
))}
</ul>
</nav>
)}
<div className={cn("md:col-span-3", links.length === 0 && "md:col-start-6")}>
<p className={label}>Kontakt</p>
<ContactList brand={brand} />
</div>
<div className="md:col-span-2 md:col-start-11 md:text-right">
<p className={label}>Ortszeit {data.city}</p>
<p className="mt-3 font-t-display text-3xl tabular-nums">{now === null ? "––:––" : clock.format(now)}</p>
</div>
</div>
<Legal brand={brand} note={data.note} />
</footer>
)
}

View file

@ -0,0 +1,236 @@
"use client"
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "motion/react"
import { useEffect, useId, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { ease, useScrollNav, wordmarkSize } from "../shared/motion"
import type { NavGlasData, NavLeisteData, NavMastheadData } from "./definitions"
import type { BlockProps } from "./types"
import { actionClass, Brand, container, linkProps, MobileMenu, useDarkSurface } from "./ui"
function MenuButton({ open, onClick, className }: { open: boolean; onClick: () => void; className?: string }) {
return (
<button type="button" onClick={onClick} aria-expanded={open} aria-label="Menü öffnen" className={cn("flex cursor-pointer items-center gap-3 text-sm", className)}>
Menü
<span aria-hidden className="flex w-6 flex-col items-end gap-1.5">
<span className="h-px w-6 bg-current" />
<span className="h-px w-4 bg-current" />
</span>
</button>
)
}
// Klassische Leiste: transparent oben, Milchglas nach dem ersten Scrollen, versteckt sich beim Runterscrollen.
// Über dunklen Bildflächen (data-surface="dark") wird die Schrift hell.
export function NavLeiste({ data, brand, links }: BlockProps<NavLeisteData>) {
const { scrolled, hidden } = useScrollNav()
const onImage = useDarkSurface()
const [open, setOpen] = useState(false)
const light = onImage && !scrolled
// Menüpunkt mit demselben Ziel wie der Button nicht doppelt zeigen (z. B. „Kontakt“)
const inline = links.filter((link) => link.href !== data.href.trim())
return (
<>
<motion.header
data-nav
animate={{ y: hidden ? "-110%" : "0%" }}
transition={{ duration: 0.45, ease }}
className="fixed inset-x-0 top-0 z-50"
>
<div
className={cn(
"border-b transition-[background-color,border-color] duration-500",
scrolled ? "border-t-fg/10 bg-t-bg/75 backdrop-blur-xl" : "border-transparent"
)}
>
<nav className={cn(container, "flex h-16 items-center justify-between gap-6 transition-colors duration-500 md:h-20", light ? "text-white" : "text-t-fg")}>
<Brand brand={brand} />
<div className="hidden items-center gap-8 md:flex">
{inline.map((link) => (
<a key={link.href} href={link.href} className="group relative text-sm opacity-75 transition-opacity hover:opacity-100">
{link.label}
<span aria-hidden className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-current transition-transform duration-300 group-hover:origin-left group-hover:scale-x-100" />
</a>
))}
{data.cta.trim() && (
<a {...linkProps(data.href)} className={cn(actionClass(light ? "light" : "solid"), "px-5 py-2.5")}>
{data.cta}
</a>
)}
</div>
<MenuButton open={open} onClick={() => setOpen(true)} className="md:hidden" />
</nav>
</div>
</motion.header>
<MobileMenu open={open} onClose={() => setOpen(false)} links={links} brand={brand} cta={{ label: data.cta, href: data.href }} />
</>
)
}
// Schwebende Glas-Pille; die Hervorhebung gleitet von Menüpunkt zu Menüpunkt
export function NavGlas({ data, brand, links }: BlockProps<NavGlasData>) {
const ref = useRef<HTMLDivElement>(null)
const pill = useId()
const [hovered, setHovered] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const inline = links.filter((link) => link.href !== data.href.trim())
useEffect(() => {
if (!open) return
const close = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", close)
return () => document.removeEventListener("pointerdown", close)
}, [open])
return (
<motion.header
data-nav
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.15 }}
className="fixed inset-x-0 top-3 z-50 flex justify-center px-3 md:top-5"
>
<div ref={ref} className="relative w-full max-w-3xl">
<nav className="flex items-center justify-between gap-2 rounded-full border border-t-fg/10 bg-t-bg/65 py-1.5 pr-1.5 pl-5 text-t-fg shadow-[0_12px_40px_-18px_rgba(0,0,0,0.45)] backdrop-blur-xl">
<Brand brand={brand} className="text-lg md:text-xl" />
<ul className="hidden items-center md:flex" onPointerLeave={() => setHovered(null)}>
{inline.map((link) => (
<li key={link.href} className="relative">
{hovered === link.href && (
<motion.span
layoutId={pill}
className="absolute inset-0 rounded-full bg-t-fg/[0.08]"
transition={{ type: "spring", bounce: 0.18, duration: 0.45 }}
/>
)}
<a href={link.href} onPointerEnter={() => setHovered(link.href)} className="relative block px-4 py-2 text-sm text-t-fg/75 transition-colors hover:text-t-fg">
{link.label}
</a>
</li>
))}
</ul>
<div className="flex items-center gap-1">
{data.cta.trim() && (
<a {...linkProps(data.href)} className={cn(actionClass("solid"), "px-4 py-2.5 md:px-5")}>
{data.cta}
</a>
)}
{links.length > 0 && (
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-label={open ? "Menü schließen" : "Menü öffnen"}
className="grid size-10 cursor-pointer place-items-center rounded-full transition-colors hover:bg-t-fg/[0.06] md:hidden"
>
<span aria-hidden className="relative block h-3 w-5">
<span className={cn("absolute left-0 h-px w-5 bg-current transition-transform duration-300", open ? "top-1.5 rotate-45" : "top-0")} />
<span className={cn("absolute left-0 h-px w-5 bg-current transition-transform duration-300", open ? "top-1.5 -rotate-45" : "top-3")} />
</span>
</button>
)}
</div>
</nav>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98, transition: { duration: 0.15 } }}
transition={{ duration: 0.25, ease }}
style={{ transformOrigin: "top right" }}
className="absolute inset-x-0 top-full mt-2 rounded-[1.75rem] border border-t-fg/10 bg-t-bg/90 p-2 text-t-fg shadow-2xl backdrop-blur-xl md:hidden"
>
{links.map((link) => (
<a key={link.href} href={link.href} onClick={() => setOpen(false)} className="block rounded-2xl px-4 py-3 text-lg transition-colors hover:bg-t-fg/[0.06]">
{link.label}
</a>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.header>
)
}
// Magazin-Kopf: großer Schriftzug im Fluss der Seite, danach eine schmale Leiste oben
export function NavMasthead({ data, brand, links }: BlockProps<NavMastheadData>) {
const ref = useRef<HTMLElement>(null)
const { scrollY } = useScroll()
const [compact, setCompact] = useState(false)
const [open, setOpen] = useState(false)
const letters = Array.from(brand.name)
useMotionValueEvent(scrollY, "change", (y) => {
setCompact(y > (ref.current?.offsetHeight ?? 320) - 24)
})
return (
<>
<header ref={ref} className={cn(container, "relative pt-4 md:pt-5")}>
<div className="flex items-center justify-between gap-4 border-b border-t-fg/15 pb-3 text-[11px] uppercase tracking-[0.22em] text-t-muted md:pb-4">
<span>{data.edition}</span>
<nav className="hidden gap-7 md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="transition-colors hover:text-t-fg">
{link.label}
</a>
))}
</nav>
<MenuButton open={open} onClick={() => setOpen(true)} className="text-[11px] tracking-[0.22em] uppercase md:hidden" />
</div>
<a
href="#top"
aria-label={brand.name}
className="block overflow-hidden pt-[0.08em] pb-[0.12em] text-center font-t-display leading-[0.9] tracking-[-0.04em]"
style={{ fontSize: wordmarkSize(brand.name, 19) }}
>
{letters.map((char, i) => (
<motion.span
key={i}
aria-hidden
className="inline-block"
initial={{ y: "105%" }}
animate={{ y: "0%" }}
transition={{ duration: 1.1, ease, delay: 0.1 + i * 0.045 }}
>
{char === " " ? " " : char}
</motion.span>
))}
</a>
<div className="flex items-center justify-between gap-4 border-y border-t-fg/15 py-3 text-[11px] uppercase tracking-[0.22em] text-t-muted">
<span>{brand.tagline}</span>
{data.cta.trim() && (
<a {...linkProps(data.href)} className="shrink-0 text-t-fg underline-offset-4 hover:underline">
{data.cta} →
</a>
)}
</div>
</header>
<motion.div
data-nav
initial={false}
animate={{ y: compact ? "0%" : "-110%" }}
transition={{ duration: 0.5, ease }}
className="fixed inset-x-0 top-0 z-50 border-b border-t-fg/10 bg-t-bg/85 text-t-fg backdrop-blur-xl"
>
<div className={cn(container, "flex h-14 items-center justify-between gap-6")}>
<Brand brand={brand} className="text-xl md:text-xl" />
<nav className="hidden gap-7 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>
))}
</nav>
<MenuButton open={open} onClick={() => setOpen(true)} className="md:hidden" />
</div>
</motion.div>
<MobileMenu open={open} onClose={() => setOpen(false)} links={links} brand={brand} cta={{ label: data.cta, href: data.href }} />
</>
)
}

View file

@ -0,0 +1,255 @@
"use client"
import { motion } from "motion/react"
import type { PointerEvent, ReactNode } from "react"
import { ArrowUpRightIcon, CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DemoForm } from "../shared/demo-form"
import { TArea, TField } from "../shared/fields"
import { splitList } from "../shared/hooks"
import { Accordion, Reveal } from "../shared/motion"
import type { CtaPortalData, FaqData, KontaktFormularData, PaketeData } from "./definitions"
import type { BlockProps } from "./types"
import { Action, actionClass, container, Eyebrow, Heading, Magnetic, telHref } from "./ui"
// ── Pakete ───────────────────────────────────────────────────────────────────
type Plan = PaketeData["plans"][number]
// Umlaufender Lichtrand: ein drehender Kegelverlauf hinter der Karte, sichtbar im 1-px-Rand
function GlowBorder({ children }: { children: ReactNode }) {
return (
<div className="relative h-full overflow-hidden rounded-[1.75rem] bg-t-fg/10 p-px">
<motion.div
aria-hidden
className="absolute top-1/2 left-1/2 aspect-square w-[250%] -translate-x-1/2 -translate-y-1/2 bg-[conic-gradient(from_0deg,transparent_0deg,var(--t-accent)_70deg,transparent_140deg,transparent_180deg,var(--t-accent)_250deg,transparent_320deg)]"
animate={{ rotate: 360 }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
<div className="relative h-full rounded-[calc(1.75rem-1px)] bg-t-bg">{children}</div>
</div>
)
}
function PlanCard({ plan, href, featured }: { plan: Plan; href: string; featured: boolean }) {
const features = splitList(plan.features, "\n")
return (
<div
className={cn(
"flex h-full flex-col rounded-[inherit] p-7 md:p-9",
featured && "bg-[radial-gradient(120%_60%_at_50%_0%,color-mix(in_oklab,var(--t-accent)_14%,transparent),transparent)]"
)}
>
<div className="flex items-center justify-between gap-4">
<h3 className="font-t-display text-2xl tracking-[-0.01em]">{plan.name}</h3>
{featured && <span className="rounded-full bg-t-accent px-3 py-1 text-xs font-medium text-t-accent-fg">{plan.ribbon}</span>}
</div>
{plan.text.trim() && <p className="mt-2 text-sm text-t-muted">{plan.text}</p>}
<p className="mt-8 flex flex-wrap items-baseline gap-x-2">
<span className="font-t-display text-5xl tracking-[-0.03em]">{plan.price}</span>
{plan.period.trim() && <span className="text-sm text-t-muted">{plan.period}</span>}
</p>
{features.length > 0 && (
<ul className="mt-8 flex flex-col gap-3 border-t border-t-fg/10 pt-8 text-sm">
{features.map((feature, i) => (
<li key={i} className="flex gap-3">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-t-accent" />
{feature}
</li>
))}
</ul>
)}
<div className="mt-10 flex flex-1 items-end">
<Action href={href} variant={featured ? "solid" : "outline"} className="w-full">
{plan.cta}
</Action>
</div>
</div>
)
}
export function Pakete({ data }: BlockProps<PaketeData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="mx-auto max-w-3xl text-center">
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
{data.text.trim() && <p className="mx-auto mt-6 max-w-xl text-lg text-t-muted">{data.text}</p>}
</div>
<div className={cn("mx-auto mt-14 grid gap-4 md:mt-16", data.plans.length >= 3 ? "max-w-6xl lg:grid-cols-3" : "max-w-4xl md:grid-cols-2")}>
{data.plans.map((plan, i) => (
<Reveal key={i} delay={i * 0.08} className="h-full">
{plan.ribbon.trim() ? (
<GlowBorder>
<PlanCard plan={plan} href={data.href} featured />
</GlowBorder>
) : (
<div className="h-full rounded-[1.75rem] border border-t-fg/10">
<PlanCard plan={plan} href={data.href} featured={false} />
</div>
)}
</Reveal>
))}
</div>
</section>
)
}
// ── FAQ ──────────────────────────────────────────────────────────────────────
export function Faq({ data, brand }: BlockProps<FaqData>) {
return (
<section className={cn(container, "grid gap-12 py-24 md:grid-cols-12 md:gap-6 md:py-36")}>
<div className="md:col-span-4">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6 text-[clamp(2.5rem,4.5vw,4.5rem)]" />
{data.text.trim() && <p className="mt-6 max-w-sm text-t-muted">{data.text}</p>}
{brand.email.trim() && (
<a href={`mailto:${brand.email}`} className="mt-5 inline-block text-sm font-medium underline decoration-t-accent decoration-2 underline-offset-4">
{brand.email}
</a>
)}
</div>
<Reveal className="md:col-span-7 md:col-start-6">
<Accordion
items={data.items}
className="border-t border-t-fg/15"
itemClassName="border-b border-t-fg/15"
questionClassName="py-6 font-t-display text-xl tracking-[-0.01em] md:py-7 md:text-2xl"
answerClassName="max-w-2xl pb-7 leading-relaxed text-t-muted"
/>
</Reveal>
</section>
)
}
// ── Leuchtender Aufruf ───────────────────────────────────────────────────────
export function CtaPortal({ data }: BlockProps<CtaPortalData>) {
function onMove(event: PointerEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect()
event.currentTarget.style.setProperty("--x", `${event.clientX - rect.left}px`)
event.currentTarget.style.setProperty("--y", `${event.clientY - rect.top}px`)
}
return (
<section className="p-3 md:p-6">
<div onPointerMove={onMove} className="relative isolate overflow-hidden rounded-[2rem] bg-t-surface px-6 py-24 text-center md:rounded-[3rem] md:py-36">
<motion.div
aria-hidden
className="absolute top-full left-1/2 -z-10 aspect-square w-[130%] max-w-[1600px] -translate-x-1/2 -translate-y-[38%] rounded-full"
style={{ background: "radial-gradient(closest-side, var(--t-accent), transparent)" }}
animate={{ scale: [1, 1.08], opacity: [0.5, 0.8] }}
transition={{ duration: 5, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [background:radial-gradient(520px_circle_at_var(--x,50%)_var(--y,50%),color-mix(in_oklab,var(--t-accent)_22%,transparent),transparent_60%)]"
/>
<div className="relative mx-auto max-w-4xl">
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6 text-[clamp(2.75rem,7vw,7rem)] leading-[0.92]" />
{data.text.trim() && (
<Reveal delay={0.1}>
<p className="mx-auto mt-6 max-w-lg text-lg text-t-muted">{data.text}</p>
</Reveal>
)}
<Reveal delay={0.2} className="mt-10">
<Magnetic>
<Action href={data.href} className="px-8 py-4 text-base">
{data.cta}
</Action>
</Magnetic>
</Reveal>
</div>
</div>
</section>
)
}
// ── Kontakt mit Formular ─────────────────────────────────────────────────────
export function KontaktFormular({ data, brand }: BlockProps<KontaktFormularData>) {
const topics = splitList(data.topics)
const details = [
{ label: "E-Mail", value: brand.email, href: `mailto:${brand.email}` },
{ label: "Telefon", value: brand.phone, href: telHref(brand.phone) },
{ label: "Adresse", value: brand.address, href: "" },
{ label: "Erreichbar", value: data.hours, href: "" },
].filter((detail) => detail.value.trim())
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-14 md:grid-cols-12 md:gap-6">
<div className="md:col-span-5">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6 text-[clamp(2.75rem,6vw,6rem)] leading-[0.92]" />
{data.text.trim() && (
<Reveal delay={0.1}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{data.text}</p>
</Reveal>
)}
<Reveal delay={0.15}>
<dl className="mt-12 grid gap-6 sm:grid-cols-2 md:grid-cols-1">
{details.map((detail) => (
<div key={detail.label}>
<dt className="text-[11px] tracking-[0.22em] text-t-muted uppercase">{detail.label}</dt>
<dd className="mt-1.5 text-lg">
{detail.href ? (
<a
href={detail.href}
className="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-0.5 transition-[background-size] duration-500 hover:bg-[length:100%_1px]"
>
{detail.value}
</a>
) : (
detail.value
)}
</dd>
</div>
))}
</dl>
</Reveal>
</div>
<Reveal delay={0.1} className="md:col-span-6 md:col-start-7">
<DemoForm
className="rounded-[1.75rem] border border-t-fg/10 bg-t-surface/50 p-6 md:p-10"
success={
<div className="py-12 text-center">
<p className="font-t-display text-4xl tracking-[-0.02em] md:text-5xl">Danke!</p>
<p className="mx-auto mt-4 max-w-sm text-t-muted">{data.success}</p>
</div>
}
>
{topics.length > 0 && (
<fieldset className="mb-8">
<legend className="text-[11px] tracking-[0.22em] uppercase opacity-60">Worum geht es?</legend>
<div className="mt-3 flex flex-wrap gap-2">
{topics.map((topic, i) => (
<label key={topic} className="cursor-pointer">
<input type="radio" name="topic" value={topic} defaultChecked={i === 0} className="peer sr-only" />
<span className="block rounded-full border border-t-fg/15 px-4 py-2 text-sm transition-colors peer-checked:border-t-accent peer-checked:bg-t-accent peer-checked:text-t-accent-fg peer-focus-visible:ring-2 peer-focus-visible:ring-t-accent/50">
{topic}
</span>
</label>
))}
</div>
</fieldset>
)}
<div className="grid gap-6 sm:grid-cols-2">
<TField variant="line" label="Name" name="name" autoComplete="name" required />
<TField variant="line" label="E-Mail" name="email" type="email" autoComplete="email" required />
<TField variant="line" label="Telefon (optional)" name="phone" type="tel" autoComplete="tel" className="sm:col-span-2" />
<TArea variant="line" label="Nachricht" name="message" rows={4} required className="sm:col-span-2" />
</div>
<button type="submit" className={cn(actionClass("solid"), "mt-10 w-full sm:w-auto")}>
{data.button.trim() || "Senden"}
<ArrowUpRightIcon className="size-4 transition-transform duration-300 group-hover:rotate-45" />
</button>
</DemoForm>
</Reveal>
</div>
</section>
)
}

View file

@ -0,0 +1,63 @@
"use client"
import type { FontPair, TemplateContent, TemplateDefinition } from "../../lib/types"
import { useDemo } from "../shared/demo"
import { TemplateShell } from "../shared/shell"
import { blockComponents } from "./registry"
import type { BrandData, NavLink } from "./types"
// Anker aus dem Menütext: "Über uns" → "ueber-uns"
function slug(text: string) {
return text
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
const menuOf = (data: Record<string, unknown>) => (typeof data.menu === "string" ? data.menu.trim() : "")
// Seite aus Bausteinen: Reihenfolge und Inhalte aus content.blocks, Marke & Kontakt aus content.data.brand.
// Jeder Baustein steckt in einem Anker (id aus seinem Menüpunkt), data-block dient der Editor-Vorschau zum Hinscrollen.
export function BlockPage({ def, content, fonts }: { def: TemplateDefinition; content: TemplateContent; fonts: FontPair }) {
const demo = useDemo()
const brand = content.data.brand as BrandData
const blocks = content.blocks.filter((block) => !block.hidden && blockComponents[block.type])
const used = new Set(["top"])
const anchors = new Map<string, string>()
for (const block of blocks) {
const base = slug(menuOf(block.data)) || block.id
let anchor = base
for (let n = 2; used.has(anchor); n++) anchor = `${base}-${n}`
used.add(anchor)
anchors.set(block.id, anchor)
}
const links: NavLink[] = blocks.flatMap((block) => {
const menu = menuOf(block.data)
return menu ? [{ href: `#${anchors.get(block.id)}`, label: menu }] : []
})
return (
<TemplateShell def={def} content={content} fonts={fonts}>
<span id="top" aria-hidden className="absolute top-0" />
{blocks.map((block) => {
const Block = blockComponents[block.type]
return (
<div key={block.id} id={anchors.get(block.id)} data-block={block.id}>
<Block data={block.data} brand={brand} links={links} />
</div>
)
})}
{blocks.length === 0 && demo && (
<div className="grid min-h-dvh place-items-center p-10 text-center">
<div>
<p className="font-t-display text-4xl tracking-[-0.02em]">Noch keine Bausteine</p>
<p className="mt-3 text-t-muted">Im Editor unter „Inhalte“ → „Baustein hinzufügen“ die Seite zusammenstellen.</p>
</div>
</div>
)}
</TemplateShell>
)
}

View file

@ -0,0 +1,115 @@
"use client"
import { motion, useInView, useScroll, useSpring } from "motion/react"
import { useRef } from "react"
import { cn } from "@/lib/utils"
import { ease } from "../shared/motion"
import type { AblaufLinieData, AblaufSpaltenData } from "./definitions"
import type { BlockProps } from "./types"
import { container, easeInOut, Eyebrow, Heading } from "./ui"
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"]
type Step = AblaufLinieData["items"][number]
// Zeitstrahl: die Linie zeichnet sich mit dem Scrollen, Schritte leuchten auf, sobald die Linie sie erreicht
export function AblaufLinie({ data }: BlockProps<AblaufLinieData>) {
const track = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ target: track, offset: ["start 75%", "end 55%"] })
const progress = useSpring(scrollYProgress, { stiffness: 120, damping: 30 })
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="mx-auto max-w-3xl md:text-center">
<Eyebrow className="md:justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div ref={track} className="relative mx-auto mt-16 max-w-5xl md:mt-24">
<div aria-hidden className="absolute top-0 bottom-0 left-[11px] w-px bg-t-fg/12 md:left-1/2" />
<motion.div
aria-hidden
style={{ scaleY: progress }}
className="absolute top-0 bottom-0 left-[11px] w-px origin-top bg-t-accent shadow-[0_0_14px_var(--t-accent)] md:left-1/2"
/>
<ol className="flex flex-col gap-14 md:gap-24">
{data.items.map((item, i) => (
<TimelineStep key={i} item={item} index={i} />
))}
</ol>
</div>
</section>
)
}
function TimelineStep({ item, index }: { item: Step; index: number }) {
const ref = useRef<HTMLLIElement>(null)
// Gleiche Höhe wie das Ende der Linie (offset "end 55%"), damit Punkt und Linie zusammen ankommen
const lit = useInView(ref, { margin: "0px 0px -45% 0px" })
const right = index % 2 === 1
return (
<li ref={ref} className="relative grid pl-12 md:grid-cols-2 md:pl-0">
<span
aria-hidden
className={cn(
"absolute top-1 left-0 grid size-[23px] place-items-center rounded-full border transition-[background-color,border-color,box-shadow] duration-500 md:left-1/2 md:-translate-x-1/2",
lit ? "border-t-accent bg-t-accent shadow-[0_0_24px_var(--t-accent)]" : "border-t-fg/20 bg-t-bg"
)}
>
<span className={cn("size-1.5 rounded-full transition-colors duration-500", lit ? "bg-t-accent-fg" : "bg-t-fg/30")} />
</span>
<motion.div
initial={{ opacity: 0, x: right ? 24 : -24 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "0px 0px -20% 0px" }}
transition={{ duration: 0.8, ease }}
className={cn(right ? "md:col-start-2 md:pl-16" : "md:pr-16 md:text-right")}
>
<p className={cn("flex flex-wrap items-center gap-3 text-xs font-medium tracking-[0.2em] text-t-muted uppercase", !right && "md:justify-end")}>
Schritt {String(index + 1).padStart(2, "0")}
{item.duration.trim() && <span className="rounded-full border border-t-fg/15 px-2.5 py-1 tracking-normal normal-case">{item.duration}</span>}
</p>
<h3 className="mt-4 font-t-display text-3xl tracking-[-0.02em] md:text-4xl">{item.title}</h3>
<p className="mt-3 leading-relaxed text-t-muted">{item.text}</p>
</motion.div>
</li>
)
}
// Schritte nebeneinander: Linien zeichnen sich nacheinander, darunter römische Ziffern
export function AblaufSpalten({ data }: BlockProps<AblaufSpaltenData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12">
<div className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</div>
<Heading text={data.title} className="md:col-span-9" />
</div>
<ol className="mt-14 grid gap-12 sm:grid-cols-2 md:mt-20 lg:grid-cols-4 lg:gap-8">
{data.items.map((item, i) => (
<li key={i}>
<motion.span
aria-hidden
className="block h-px origin-left bg-t-fg/25"
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.2, ease: easeInOut, delay: i * 0.12 }}
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.9, ease, delay: 0.2 + i * 0.12 }}
>
<span className="mt-6 block font-t-display text-6xl leading-none text-t-accent italic">{ROMAN[i] ?? i + 1}</span>
<h3 className="mt-8 font-t-display text-2xl tracking-[-0.01em] md:text-3xl">{item.title}</h3>
<p className="mt-3 leading-relaxed text-t-muted">{item.text}</p>
</motion.div>
</li>
))}
</ol>
</section>
)
}

View file

@ -0,0 +1,281 @@
"use client"
import { animate, AnimatePresence, motion, useAnimationFrame, useInView, useMotionValue, useReducedMotionConfig, wrap } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { Counter, Marquee, Reveal, ease } from "../shared/motion"
import type { LogosData, StimmenWandData, StimmenZitatData, TeamData, ZahlenData } from "./definitions"
import type { BlockProps } from "./types"
import { container, easeInOut, Eyebrow, Heading, InkImage } from "./ui"
// ── Kennzahlen ───────────────────────────────────────────────────────────────
export function Zahlen({ data }: BlockProps<ZahlenData>) {
const header = data.eyebrow.trim() || data.title.trim()
return (
<section className={cn(container, "py-20 md:py-28")}>
{header && (
<div className="mb-14 grid gap-6 md:mb-20 md:grid-cols-12">
<div className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</div>
<Heading text={data.title} className="text-[clamp(2.25rem,4.5vw,4.5rem)] md:col-span-9" />
</div>
)}
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
{data.items.map((item, i) => (
<div key={i}>
<motion.span
aria-hidden
className="block h-px origin-left bg-t-fg/20"
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.2, ease: easeInOut, delay: i * 0.1 }}
/>
<p className="mt-6 flex items-start gap-x-1.5 font-t-display text-[clamp(2.75rem,5.5vw,5.5rem)] leading-none tracking-[-0.04em] whitespace-nowrap">
<Counter value={item.value} />
{/* Zusatz (+, %, Mrd. €) klein und hochgestellt, damit lange Zusätze nicht umbrechen */}
{item.suffix.trim() && <span className="mt-[0.1em] text-[0.42em] tracking-normal text-t-accent">{item.suffix.trim()}</span>}
</p>
<p className="mt-3 text-sm text-t-muted">{item.label}</p>
</div>
))}
</div>
</section>
)
}
// ── Logo-Laufband ────────────────────────────────────────────────────────────
export function Logos({ data }: BlockProps<LogosData>) {
return (
<section className="py-14 md:py-20">
{data.title.trim() && <p className={cn(container, "mb-10 text-center text-sm text-t-muted")}>{data.title}</p>}
<div className="[mask-image:linear-gradient(90deg,transparent,black_12%,black_88%,transparent)]">
<Marquee duration={36}>
{data.items.map((item, i) =>
item.logo.trim() ? (
<span key={i} className="relative mx-8 block h-10 w-36 opacity-60 grayscale transition-opacity hover:opacity-100 md:mx-12">
<TImage src={item.logo} alt={item.name} sizes="144px" className="object-contain" />
</span>
) : (
<span key={i} className="mx-8 font-t-display text-2xl tracking-[-0.02em] whitespace-nowrap text-t-fg/45 md:mx-12 md:text-3xl">
{item.name}
</span>
)
)}
</Marquee>
</div>
</section>
)
}
// ── Großes Zitat ─────────────────────────────────────────────────────────────
const ROTATE_MS = 7000
// Wechselt alle 7 Sekunden; über dem Bereich hält es an. Die Leiste unter dem Zitat zeigt die Restzeit.
export function StimmenZitat({ data }: BlockProps<StimmenZitatData>) {
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
const count = data.items.length
const current = count > 0 ? index % count : 0
useEffect(() => {
if (count < 2 || paused) return
const timer = setTimeout(() => setIndex((i) => (i + 1) % count), ROTATE_MS)
return () => clearTimeout(timer)
}, [current, count, paused])
if (count === 0) return null
const item = data.items[current]
return (
<section onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} className={cn(container, "py-24 md:py-40")}>
<div className="mx-auto max-w-5xl text-center">
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<span aria-hidden className="mt-10 block h-[0.5em] font-t-display text-[9rem] leading-none text-t-accent md:text-[12rem]">
“
</span>
<div className="grid min-h-[19rem] items-center md:min-h-[16rem]">
<AnimatePresence mode="wait" initial={false}>
<motion.figure
key={current}
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -16, filter: "blur(8px)" }}
transition={{ duration: 0.6, ease }}
>
<blockquote className="font-t-display text-[clamp(1.6rem,3.4vw,3rem)] leading-[1.18] tracking-[-0.015em] text-balance">{item.quote}</blockquote>
<figcaption className="mt-8 text-sm">
<span className="font-medium">{item.author}</span>
{item.role.trim() && <span className="text-t-muted"> · {item.role}</span>}
</figcaption>
</motion.figure>
</AnimatePresence>
</div>
{count > 1 && (
<div className="mt-12 flex items-center justify-center gap-4">
<button
type="button"
aria-label="Vorherige Stimme"
onClick={() => setIndex((current - 1 + count) % count)}
className="grid size-11 cursor-pointer place-items-center rounded-full border border-t-fg/15 transition-[border-color,scale] duration-200 hover:border-t-fg/50 active:scale-95"
>
<ArrowLeftIcon className="size-4" />
</button>
<div className="flex gap-2">
{data.items.map((_, i) => (
<button
key={i}
type="button"
aria-label={`Stimme ${i + 1}`}
aria-current={i === current}
onClick={() => setIndex(i)}
className="relative h-1 w-10 cursor-pointer overflow-hidden rounded-full bg-t-fg/15"
>
{i === current && (
<motion.span
key={`${current}-${paused}`}
className="absolute inset-0 origin-left bg-t-accent"
initial={{ scaleX: paused ? 1 : 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: paused ? 0 : ROTATE_MS / 1000, ease: "linear" }}
/>
)}
</button>
))}
</div>
<button
type="button"
aria-label="Nächste Stimme"
onClick={() => setIndex((current + 1) % count)}
className="grid size-11 cursor-pointer place-items-center rounded-full border border-t-fg/15 transition-[border-color,scale] duration-200 hover:border-t-fg/50 active:scale-95"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
)}
</div>
</section>
)
}
// ── Stimmen-Wand ─────────────────────────────────────────────────────────────
type Testimonial = StimmenWandData["items"][number]
function TestimonialCard({ item, hidden }: { item: Testimonial; hidden: boolean }) {
return (
<figure aria-hidden={hidden || undefined} className="mb-4 rounded-[1.5rem] border border-t-fg/10 bg-t-fg/[0.03] p-6 md:p-7">
<blockquote className="leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
{item.image.trim() && (
<span className="relative size-10 shrink-0 overflow-hidden rounded-full bg-t-surface">
<TImage src={item.image} sizes="40px" />
</span>
)}
<span className="text-sm">
<span className="block font-medium">{item.author}</span>
<span className="text-t-muted">{item.role}</span>
</span>
</figcaption>
</figure>
)
}
// Endlose Spalte: Inhalt doppelt, verschoben um die halbe Höhe. Beim Darüberfahren bremst sie weich ab.
function LoopColumn({ items, speed, reverse, className }: { items: Testimonial[]; speed: number; reverse?: boolean; className?: string }) {
const ref = useRef<HTMLDivElement>(null)
const inner = useRef<HTMLDivElement>(null)
const y = useMotionValue(0)
const rate = useMotionValue(1)
const visible = useInView(ref)
const reduce = useReducedMotionConfig()
useAnimationFrame((_, delta) => {
const element = inner.current
if (!element || !visible || reduce) return
const half = element.offsetHeight / 2
if (half <= 0) return
y.set(wrap(-half, 0, y.get() + (reverse ? 1 : -1) * speed * (delta / 1000) * rate.get()))
})
return (
<div
ref={ref}
onPointerEnter={() => animate(rate, 0, { duration: 0.6, ease: "easeOut" })}
onPointerLeave={() => animate(rate, 1, { duration: 0.8, ease: "easeInOut" })}
className={cn("h-full overflow-hidden", className)}
>
<motion.div ref={inner} style={{ y }}>
{[0, 1].map((copy) => items.map((item, i) => <TestimonialCard key={`${copy}-${i}`} item={item} hidden={copy === 1} />))}
</motion.div>
</div>
)
}
export function StimmenWand({ data }: BlockProps<StimmenWandData>) {
const split = (columns: number) => Array.from({ length: columns }, (_, c) => data.items.filter((_, i) => i % columns === c))
return (
<section className="overflow-hidden py-24 md:py-36">
<div className={cn(container, "max-w-3xl text-center")}>
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div className={cn(container, "mt-14 md:mt-16")}>
<div className="h-[620px] [mask-image:linear-gradient(transparent,black_14%,black_86%,transparent)]">
<LoopColumn items={data.items} speed={26} className="md:hidden" />
<div className="hidden h-full grid-cols-2 gap-4 md:grid lg:hidden">
{split(2).map((items, c) => (
<LoopColumn key={c} items={items} speed={26 + c * 6} reverse={c % 2 === 1} />
))}
</div>
<div className="hidden h-full grid-cols-3 gap-4 lg:grid">
{split(3).map((items, c) => (
<LoopColumn key={c} items={items} speed={24 + c * 5} reverse={c % 2 === 1} />
))}
</div>
</div>
</div>
</section>
)
}
// ── Team ─────────────────────────────────────────────────────────────────────
export function Team({ data }: BlockProps<TeamData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12 md:items-end">
<div className="md:col-span-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
{data.text.trim() && (
<Reveal className="md:col-span-4 md:col-start-9">
<p className="text-lg leading-relaxed text-t-muted">{data.text}</p>
</Reveal>
)}
</div>
<div className="mt-12 grid grid-cols-2 gap-x-3 gap-y-10 md:mt-16 md:grid-cols-4 md:gap-x-5">
{data.members.map((member, i) => (
<Reveal key={i} delay={(i % 4) * 0.07} className={cn(i % 2 === 1 && "md:mt-16")}>
<figure>
<InkImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="aspect-[3/4] rounded-[1rem]" />
<figcaption className="mt-4">
<p className="font-t-display text-xl tracking-[-0.01em] md:text-2xl">{member.name}</p>
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
</figcaption>
</figure>
</Reveal>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,52 @@
"use client"
import type { ComponentType } from "react"
import { Statement, StoryKapitel, TextBild } from "./about"
import { Laufband, Wortmaske } from "./accents"
import { FooterSchlicht, FooterWortmarke } from "./footer"
import { NavGlas, NavLeiste, NavMasthead } from "./navigation"
import { CtaPortal, Faq, KontaktFormular, Pakete } from "./offer"
import { AblaufLinie, AblaufSpalten } from "./process"
import { Logos, StimmenWand, StimmenZitat, Team, Zahlen } from "./proof"
import { LeistungenBento, LeistungenKarten, LeistungenPanels } from "./services"
import { HeroAurora, HeroEditorial, HeroKinetik, HeroKino, HeroVollbild } from "./start"
import type { BlockProps } from "./types"
import { GalerieKarussell, ProjekteIndex, ProjekteQuerlauf } from "./work"
type BlockComponent = ComponentType<BlockProps>
// Typ → Komponente; Felder und Standardinhalte stehen in definitions.ts (gleiche Schlüssel)
export const blockComponents: Record<string, BlockComponent> = {
"nav-leiste": NavLeiste as BlockComponent,
"nav-glas": NavGlas as BlockComponent,
"nav-masthead": NavMasthead as BlockComponent,
"hero-kino": HeroKino as BlockComponent,
"hero-vollbild": HeroVollbild as BlockComponent,
"hero-aurora": HeroAurora as BlockComponent,
"hero-editorial": HeroEditorial as BlockComponent,
"hero-kinetik": HeroKinetik as BlockComponent,
statement: Statement as BlockComponent,
"story-kapitel": StoryKapitel as BlockComponent,
"text-bild": TextBild as BlockComponent,
"leistungen-panels": LeistungenPanels as BlockComponent,
"leistungen-bento": LeistungenBento as BlockComponent,
"leistungen-karten": LeistungenKarten as BlockComponent,
"projekte-querlauf": ProjekteQuerlauf as BlockComponent,
"projekte-index": ProjekteIndex as BlockComponent,
"galerie-karussell": GalerieKarussell as BlockComponent,
"ablauf-linie": AblaufLinie as BlockComponent,
"ablauf-spalten": AblaufSpalten as BlockComponent,
zahlen: Zahlen as BlockComponent,
logos: Logos as BlockComponent,
"stimmen-zitat": StimmenZitat as BlockComponent,
"stimmen-wand": StimmenWand as BlockComponent,
team: Team as BlockComponent,
pakete: Pakete as BlockComponent,
faq: Faq as BlockComponent,
"cta-portal": CtaPortal as BlockComponent,
"kontakt-formular": KontaktFormular as BlockComponent,
laufband: Laufband as BlockComponent,
wortmaske: Wortmaske as BlockComponent,
"footer-wortmarke": FooterWortmarke as BlockComponent,
"footer-schlicht": FooterSchlicht as BlockComponent,
}

View file

@ -0,0 +1,261 @@
"use client"
import { motion, useMotionTemplate, useMotionValue, useSpring } from "motion/react"
import { useRef, useState, type PointerEvent, type ReactNode } from "react"
import { ArrowUpRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Reveal, ease } from "../shared/motion"
import type { LeistungenBentoData, LeistungenKartenData, LeistungenPanelsData } from "./definitions"
import type { BlockProps } from "./types"
import { container, Eyebrow, Heading } from "./ui"
const number = (i: number) => String(i + 1).padStart(2, "0")
// ── Panels ───────────────────────────────────────────────────────────────────
// Bildpaneele nebeneinander; das aktive zieht sich auf (Desktop). Mobil als Karten untereinander.
export function LeistungenPanels({ data }: BlockProps<LeistungenPanelsData>) {
const [active, setActive] = useState(0)
const current = Math.min(active, Math.max(0, data.items.length - 1))
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="max-w-3xl">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div className="mt-12 hidden h-[72vh] max-h-[820px] min-h-[520px] gap-3 md:flex">
{data.items.map((item, i) => {
const open = i === current
return (
<article
key={i}
tabIndex={0}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={() => setActive(i)}
style={{ flexBasis: 0 }}
className={cn(
"relative min-w-0 cursor-pointer overflow-hidden rounded-[1.25rem] bg-black text-white outline-none focus-visible:ring-2 focus-visible:ring-t-accent",
"transition-[flex-grow] duration-700 ease-[cubic-bezier(0.77,0,0.175,1)]",
open ? "grow-[5]" : "grow"
)}
>
<TImage
src={item.image}
alt={item.title}
sizes="60vw"
className={cn("transition-transform duration-[1.4s] ease-out", open ? "scale-100" : "scale-110")}
/>
<div aria-hidden className={cn("absolute inset-0 bg-black transition-opacity duration-700", open ? "opacity-20" : "opacity-55")} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
<span className="absolute top-6 left-6 text-xs tracking-[0.2em] tabular-nums opacity-80">{number(i)}</span>
<span
aria-hidden
className={cn(
"absolute bottom-6 left-6 rotate-180 font-t-display text-2xl whitespace-nowrap transition-opacity duration-300 [writing-mode:vertical-rl]",
open ? "opacity-0" : "opacity-100 delay-300"
)}
>
{item.title}
</span>
<div
className={cn(
"absolute inset-x-0 bottom-0 p-8 transition-[opacity,translate] duration-500 lg:p-10",
open ? "translate-y-0 opacity-100 delay-300" : "pointer-events-none translate-y-6 opacity-0"
)}
>
<h3 className="font-t-display text-[clamp(2rem,3.4vw,3.5rem)] leading-none tracking-[-0.03em]">{item.title}</h3>
<p className="mt-4 max-w-md text-white/80">{item.text}</p>
</div>
</article>
)
})}
</div>
<div className="mt-10 grid gap-3 md:hidden">
{data.items.map((item, i) => (
<Reveal key={i}>
<article className="relative aspect-[4/5] overflow-hidden rounded-[1.25rem] bg-black text-white">
<TImage src={item.image} alt={item.title} sizes="100vw" />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-6">
<span className="text-xs tracking-[0.2em] opacity-80">{number(i)}</span>
<h3 className="mt-2 font-t-display text-3xl tracking-[-0.02em]">{item.title}</h3>
<p className="mt-2 text-white/80">{item.text}</p>
</div>
</article>
</Reveal>
))}
</div>
</section>
)
}
// ── Bento ────────────────────────────────────────────────────────────────────
// Ein Lichtkegel folgt der Maus über alle Kacheln: Rand und Fläche leuchten in Akzentfarbe auf.
// Die Position steht als --x/--y direkt an jeder Kachel (nicht am Raster), damit nur die Kacheln neu zeichnen.
export function LeistungenBento({ data }: BlockProps<LeistungenBentoData>) {
const cards = useRef<(HTMLDivElement | null)[]>([])
function onMove(event: PointerEvent<HTMLDivElement>) {
for (const card of cards.current) {
if (!card) continue
const rect = card.getBoundingClientRect()
card.style.setProperty("--x", `${event.clientX - rect.left}px`)
card.style.setProperty("--y", `${event.clientY - rect.top}px`)
}
}
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12 md:items-end md:gap-6">
<div className="md:col-span-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
{data.text.trim() && (
<Reveal className="md:col-span-4 md:col-start-9">
<p className="text-lg leading-relaxed text-t-muted">{data.text}</p>
</Reveal>
)}
</div>
<div onPointerMove={onMove} className="group/grid mt-12 grid gap-3 md:mt-16 md:auto-rows-[minmax(15rem,auto)] md:grid-cols-3">
{data.items.map((item, i) => {
const featured = i === 0
return (
<motion.div
key={i}
ref={(node) => {
cards.current[i] = node
}}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.8, ease, delay: (i % 3) * 0.08 }}
className={cn(
"group/card relative rounded-[1.5rem] bg-t-fg/10 p-px",
"[background-image:radial-gradient(380px_circle_at_var(--x,-999px)_var(--y,-999px),var(--t-accent),transparent_45%)]",
featured && "md:col-span-2 md:row-span-2"
)}
>
<div className="relative flex h-full flex-col overflow-hidden rounded-[calc(1.5rem-1px)] bg-t-bg p-7 md:p-8">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover/grid:opacity-100 [background:radial-gradient(520px_circle_at_var(--x,-999px)_var(--y,-999px),color-mix(in_oklab,var(--t-accent)_14%,transparent),transparent_45%)]"
/>
{item.image.trim() && (
<div
className={cn(
"relative mb-7 overflow-hidden rounded-[1rem] bg-t-surface",
featured ? "aspect-[16/9] md:aspect-auto md:min-h-[14rem] md:flex-1" : "aspect-[16/10]"
)}
>
<TImage
src={item.image}
alt={item.title}
sizes={featured ? "(min-width: 768px) 60vw, 100vw" : "(min-width: 768px) 30vw, 100vw"}
className="transition-transform duration-700 ease-out group-hover/card:scale-[1.04]"
/>
</div>
)}
<div className="relative mt-auto">
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-t-muted">{number(i)}</span>
<ArrowUpRightIcon className="size-4 text-t-muted transition-transform duration-300 group-hover/card:rotate-45 group-hover/card:text-t-accent" />
</div>
<h3 className={cn("mt-3 font-t-display tracking-[-0.02em]", featured ? "text-3xl md:text-5xl" : "text-2xl")}>{item.title}</h3>
<p className="mt-3 max-w-md leading-relaxed text-t-muted">{item.text}</p>
</div>
</div>
</motion.div>
)
})}
</div>
</section>
)
}
// ── 3D-Karten ────────────────────────────────────────────────────────────────
// Neigt sich zur Maus und wirft einen Glanz (nur mit Maus; auf Touch bleibt die Karte ruhig)
function TiltCard({ children }: { children: ReactNode }) {
const rotateX = useMotionValue(0)
const rotateY = useMotionValue(0)
const glareX = useMotionValue(50)
const glareY = useMotionValue(50)
const springX = useSpring(rotateX, { stiffness: 180, damping: 18 })
const springY = useSpring(rotateY, { stiffness: 180, damping: 18 })
const glare = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, color-mix(in oklab, var(--t-fg) 14%, transparent), transparent 55%)`
function onMove(event: PointerEvent<HTMLDivElement>) {
if (event.pointerType !== "mouse") return
const rect = event.currentTarget.getBoundingClientRect()
const px = (event.clientX - rect.left) / rect.width
const py = (event.clientY - rect.top) / rect.height
rotateY.set((px - 0.5) * 12)
rotateX.set((0.5 - py) * 12)
glareX.set(px * 100)
glareY.set(py * 100)
}
return (
<div className="h-full [perspective:1200px]">
<motion.div
onPointerMove={onMove}
onPointerLeave={() => {
rotateX.set(0)
rotateY.set(0)
}}
style={{ rotateX: springX, rotateY: springY }}
className="group relative h-full rounded-[1.75rem] border border-t-fg/10 bg-t-surface p-8 md:p-10"
>
<motion.div aria-hidden style={{ background: glare }} className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
<div className="relative">{children}</div>
</motion.div>
</div>
)
}
export function LeistungenKarten({ data }: BlockProps<LeistungenKartenData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="max-w-3xl">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div className="mt-12 grid gap-4 md:mt-16 md:grid-cols-2">
{data.items.map((item, i) => {
const features = splitList(item.features, "\n")
return (
<Reveal key={i} delay={(i % 2) * 0.08} className="h-full">
<TiltCard>
<div className="flex items-start justify-between gap-6">
<span className="font-t-display text-[5.5rem] leading-[0.8] tracking-[-0.05em] text-t-accent">{number(i)}</span>
<ArrowUpRightIcon className="size-6 text-t-muted transition-transform duration-500 group-hover:rotate-45" />
</div>
<h3 className="mt-10 font-t-display text-3xl tracking-[-0.02em] md:text-4xl">{item.title}</h3>
<p className="mt-3 max-w-sm text-t-muted">{item.text}</p>
{features.length > 0 && (
<ul className="mt-8 grid gap-2.5 border-t border-t-fg/10 pt-6 text-sm">
{features.map((feature, j) => (
<li key={j} className="flex items-center gap-3">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-t-accent" />
{feature}
</li>
))}
</ul>
)}
</TiltCard>
</Reveal>
)
})}
</div>
</section>
)
}

View file

@ -0,0 +1,496 @@
"use client"
import {
motion,
useAnimationFrame,
useInView,
useMotionTemplate,
useMotionValue,
useReducedMotionConfig,
useScroll,
useSpring,
useTransform,
type MotionValue,
} from "motion/react"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { ease, wordmarkSize } from "../shared/motion"
import type { HeroAuroraData, HeroEditorialData, HeroKinetikData, HeroKinoData, HeroVollbildData } from "./definitions"
import type { BlockProps } from "./types"
import { Action, container, easeInOut, Eyebrow, Heading, LineLink, linkProps, Magnetic, RevealImage } from "./ui"
// Hervorhebung auf Bildern: Akzent mit Weiß gemischt, damit sie auch bei dunklen Akzenten lesbar bleibt
const emOnImage = "italic text-[color-mix(in_oklab,var(--t-accent)_55%,white)]"
// ── Kino-Fenster ─────────────────────────────────────────────────────────────
// Ein Bildfenster unter der Überschrift öffnet sich beim Scrollen zum Vollbild (Sektion bleibt stehen)
export function HeroKino({ data }: BlockProps<HeroKinoData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] })
const progress = useSpring(scrollYProgress, { stiffness: 140, damping: 30, mass: 0.35 })
const inset = useTransform(progress, [0, 0.72], [1, 0])
// Abstände des Fensters als CSS-Variablen (mobil/desktop), skaliert mit dem Fortschritt
const clipPath = useMotionTemplate`inset(calc(var(--it) * ${inset}) calc(var(--ix) * ${inset}) calc(var(--ib) * ${inset}) calc(var(--ix) * ${inset}) round calc(1.75rem * ${inset}))`
const imageScale = useTransform(progress, [0, 0.72], [1.3, 1])
// Kameraschwenk: das kleine Fenster zeigt zuerst den oberen Bildteil, beim Öffnen gleitet das Bild zur Mitte
const imageY = useTransform(progress, [0, 0.72], ["24%", "0%"])
const titleY = useTransform(progress, [0, 0.4], ["0%", "-40%"])
const titleOpacity = useTransform(progress, [0.04, 0.36], [1, 0])
const shade = useTransform(progress, [0.45, 0.8], [0, 1])
const outroOpacity = useTransform(progress, [0.7, 0.88], [0, 1])
const outroY = useTransform(progress, [0.7, 0.88], [48, 0])
const outroEvents = useTransform(progress, (value) => (value > 0.75 ? "auto" : "none"))
const cue = useTransform(progress, [0, 0.06], [1, 0])
return (
<section ref={ref} className="relative h-[250vh]">
<div className="sticky top-0 h-svh overflow-hidden">
<motion.div
data-surface="dark"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.2, delay: 0.3 }}
style={{ clipPath }}
className="absolute inset-0 bg-black [--ib:7%] [--it:47%] [--ix:5%] md:[--ib:6%] md:[--it:45%] md:[--ix:27%]"
>
<motion.div style={{ y: imageY, scale: imageScale }} className="absolute inset-0">
<TImage src={data.image} eager sizes="100vw" />
</motion.div>
<motion.div style={{ opacity: shade }} className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-black/10" />
</motion.div>
<motion.div
style={{ y: titleY, opacity: titleOpacity }}
className={cn(container, "relative flex h-[45%] flex-col items-center justify-end pb-7 text-center md:pb-9")}
>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }}>
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
</motion.div>
<Heading as="h1" onMount delay={0.25} text={data.title} className="mt-5 text-[clamp(3rem,8.4vw,9.25rem)] leading-[0.88] tracking-[-0.04em]" />
</motion.div>
<motion.div
style={{ opacity: outroOpacity, y: outroY, pointerEvents: outroEvents }}
className={cn(container, "absolute inset-x-0 bottom-0 flex flex-col gap-8 pb-12 text-white md:flex-row md:items-end md:justify-between md:pb-16")}
>
<p className="max-w-2xl font-t-display text-[clamp(1.6rem,3vw,2.75rem)] leading-[1.12] tracking-[-0.02em]">{data.text}</p>
<Action href={data.href} variant="light" className="self-start md:self-auto">
{data.cta}
</Action>
</motion.div>
<motion.div style={{ opacity: cue }} aria-hidden className="absolute right-5 bottom-[calc(7%+1.25rem)] flex items-center gap-3 text-[10px] tracking-[0.3em] text-white/80 uppercase md:right-[calc(27%+1.5rem)] md:bottom-[calc(6%+1.5rem)]">
Scrollen
<span className="relative h-8 w-px overflow-hidden bg-white/25">
<motion.span
className="absolute inset-x-0 top-0 h-1/2 bg-white"
animate={{ y: ["-100%", "200%"] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
</span>
</motion.div>
</div>
</section>
)
}
// ── Vollbild ─────────────────────────────────────────────────────────────────
export function HeroVollbild({ data }: BlockProps<HeroVollbildData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const y = useTransform(scrollYProgress, [0, 1], ["0%", "18%"])
const fade = useTransform(scrollYProgress, [0, 0.55], [1, 0])
return (
<section ref={ref} data-surface="dark" className="relative h-svh min-h-[600px] overflow-hidden bg-black text-white">
<motion.div style={{ y }} className="absolute inset-0">
<motion.div initial={{ scale: 1.18 }} animate={{ scale: 1 }} transition={{ duration: 2.6, ease: [0.16, 1, 0.3, 1] }} className="absolute inset-0">
<TImage src={data.image} eager sizes="100vw" />
</motion.div>
</motion.div>
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-black/30" />
<motion.div style={{ opacity: fade }} className={cn(container, "relative flex h-full flex-col justify-end pb-10 md:pb-14")}>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 0.3 }}>
<Eyebrow className="text-white/75">{data.eyebrow}</Eyebrow>
</motion.div>
<Heading
as="h1"
onMount
delay={0.4}
text={data.title}
emClassName={emOnImage}
className="mt-6 text-[clamp(3.25rem,8.8vw,10rem)] leading-[0.9] tracking-[-0.04em]"
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 0.9 }}
className="mt-10 flex flex-col gap-8 border-t border-white/20 pt-8 md:flex-row md:items-end md:justify-between"
>
<p className="max-w-md text-lg leading-relaxed text-white/80">{data.text}</p>
<div className="flex flex-wrap gap-3">
<Action href={data.href}>{data.cta}</Action>
<Action href={data.hrefSecondary} variant="light">
{data.ctaSecondary}
</Action>
</div>
</motion.div>
</motion.div>
</section>
)
}
// ── Aurora ───────────────────────────────────────────────────────────────────
const auroraBlobs = [
{ style: { top: "-25%", left: "-10%", width: "60vw", height: "60vw", background: "radial-gradient(closest-side, var(--t-accent), transparent)" }, x: ["0%", "14%"], y: ["0%", "8%"], scale: [1, 1.15], duration: 18 },
{ style: { top: "-15%", right: "-15%", width: "55vw", height: "55vw", background: "radial-gradient(closest-side, color-mix(in oklab, var(--t-accent) 50%, #22d3ee), transparent)" }, x: ["0%", "-12%"], y: ["0%", "14%"], scale: [1.1, 0.95], duration: 22 },
{ style: { bottom: "-35%", left: "18%", width: "65vw", height: "65vw", background: "radial-gradient(closest-side, color-mix(in oklab, var(--t-accent) 45%, #f472b6), transparent)" }, x: ["0%", "-8%"], y: ["0%", "-12%"], scale: [0.95, 1.12], duration: 26 },
]
// Langsam ziehende Farbwolken in Akzent-Tönen über einem feinen Raster; auch für andere Bausteine nutzbar
export function AuroraGlow({ className }: { className?: string }) {
return (
<div aria-hidden className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}>
{auroraBlobs.map((blob, i) => (
<motion.div
key={i}
className="absolute rounded-full opacity-45"
style={blob.style}
animate={{ x: blob.x, y: blob.y, scale: blob.scale }}
transition={{ duration: blob.duration, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
))}
<div className="absolute inset-0 [background-image: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)] [background-size:64px_64px] [mask-image:radial-gradient(ellipse_at_50%_35%,black_15%,transparent_70%)]" />
</div>
)
}
export function HeroAurora({ data }: BlockProps<HeroAuroraData>) {
const keywords = splitList(data.keywords).slice(0, 8)
return (
<section className="relative overflow-hidden pt-32 pb-12 md:pt-44 md:pb-20">
<AuroraGlow />
<div className={cn(container, "relative flex flex-col items-center text-center")}>
{data.badge.trim() && (
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.1 }}
className="relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-t-fg/15 bg-t-bg/40 px-4 py-1.5 text-xs font-medium backdrop-blur-md"
>
<span aria-hidden className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-t-accent opacity-60" />
<span className="relative size-2 rounded-full bg-t-accent" />
</span>
{data.badge}
<motion.span
aria-hidden
className="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-t-fg/15 to-transparent"
initial={{ x: "-120%" }}
animate={{ x: "320%" }}
transition={{ duration: 2.4, repeat: Infinity, repeatDelay: 2.5, ease: "easeInOut" }}
/>
</motion.p>
)}
<Heading
as="h1"
onMount
delay={0.2}
text={data.title}
emClassName="bg-gradient-to-r from-t-accent via-[color-mix(in_oklab,var(--t-accent)_50%,#22d3ee)] to-[color-mix(in_oklab,var(--t-accent)_45%,#f472b6)] bg-clip-text text-transparent [-webkit-background-clip:text]"
className="mt-7 max-w-5xl text-[clamp(2.9rem,7vw,7.25rem)] leading-[0.98] tracking-[-0.045em]"
/>
<motion.p
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 0.6 }}
className="mt-7 max-w-xl text-lg leading-relaxed text-t-muted md:text-xl"
>
{data.text}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 0.75 }}
className="mt-9 flex flex-wrap justify-center gap-3"
>
<Magnetic>
<Action href={data.href}>{data.cta}</Action>
</Magnetic>
<Action href={data.hrefSecondary} variant="outline" className="bg-t-bg/30 backdrop-blur-md">
{data.ctaSecondary}
</Action>
</motion.div>
{keywords.length > 0 && <Orbit words={keywords} />}
</div>
</section>
)
}
// Lichtkugel mit Schlagworten auf einer Ellipsen-Bahn; hinten kleiner und blasser, vorne vor der Kugel
function Orbit({ words }: { words: string[] }) {
const ref = useRef<HTMLDivElement>(null)
const angle = useMotionValue(0)
const width = useMotionValue(0)
const height = useMotionValue(0)
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new ResizeObserver(([entry]) => {
width.set(entry.contentRect.width)
height.set(entry.contentRect.height)
})
observer.observe(element)
return () => observer.disconnect()
}, [width, height])
useAnimationFrame((_, delta) => {
if (reduce || !visible) return
angle.set(angle.get() + delta * 0.008)
})
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1.4, ease, delay: 0.5 }}
className="relative mt-12 aspect-[4/3] w-full max-w-5xl sm:aspect-[2/1] md:mt-16"
>
<div aria-hidden className="absolute inset-x-[3%] inset-y-[12%] rounded-[50%] border border-t-fg/10" />
<div aria-hidden className="absolute inset-x-[16%] inset-y-[4%] rounded-[50%] border border-dashed border-t-fg/[0.07]" />
<div aria-hidden className="absolute top-1/2 left-1/2 z-[2] aspect-square w-[34%] -translate-x-1/2 -translate-y-1/2 sm:w-[28%]">
<div className="absolute inset-[-40%] rounded-full opacity-60 blur-2xl" style={{ background: "radial-gradient(closest-side, var(--t-accent), transparent)" }} />
<motion.div
className="absolute inset-0 rounded-full"
style={{
background: "radial-gradient(circle at 32% 28%, color-mix(in oklab, white 80%, var(--t-accent)) 0%, var(--t-accent) 38%, color-mix(in oklab, var(--t-accent) 35%, black) 80%)",
boxShadow: "inset -18px -24px 60px rgba(0,0,0,0.35), 0 30px 90px -20px var(--t-accent)",
}}
animate={{ y: [0, -10] }}
transition={{ duration: 3.5, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
</div>
{words.map((word, i) => (
<OrbitChip key={`${word}-${i}`} word={word} angle={angle} width={width} height={height} offset={(360 / words.length) * i} />
))}
</motion.div>
)
}
function OrbitChip({ word, angle, width, height, offset }: {
word: string
angle: MotionValue<number>
width: MotionValue<number>
height: MotionValue<number>
offset: number
}) {
const theta = useTransform(angle, (value) => ((value + offset) * Math.PI) / 180)
const x = useTransform(() => Math.cos(theta.get()) * width.get() * 0.45)
const y = useTransform(() => Math.sin(theta.get()) * height.get() * 0.38)
// 0 = hinten (oben auf der Bahn), 1 = vorne (unten). Gerundet, weil der Server Stilwerte mit weniger
// Nachkommastellen ausgibt – sonst meldet React beim Hydrieren abweichende Attribute.
const depth = useTransform(theta, (value) => (Math.sin(value) + 1) / 2)
const scale = useTransform(depth, (value) => Math.round((0.78 + value * 0.28) * 1000) / 1000)
const opacity = useTransform(depth, (value) => Math.round((0.45 + value * 0.55) * 1000) / 1000)
const zIndex = useTransform(depth, (value) => (value > 0.5 ? 3 : 1))
return (
<motion.span
style={{ x, y, scale, opacity, zIndex }}
className="absolute top-1/2 left-1/2 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-t-fg/15 bg-t-surface/85 px-3.5 py-1.5 text-xs font-medium whitespace-nowrap shadow-[0_10px_30px_-14px_rgba(0,0,0,0.5)] sm:px-4 sm:py-2 sm:text-sm"
>
<span aria-hidden className="size-1.5 rounded-full bg-t-accent" />
{word}
</motion.span>
)
}
// ── Editorial ────────────────────────────────────────────────────────────────
export function HeroEditorial({ data, brand }: BlockProps<HeroEditorialData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const imageY = useTransform(scrollYProgress, [0, 1], ["0%", "10%"])
return (
<section ref={ref} className={cn(container, "relative grid gap-10 pt-24 pb-16 md:grid-cols-12 md:gap-6 md:pt-32 md:pb-24")}>
{brand.tagline.trim() && (
<p aria-hidden className="absolute top-32 left-0 hidden rotate-180 text-[10px] tracking-[0.3em] text-t-muted uppercase [writing-mode:vertical-rl] xl:block">
{brand.tagline}
</p>
)}
<div className="flex flex-col md:col-span-7 md:pr-6">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.1 }}>
<Eyebrow>{data.eyebrow}</Eyebrow>
</motion.div>
<Heading as="h1" onMount delay={0.15} text={data.title} className="mt-8 text-[clamp(3.25rem,7.8vw,8.75rem)] leading-[0.9] tracking-[-0.035em]" />
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 0.8 }}
className="mt-auto grid gap-6 pt-12 sm:grid-cols-[1fr_auto] sm:items-end"
>
<p className="max-w-sm text-lg leading-relaxed text-t-muted">{data.text}</p>
<LineLink href={data.href}>{data.cta}</LineLink>
</motion.div>
</div>
<figure className="md:col-span-5">
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
<motion.div style={{ y: imageY }} className="absolute inset-x-0 -top-[6%] -bottom-[6%]">
<motion.div initial={{ scale: 1.25 }} animate={{ scale: 1 }} transition={{ duration: 2, ease }} className="absolute inset-0">
<TImage src={data.image} alt={data.caption} eager sizes="(min-width: 768px) 42vw, 100vw" />
</motion.div>
</motion.div>
{/* Jalousien: fünf Streifen in Hintergrundfarbe ziehen nacheinander nach oben weg */}
{[0, 1, 2, 3, 4].map((i) => (
<motion.span
key={i}
aria-hidden
className="absolute inset-y-0 origin-top bg-t-bg"
style={{ left: `${i * 20}%`, width: "20.5%" }}
initial={{ scaleY: 1 }}
animate={{ scaleY: 0 }}
transition={{ duration: 1.1, ease: easeInOut, delay: 0.3 + i * 0.09 }}
/>
))}
</div>
{data.caption.trim() && (
<figcaption className="mt-3 flex justify-between gap-4 text-xs text-t-muted">
<span>{data.caption}</span>
<span>Nº 01</span>
</figcaption>
)}
</figure>
</section>
)
}
// ── Kinetik ──────────────────────────────────────────────────────────────────
const REST = `"wght" 250, "wdth" 75`
// Buchstaben werden zur Maus hin fetter und breiter (variable Schrift mit wght/wdth-Achse, z. B. Mona Sans).
// Ohne Maus wandert ein gedachter Punkt langsam über das Wort.
function PressureWord({ text, className }: { text: string; className?: string }) {
const ref = useRef<HTMLHeadingElement>(null)
const letters = useRef<(HTMLSpanElement | null)[]>([])
const pointer = useRef({ x: -1e4, y: -1e4, targetX: -1e4, targetY: -1e4, active: false })
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
useEffect(() => {
const move = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return
Object.assign(pointer.current, { targetX: event.clientX, targetY: event.clientY, active: true })
}
const leave = () => {
pointer.current.active = false
}
window.addEventListener("pointermove", move)
document.documentElement.addEventListener("pointerleave", leave)
return () => {
window.removeEventListener("pointermove", move)
document.documentElement.removeEventListener("pointerleave", leave)
}
}, [])
useAnimationFrame((time) => {
const element = ref.current
if (!element || !visible) return
const rect = element.getBoundingClientRect()
const p = pointer.current
if (!p.active) {
p.targetX = reduce ? -1e4 : rect.left + rect.width * (0.5 + 0.48 * Math.sin(time / 2200))
p.targetY = rect.top + rect.height / 2
}
p.x += (p.targetX - p.x) * 0.12
p.y += (p.targetY - p.y) * 0.12
const reach = Math.max(rect.width / 2.6, 180)
const boxes = letters.current.map((letter) => letter?.getBoundingClientRect())
boxes.forEach((box, i) => {
const letter = letters.current[i]
if (!box || !letter) return
const distance = Math.hypot(p.x - (box.left + box.width / 2), p.y - (box.top + box.height / 2))
const t = Math.max(0, 1 - distance / reach)
const eased = t * t * (3 - 2 * t)
letter.style.fontVariationSettings = `"wght" ${Math.round(250 + eased * 650)}, "wdth" ${Math.round(75 + eased * 50)}`
})
})
return (
<h1
ref={ref}
aria-label={text}
className={cn("text-center font-t-display leading-[0.82] tracking-[-0.04em] whitespace-nowrap select-none", className)}
style={{ fontSize: wordmarkSize(text, 24) }}
>
{Array.from(text).map((char, i) => (
<span
key={i}
aria-hidden
ref={(node) => {
letters.current[i] = node
}}
style={{ fontVariationSettings: REST }}
>
{char === " " ? " " : char}
</span>
))}
</h1>
)
}
export function HeroKinetik({ data, brand }: BlockProps<HeroKinetikData>) {
const word = data.word.trim() || brand.name
return (
<section className="relative overflow-hidden pt-24 md:pt-28">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.2 }}
className={cn(container, "flex items-center justify-between gap-6 text-[11px] tracking-[0.25em] text-t-muted uppercase")}
>
<span>{data.eyebrow}</span>
<span className="hidden sm:inline">Scrollen ↓</span>
</motion.div>
<motion.div initial={{ opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1.2, ease, delay: 0.1 }}>
<PressureWord text={word} className="mt-4 md:mt-2" />
</motion.div>
<div className={cn(container, "mt-6 grid gap-8 border-t border-t-fg/15 py-10 md:mt-8 md:grid-cols-12 md:gap-6 md:py-12")}>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 0.5 }}
className="font-t-display text-[clamp(1.4rem,2.4vw,2.25rem)] leading-[1.15] tracking-[-0.02em] md:col-span-6"
>
{data.text}
</motion.p>
{data.cta.trim() && (
<div className="flex items-end md:col-span-2 md:col-start-8">
<Magnetic strength={0.4}>
<a
{...linkProps(data.href)}
className="grid size-28 place-items-center rounded-full bg-t-accent p-4 text-center text-sm leading-tight font-medium text-t-accent-fg transition-[scale] duration-200 active:scale-95 md:size-32"
>
{data.cta} ↗
</a>
</Magnetic>
</div>
)}
<RevealImage src={data.image} sizes="(min-width: 768px) 33vw, 100vw" className="aspect-[4/3] rounded-[1rem] md:col-span-4 md:col-start-9" />
</div>
</section>
)
}

View file

@ -0,0 +1,9 @@
import type { SectionData } from "../../lib/types"
// Marke & Kontakt gelten für die ganze Seite (content.data.brand) und stehen jedem Baustein zur Verfügung
export type BrandData = { name: string; tagline: string; email: string; phone: string; address: string; logo: string }
// Menüpunkt eines Bausteins (Feld „menu“), verlinkt auf seinen Anker
export type NavLink = { href: string; label: string }
export type BlockProps<D extends SectionData = SectionData> = { data: D; brand: BrandData; links: NavLink[] }

View file

@ -0,0 +1,329 @@
"use client"
import { AnimatePresence, motion, useInView, useMotionValue, useScroll, useSpring } from "motion/react"
import { useEffect, useEffectEvent, useRef, useState, useSyncExternalStore, type PointerEvent, type ReactNode } from "react"
import { ArrowRightIcon, ArrowUpRightIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { RevealWords, ease } from "../shared/motion"
import type { BrandData, NavLink } from "./types"
// Gemeinsame Bausteine der Blöcke. Alles richtet sich nach den Paletten-Farben (t-*), damit jeder Block
// auf hellen wie dunklen Paletten funktioniert und sich mit anderen Blöcken kombinieren lässt.
export const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
export const em = "italic text-t-accent"
// Für Bewegungen auf der Fläche (Paneele, Masken); `ease` aus shared/motion für Auftritte
export const easeInOut = [0.77, 0, 0.175, 1] as const
const SAFE_HREF = /^(#|\/|mailto:|tel:|https?:\/\/)/i
// Button-Ziele kommen aus dem Editor: nur Anker, eigene Pfade, mailto:, tel: und http(s)
export function linkProps(href: string) {
const target = SAFE_HREF.test(href.trim()) ? href.trim() : "#"
return /^https?:\/\//i.test(target) ? { href: target, target: "_blank", rel: "noreferrer" } : { href: target }
}
export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`
export function useMedia(query: string) {
return useSyncExternalStore(
(onChange) => {
const media = window.matchMedia(query)
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
},
() => window.matchMedia(query).matches,
() => false
)
}
export function Eyebrow({ children, className }: { children: string; className?: string }) {
if (!children.trim()) return null
return (
<p className={cn("flex items-center gap-3 text-[11px] font-medium uppercase tracking-[0.28em] text-t-muted", className)}>
<span aria-hidden className="h-px w-7 shrink-0 bg-t-accent" />
{children}
</p>
)
}
export function Heading({ text, as = "h2", className, emClassName = em, onMount, delay }: {
text: string
as?: "h1" | "h2" | "h3"
className?: string
emClassName?: string
onMount?: boolean
delay?: number
}) {
if (!text.trim()) return null
return (
<RevealWords
as={as}
text={text}
onMount={onMount}
delay={delay}
emClassName={emClassName}
className={cn("font-t-display text-[clamp(2.5rem,5.6vw,5.5rem)] leading-[0.95] tracking-[-0.03em]", className)}
/>
)
}
// Bild, das beim Erscheinen von unten aufgedeckt wird und sich dabei leicht zurückzoomt
export function RevealImage({ src, alt = "", className, sizes }: { src: string; alt?: string; className?: string; sizes?: string }) {
return (
<motion.div
className={cn("relative overflow-hidden bg-t-surface", className)}
initial={{ clipPath: "inset(100% 0% 0% 0%)" }}
whileInView={{ clipPath: "inset(0% 0% 0% 0%)" }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.2, ease: easeInOut }}
>
<motion.div
className="absolute inset-0"
initial={{ scale: 1.2 }}
whileInView={{ scale: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.8, ease }}
>
<TImage src={src} alt={alt} sizes={sizes} />
</motion.div>
</motion.div>
)
}
// Textlink mit Linie darunter, die beim Darüberfahren zusammenschnurrt
export function LineLink({ href, children, className }: { href: string; children: string; className?: string }) {
if (!children.trim()) return null
return (
<a {...linkProps(href)} className={cn("group inline-flex items-center gap-3 text-sm font-medium uppercase tracking-[0.18em]", className)}>
<span className="relative pb-1">
{children}
<span aria-hidden className="absolute bottom-0 left-0 h-px w-full origin-left bg-current transition-transform duration-500 ease-out group-hover:scale-x-[0.35]" />
</span>
<ArrowRightIcon className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
)
}
const actionStyles = {
solid: "bg-t-accent text-t-accent-fg hover:shadow-[0_14px_40px_-14px_var(--t-accent)]",
outline: "border border-t-fg/20 text-t-fg hover:border-t-fg/60",
inverse: "bg-t-fg text-t-bg",
light: "border border-white/35 text-white backdrop-blur-sm hover:border-white/80",
}
export type ActionVariant = keyof typeof actionStyles
export const actionClass = (variant: ActionVariant = "solid") =>
cn(
"group inline-flex cursor-pointer items-center justify-center gap-2 rounded-full px-6 py-3.5 text-sm font-medium",
"transition-[scale,box-shadow,border-color,background-color] duration-200 ease-out active:scale-[0.97]",
actionStyles[variant]
)
export function Action({ href, children, variant = "solid", className }: { href: string; children: string; variant?: ActionVariant; className?: string }) {
if (!children.trim()) return null
return (
<a {...linkProps(href)} className={cn(actionClass(variant), className)}>
{children}
<ArrowUpRightIcon className="size-4 transition-transform duration-300 group-hover:rotate-45" />
</a>
)
}
// Folgt der Maus ein Stück, sobald sie in der Nähe ist (nur mit Maus, nicht bei Touch)
export function Magnetic({ children, className, strength = 0.3 }: { children: ReactNode; className?: string; strength?: number }) {
const x = useMotionValue(0)
const y = useMotionValue(0)
const springX = useSpring(x, { stiffness: 220, damping: 18, mass: 0.6 })
const springY = useSpring(y, { stiffness: 220, damping: 18, mass: 0.6 })
function onMove(event: PointerEvent<HTMLDivElement>) {
if (event.pointerType !== "mouse") return
const rect = event.currentTarget.getBoundingClientRect()
x.set((event.clientX - (rect.left + rect.width / 2)) * strength)
y.set((event.clientY - (rect.top + rect.height / 2)) * strength)
}
return (
<motion.div
onPointerMove={onMove}
onPointerLeave={() => {
x.set(0)
y.set(0)
}}
style={{ x: springX, y: springY }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
)
}
// Schwarzweiß-Bild, das sich beim Darüberfahren vom Mauspunkt aus einfärbt; auf Touch-Geräten beim Erscheinen
export function InkImage({ src, alt = "", className, sizes }: { src: string; alt?: string; className?: string; sizes?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState(false)
const [origin, setOrigin] = useState({ x: 50, y: 50 })
const touch = useMedia("(hover: none)")
const seen = useInView(ref, { once: true, amount: 0.6 })
const colored = hover || (touch && seen)
function track(event: PointerEvent<HTMLDivElement>, active: boolean) {
if (event.pointerType !== "mouse") return
const rect = event.currentTarget.getBoundingClientRect()
setOrigin({ x: ((event.clientX - rect.left) / rect.width) * 100, y: ((event.clientY - rect.top) / rect.height) * 100 })
setHover(active)
}
return (
<div ref={ref} onPointerEnter={(event) => track(event, true)} onPointerLeave={(event) => track(event, false)} className={cn("relative overflow-hidden bg-t-surface", className)}>
<TImage src={src} alt={alt} sizes={sizes} className="grayscale" />
<motion.div
aria-hidden
className="absolute inset-0"
initial={false}
animate={{ clipPath: `circle(${colored ? 150 : 0}% at ${origin.x}% ${origin.y}%)` }}
transition={{ duration: colored ? 0.9 : 0.5, ease }}
>
<TImage src={src} sizes={sizes} />
</motion.div>
</div>
)
}
export function Brand({ brand, className }: { brand: BrandData; className?: string }) {
return (
<a href="#top" className={cn("shrink-0 font-t-display text-xl tracking-tight md:text-2xl", className)}>
{brand.logo.trim() ? (
<span className="relative block h-8 w-36">
<TImage src={brand.logo} alt={brand.name} sizes="144px" className="object-contain object-left" />
</span>
) : (
brand.name
)}
</a>
)
}
// Anker-Links erst nach dem Schließen des Menüs ansteuern, sonst blockiert die Scroll-Sperre
function goTo(href: string) {
if (!href.startsWith("#")) return
setTimeout(() => document.getElementById(href.slice(1))?.scrollIntoView({ behavior: "smooth" }), 80)
}
// Vollbild-Menü fürs Handy (Navigation „Leiste“ und „Magazin-Kopf“)
export function MobileMenu({ open, onClose, links, brand, cta }: {
open: boolean
onClose: () => void
links: NavLink[]
brand: BrandData
cta: { label: string; href: string }
}) {
const onKey = useEffectEvent((event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
})
useEffect(() => {
if (!open) return
const root = document.documentElement
const previous = root.style.overflow
root.style.overflow = "hidden"
window.addEventListener("keydown", onKey)
return () => {
root.style.overflow = previous
window.removeEventListener("keydown", onKey)
}
}, [open])
function navigate(href: string) {
onClose()
goTo(href)
}
return (
<AnimatePresence>
{open && (
<motion.div
role="dialog"
aria-modal
aria-label="Menü"
data-nav
initial={{ clipPath: "inset(0% 0% 100% 0%)" }}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
exit={{ clipPath: "inset(0% 0% 100% 0%)", transition: { duration: 0.45, ease: easeInOut } }}
transition={{ duration: 0.65, ease: easeInOut }}
className="fixed inset-0 z-[80] flex flex-col bg-t-bg px-5 pt-4 pb-10 text-t-fg"
>
<div className="flex h-12 items-center justify-between">
<Brand brand={brand} />
<button type="button" onClick={onClose} aria-label="Menü schließen" className="grid size-11 cursor-pointer place-items-center rounded-full border border-t-fg/15 transition-transform active:scale-95">
<XIcon className="size-5" />
</button>
</div>
<nav className="mt-auto flex flex-col">
{links.map((link, i) => (
<span key={link.href} className="overflow-hidden">
<motion.a
href={link.href}
onClick={(event) => {
event.preventDefault()
navigate(link.href)
}}
initial={{ y: "110%" }}
animate={{ y: "0%" }}
transition={{ duration: 0.7, ease, delay: 0.25 + i * 0.06 }}
className="block py-1 font-t-display text-[clamp(2.5rem,11vw,4.5rem)] leading-[1.08] tracking-[-0.03em]"
>
{link.label}
</motion.a>
</span>
))}
</nav>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }} className="mt-10 flex flex-wrap items-center justify-between gap-4">
{cta.label.trim() && (
<a
{...linkProps(cta.href)}
onClick={(event) => {
if (!cta.href.startsWith("#")) return
event.preventDefault()
navigate(cta.href)
}}
className={actionClass("solid")}
>
{cta.label}
</a>
)}
<a href={`mailto:${brand.email}`} className="text-sm text-t-muted">{brand.email}</a>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// Liegt unter der Navigation gerade eine dunkle Bildfläche (data-surface="dark")? Dann braucht sie helle Schrift.
export function useDarkSurface(probeY = 36) {
const [dark, setDark] = useState(false)
const { scrollY } = useScroll()
useEffect(() => {
const check = () => {
const hit = document.elementsFromPoint(window.innerWidth / 2, probeY).find((element) => !element.closest("[data-nav]"))
setDark(Boolean(hit?.closest('[data-surface="dark"]')))
}
const frame = requestAnimationFrame(check)
const timer = setTimeout(check, 700)
const unsubscribe = scrollY.on("change", check)
window.addEventListener("resize", check)
return () => {
cancelAnimationFrame(frame)
clearTimeout(timer)
unsubscribe()
window.removeEventListener("resize", check)
}
}, [scrollY, probeY])
return dark
}

View file

@ -0,0 +1,275 @@
"use client"
import {
animate,
AnimatePresence,
motion,
useAnimationFrame,
useInView,
useMotionValue,
useMotionValueEvent,
useReducedMotionConfig,
useScroll,
useTransform,
} from "motion/react"
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { HorizontalScroll, ease } from "../shared/motion"
import type { GalerieKarussellData, ProjekteIndexData, ProjekteQuerlaufData } from "./definitions"
import type { BlockProps } from "./types"
import { container, Eyebrow, Heading } from "./ui"
const pad = (n: number) => String(n).padStart(2, "0")
const number = (i: number) => pad(i + 1)
// ── Querlauf ─────────────────────────────────────────────────────────────────
// Projekte gleiten beim Scrollen seitlich vorbei; die Bildhöhe richtet sich nach dem Bildschirm
export function ProjekteQuerlauf({ data }: BlockProps<ProjekteQuerlaufData>) {
return (
<HorizontalScroll
header={
<div className={cn(container, "mb-8 grid gap-4 md:mb-12 md:grid-cols-12 md:items-end")}>
<div className="md:col-span-8">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-5 text-[clamp(2.25rem,5vw,5rem)]" />
</div>
<p className="text-sm text-t-muted md:col-span-4 md:text-right">
{pad(data.items.length)} Projekte · weiterscrollen
</p>
</div>
}
trackClassName="items-end gap-4 px-5 md:gap-8 md:px-10"
footer={(progress) => (
<div className={cn(container, "mt-8 md:mt-10")}>
<div className="h-px w-full bg-t-fg/15">
<motion.div style={{ scaleX: progress }} className="h-full origin-left bg-t-accent" />
</div>
</div>
)}
>
{data.items.map((item, i) => (
<article key={i} className="group shrink-0">
<div
className={cn(
"relative overflow-hidden rounded-[1.25rem] bg-t-surface",
i % 2 === 0 ? "aspect-[4/3] h-[40vh] md:h-[50vh]" : "aspect-[3/4] h-[46vh] md:h-[58vh]"
)}
>
<TImage src={item.image} alt={item.title} sizes="(min-width: 768px) 50vw, 90vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
</div>
<div className="mt-4 flex items-baseline justify-between gap-6">
<h3 className="font-t-display text-2xl tracking-[-0.02em] md:text-3xl">{item.title}</h3>
<span className="text-sm whitespace-nowrap text-t-muted">
{[item.category, item.year].filter(Boolean).join(" · ")}
</span>
</div>
</article>
))}
</HorizontalScroll>
)
}
// ── Index mit Bildspur ───────────────────────────────────────────────────────
type TrailImage = { id: number; x: number; y: number; src: string; rotate: number }
// Swiss-Liste; mit der Maus entsteht eine Spur aus Projektbildern, die nach kurzer Zeit verblasst
export function ProjekteIndex({ data }: BlockProps<ProjekteIndexData>) {
const last = useRef({ x: -1e4, y: -1e4, count: 0 })
const timers = useRef(new Set<ReturnType<typeof setTimeout>>())
const [trail, setTrail] = useState<TrailImage[]>([])
const images = data.items.map((item) => item.image).filter((src) => src.trim())
useEffect(() => {
const pending = timers.current
return () => pending.forEach((timer) => clearTimeout(timer))
}, [])
function onMove(event: PointerEvent<HTMLElement>) {
if (event.pointerType !== "mouse" || images.length === 0) return
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const previous = last.current
if (Math.hypot(x - previous.x, y - previous.y) < 90) return
previous.x = x
previous.y = y
previous.count += 1
const image = { id: previous.count, x, y, src: images[previous.count % images.length], rotate: ((previous.count * 37) % 17) - 8 }
setTrail((current) => [...current.slice(-6), image])
const timer = setTimeout(() => {
setTrail((current) => current.filter((entry) => entry.id !== image.id))
timers.current.delete(timer)
}, 700)
timers.current.add(timer)
}
return (
<section onPointerMove={onMove} className={cn(container, "relative py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12 md:items-end">
<div className="md:col-span-8">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-5" />
</div>
<p className="font-t-display text-4xl text-t-muted md:col-span-4 md:text-right md:text-6xl">({pad(data.items.length)})</p>
</div>
<div className="mt-12 border-t border-t-fg/15 md:mt-16">
<div className="hidden grid-cols-12 gap-4 border-b border-t-fg/15 py-3 text-[11px] tracking-[0.22em] text-t-muted uppercase md:grid">
<span className="col-span-1">Nº</span>
<span className="col-span-5">Projekt</span>
<span className="col-span-3">Ort</span>
<span className="col-span-2">Kategorie</span>
<span className="col-span-1 text-right">Jahr</span>
</div>
{data.items.map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -8% 0px" }}
transition={{ duration: 0.7, ease, delay: i * 0.05 }}
className="group grid grid-cols-12 items-baseline gap-x-4 gap-y-2 border-b border-t-fg/15 py-6 md:py-8"
>
<span className="col-span-2 text-sm text-t-muted tabular-nums md:col-span-1">{number(i)}</span>
<h3 className="col-span-10 font-t-display text-[clamp(1.75rem,3.2vw,3.25rem)] leading-none tracking-[-0.03em] transition-transform duration-500 ease-out md:col-span-5 md:group-hover:translate-x-3">
{item.title}
</h3>
<span className="col-span-6 col-start-3 text-sm text-t-muted md:col-span-3 md:col-start-auto md:text-base">{item.location}</span>
<span className="hidden text-t-muted md:col-span-2 md:block">{item.category}</span>
<span className="col-span-4 text-right text-sm tabular-nums md:col-span-1 md:text-base">{item.year}</span>
<div className="relative col-span-12 mt-3 aspect-[16/10] overflow-hidden rounded-xl bg-t-surface md:hidden">
<TImage src={item.image} alt={item.title} sizes="100vw" />
</div>
</motion.div>
))}
</div>
<div aria-hidden className="pointer-events-none absolute inset-0 z-10 hidden overflow-hidden md:block">
<AnimatePresence>
{trail.map((image) => (
<motion.div
key={image.id}
className="absolute w-52 overflow-hidden rounded-xl bg-t-surface shadow-[0_30px_60px_-20px_rgba(0,0,0,0.5)]"
style={{ left: image.x, top: image.y, rotate: image.rotate, x: "-50%", y: "-50%" }}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85, transition: { duration: 0.35 } }}
transition={{ duration: 0.4, ease }}
>
<div className="relative aspect-[4/5]">
<TImage src={image.src} sizes="208px" />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</section>
)
}
// ── 3D-Karussell ─────────────────────────────────────────────────────────────
type Drag = { startX: number; startValue: number; lastX: number; lastTime: number; velocity: number }
// Bilder auf einem Zylinder: dreht sich langsam von selbst, mit dem Scrollen und per Ziehen (mit Schwung).
// Breite und Radius rechnet CSS aus (--w, --n), damit es auf jeder Bildschirmgröße passt.
export function GalerieKarussell({ data }: BlockProps<GalerieKarussellData>) {
const ref = useRef<HTMLElement>(null)
const images = data.images.filter((image) => image.src.trim())
const count = Math.max(images.length, 1)
const step = 360 / count
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const scrolled = useTransform(scrollYProgress, [0, 1], [0, -200])
const drag = useMotionValue(0)
const spin = useMotionValue(0)
const rotation = useTransform(() => scrolled.get() + drag.get() + spin.get())
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
const dragging = useRef<Drag | null>(null)
const [front, setFront] = useState(0)
useAnimationFrame((_, delta) => {
if (reduce || !visible || dragging.current) return
spin.set(spin.get() - delta * 0.006)
})
useMotionValueEvent(rotation, "change", (value) => {
setFront((((Math.round(-value / step) % count) + count) % count))
})
function onDown(event: PointerEvent<HTMLDivElement>) {
event.currentTarget.setPointerCapture(event.pointerId)
dragging.current = { startX: event.clientX, startValue: drag.get(), lastX: event.clientX, lastTime: event.timeStamp, velocity: 0 }
}
function onMove(event: PointerEvent<HTMLDivElement>) {
const state = dragging.current
if (!state) return
state.velocity = (event.clientX - state.lastX) / Math.max(1, event.timeStamp - state.lastTime)
state.lastX = event.clientX
state.lastTime = event.timeStamp
drag.set(state.startValue + (event.clientX - state.startX) * 0.3)
}
function onUp() {
const state = dragging.current
if (!state) return
dragging.current = null
// Schwung: bei schnellem Loslassen dreht es noch ein Stück weiter
animate(drag, drag.get() + state.velocity * 240, { duration: 1, ease: [0.16, 1, 0.3, 1] })
}
return (
<section ref={ref} className="overflow-hidden py-24 md:py-36">
<div className={cn(container, "grid gap-6 md:grid-cols-12 md:items-end")}>
<div className="md:col-span-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-5" />
</div>
{data.text.trim() && <p className="text-t-muted md:col-span-4 md:col-start-9 md:text-right">{data.text}</p>}
</div>
<div
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
style={{ "--n": count, "--r": "calc(var(--w) / 2 / tan(calc(180deg / var(--n))) * 1.28)" } as CSSProperties}
className="relative mt-8 h-[min(80vw,660px)] min-h-[360px] cursor-grab touch-pan-y select-none [--w:clamp(140px,38vw,280px)] [perspective:1700px] active:cursor-grabbing md:mt-12 md:h-[min(46vw,660px)] md:[--w:clamp(150px,18vw,280px)]"
>
{/* Leicht von oben betrachtet, damit der Ring als Ring lesbar ist */}
<div className="absolute inset-0 [transform:rotateX(-7deg)] [transform-style:preserve-3d]">
<motion.div style={{ rotateY: rotation }} className="absolute inset-0 [transform-style:preserve-3d]">
{images.map((image, i) => (
<figure
key={i}
className="absolute top-1/2 left-1/2 aspect-[3/4] w-[var(--w)] [transform-style:preserve-3d]"
style={{ transform: `translate(-50%, -50%) rotateY(${i * step}deg) translateZ(var(--r))` }}
>
<div className="absolute inset-0 overflow-hidden rounded-[1rem] bg-t-surface shadow-[0_30px_60px_-30px_rgba(0,0,0,0.55)] [backface-visibility:hidden]">
<TImage src={image.src} alt={image.caption} sizes="300px" />
</div>
{/* Rückseite: dasselbe Bild gedimmt – hinten entsteht so ein geschlossener Ring statt einer Lücke */}
<div aria-hidden className="absolute inset-0 overflow-hidden rounded-[1rem] bg-t-surface [transform:rotateY(180deg)] [backface-visibility:hidden]">
<TImage src={image.src} sizes="300px" className="opacity-30 grayscale" />
</div>
</figure>
))}
</motion.div>
</div>
</div>
<div className="mt-6 h-6 overflow-hidden text-center text-sm tracking-[0.18em] text-t-muted uppercase md:mt-8">
<AnimatePresence mode="wait" initial={false}>
<motion.p key={front} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
{images[front]?.caption}
</motion.p>
</AnimatePresence>
</div>
</section>
)
}

View file

@ -1,5 +1,8 @@
import type { FontPair } from "../lib/types"
import { fonts as agentur } from "./agentur/fonts"
import { fonts as atelier } from "./atelier/fonts"
import { fonts as aurora } from "./aurora/fonts"
import { fonts as baukasten } from "./baukasten/fonts"
import { fonts as beauty } from "./beauty/fonts"
import { fonts as cafe } from "./cafe/fonts"
import { fonts as coach } from "./coach/fonts"
@ -13,9 +16,11 @@ import { fonts as kampagne } from "./kampagne/fonts"
import { fonts as kanzlei } from "./kanzlei/fonts"
import { fonts as karriere } from "./karriere/fonts"
import { fonts as linkinbio } from "./linkinbio/fonts"
import { fonts as monolith } from "./monolith/fonts"
import { fonts as nutzfahrzeuge } from "./nutzfahrzeuge/fonts"
import { fonts as pflege } from "./pflege/fonts"
import { fonts as praxis } from "./praxis/fonts"
import { fonts as prisma } from "./prisma/fonts"
import { fonts as restaurant } from "./restaurant/fonts"
import { fonts as saas } from "./saas/fonts"
import { fonts as shop } from "./shop/fonts"
@ -23,8 +28,8 @@ import { fonts as speisekarte } from "./speisekarte/fonts"
import { fonts as verein } from "./verein/fonts"
const library: Record<string, Record<string, FontPair>> = {
agentur, beauty, cafe, coach, event, fitness, fotograf, handwerk, hotel, immobilien, kampagne,
kanzlei, karriere, linkinbio, nutzfahrzeuge, pflege, praxis, restaurant, saas, shop, speisekarte, verein,
agentur, atelier, aurora, baukasten, beauty, cafe, coach, event, fitness, fotograf, handwerk, hotel, immobilien, kampagne,
kanzlei, karriere, linkinbio, monolith, nutzfahrzeuge, pflege, praxis, prisma, restaurant, saas, shop, speisekarte, verein,
}
// Schriftpaar einer anderen Vorlage ("vorlage:schlüssel"); alle Schriften laden erst bei Verwendung (preload: false)

View file

@ -0,0 +1,132 @@
import { unsplash } from "../../lib/content"
import { defineBuilder } from "../blocks/builder"
export const monolith = defineBuilder({
key: "monolith",
name: "Monolith",
industry: "Universell: Unternehmen & Marken",
description: "Dunkel und filmisch: ein Bildfenster öffnet sich zum Vollbild, Kapitel-Story mit wechselnden Bildern, aufziehende Paneele, Projekte im Querlauf und ein Schriftzug zum Schluss. Aus Bausteinen – frei änderbar.",
cover: unsplash("1506905925346-21bda4d32df4", 1200),
samples: {
inquiry: "Sehr geehrte Damen und Herren, wir planen die Neuentwicklung eines Bürogebäudes mit rund 12.000 m² in Frankfurt und suchen einen Partner für Entwicklung und Finanzierung. Wäre ein erstes Gespräch im kommenden Monat möglich? Mit freundlichen Grüßen, Dr. Martin Seidel",
topic: "Spatenstich für das Quartier Isarbogen",
},
palettes: [
{ key: "onyx", label: "Onyx", dark: true, bg: "#0b0b0c", fg: "#ede9e1", muted: "#8b867d", surface: "#161617", accent: "#c8a96a" },
{ key: "graphit", label: "Graphit", dark: true, bg: "#111315", fg: "#e8ebee", muted: "#8a9198", surface: "#1a1d20", accent: "#8fb3d9" },
{ key: "kreide", label: "Kreide", bg: "#f1eee8", fg: "#141413", muted: "#6d6a63", surface: "#e5e0d6", accent: "#8a6a3c" },
],
fonts: [
{ key: "kino", label: "Noto Serif Display & Host Grotesk" },
{ key: "hubot", label: "Hubot Sans & Host Grotesk" },
],
brand: {
name: "Valmont",
tagline: "Unternehmensgruppe seit 1952",
email: "kontakt@valmont-gruppe.de",
phone: "+49 89 2154 880",
address: "Maximilianstraße 12, 80539 München",
logo: "",
},
seed: [
{ type: "nav-leiste", data: { cta: "Kontakt", href: "#kontakt" } },
{
type: "hero-kino",
data: {
eyebrow: "Unternehmensgruppe · seit 1952",
title: "Werte, die\n*bleiben.*",
text: "Wir entwickeln, finanzieren und betreiben Projekte, die Generationen überdauern – mit Weitblick, Präzision und Haltung.",
cta: "Unsere Projekte",
href: "#projekte",
image: unsplash("1506905925346-21bda4d32df4", 2400),
},
},
{
type: "statement",
data: {
eyebrow: "Haltung",
text: "Seit drei Generationen glauben wir an eine einfache Idee: Qualität ist kein Zufall. Sie entsteht, wenn Menschen mit Geduld und Leidenschaft an etwas arbeiten, das größer ist als sie selbst.",
author: "Katharina Valmont",
role: "Vorstand in dritter Generation",
},
},
{
type: "story-kapitel",
data: {
menu: "Geschichte",
eyebrow: "Geschichte",
title: "Drei Generationen.\nEine *Haltung.*",
items: [
{ year: "1952", title: "Der Anfang", text: "Friedrich Valmont gründet in München ein kleines Bauunternehmen – mit zwölf Mitarbeitern und dem Anspruch, nur zu bauen, was hundert Jahre hält.", image: unsplash("1517048676732-d65bc937f952", 1400) },
{ year: "1987", title: "Der Aufbruch", text: "Die zweite Generation öffnet das Haus für neue Felder: Immobilienentwicklung, erste Beteiligungen und Standorte in ganz Europa.", image: unsplash("1511818966892-d7d671e672a2", 1400) },
{ year: "2024", title: "Die Zukunft", text: "Heute verbinden wir Erfahrung mit Mut: Energie aus erneuerbaren Quellen, Quartiere für morgen und eine Stiftung für junge Talente.", image: unsplash("1451187580459-43490279c0fa", 1400) },
],
},
},
{
type: "leistungen-panels",
data: {
menu: "Bereiche",
eyebrow: "Geschäftsbereiche",
title: "Vier Felder.\nEin *Anspruch.*",
items: [
{ title: "Immobilien", text: "Wir entwickeln Wohn- und Geschäftshäuser in besten Lagen – vom Grundstück bis zur Schlüsselübergabe.", image: unsplash("1600585154340-be6161a56a0c", 1600) },
{ title: "Energie", text: "Solar- und Windparks mit 480 Megawatt Leistung versorgen heute über 200.000 Haushalte mit sauberem Strom.", image: unsplash("1470071459604-3b5ec3a7fe05", 1600) },
{ title: "Beteiligungen", text: "Wir investieren langfristig in mittelständische Unternehmen mit Substanz und Zukunft.", image: unsplash("1486406146926-c627a92ad1ab", 1600) },
{ title: "Stiftung", text: "Die Valmont-Stiftung fördert jedes Jahr über 120 junge Talente in Handwerk, Technik und Kunst.", image: unsplash("1522071820081-009f0129c71c", 1600) },
],
},
},
{
type: "projekte-querlauf",
data: {
menu: "Projekte",
eyebrow: "Ausgewählte Projekte",
title: "Gebaut für\n*Generationen*",
items: [
{ title: "Haus am See", category: "Wohnen", year: "2025", image: unsplash("1613490493576-7fde63acd811", 1600) },
{ title: "Turm 7", category: "Büro", year: "2024", image: unsplash("1431576901776-e539bd916ba2", 1600) },
{ title: "Quartier Isarbogen", category: "Quartier", year: "2023", image: unsplash("1497366811353-6870744d04b2", 1600) },
{ title: "Villa Serena", category: "Wohnen", year: "2022", image: unsplash("1512917774080-9991f1c4c750", 1600) },
{ title: "Campus Nord", category: "Bildung", year: "2021", image: unsplash("1487958449943-2429e8be8625", 1600) },
],
},
},
{
type: "zahlen",
data: {
items: [
{ value: "70", suffix: "+", label: "Jahre Erfahrung" },
{ value: "1.200", suffix: "", label: "Mitarbeitende" },
{ value: "14", suffix: "", label: "Standorte in Europa" },
{ value: "2,4", suffix: " Mrd. €", label: "Projektvolumen" },
],
},
},
{
type: "stimmen-zitat",
data: {
eyebrow: "Stimmen",
items: [
{ quote: "Valmont denkt in Jahrzehnten, nicht in Quartalen. Genau diese Haltung hat unser Projekt zu etwas Besonderem gemacht.", author: "Dr. Thomas Berger", role: "Geschäftsführer, Berger Holding" },
{ quote: "Ein Partner, der Wort hält – vom ersten Handschlag bis zur Übergabe. Termingerecht, transparent und mit beeindruckender Qualität.", author: "Sabine Krüger", role: "Vorständin, Krüger Logistik AG" },
{ quote: "Selten erlebt man so viel Sorgfalt im Detail und so viel Weitblick im Ganzen.", author: "Prof. Elena Hartwig", role: "Architektin" },
],
},
},
{
type: "kontakt-formular",
data: {
eyebrow: "Kontakt",
title: "Lassen Sie uns\n*sprechen.*",
text: "Ob Projektidee, Partnerschaft oder Presseanfrage – wir freuen uns auf das Gespräch.",
topics: "Projekt, Partnerschaft, Karriere, Presse",
success: "Vielen Dank. Wir melden uns innerhalb von zwei Werktagen.",
hours: "Mo–Fr, 9–18 Uhr",
},
},
{ type: "footer-wortmarke", data: { text: "Werte, die bleiben – seit 1952.", cta: "Kontakt aufnehmen", href: "#kontakt" } },
],
})
export type MonolithData = typeof monolith.defaults

View file

@ -0,0 +1,4 @@
import type { FontPair } from "../../lib/types"
import { pairs } from "../blocks/fonts"
export const fonts: Record<string, FontPair> = { kino: pairs.kino, hubot: pairs.hubot }

View file

@ -0,0 +1,10 @@
"use client"
import type { TemplateProps } from "../../lib/types"
import { BlockPage } from "../blocks/page"
import { monolith, type MonolithData } from "./definition"
import { fonts } from "./fonts"
export function MonolithTemplate({ content }: TemplateProps<MonolithData>) {
return <BlockPage def={monolith} content={content} fonts={fonts[content.fonts] ?? fonts.kino} />
}

View file

@ -0,0 +1,128 @@
import { unsplash } from "../../lib/content"
import { defineBuilder } from "../blocks/builder"
export const prisma = defineBuilder({
key: "prisma",
name: "Prisma",
industry: "Universell: Studio & Portfolio",
description: "Kinetisch im Swiss-Stil: Schriftzug, der auf die Maus reagiert, Laufband mit Scroll-Tempo, Projekt-Index mit Bildspur, 3D-Karten und ein drehbares 3D-Karussell. Aus Bausteinen – frei änderbar.",
cover: unsplash("1487958449943-2429e8be8625", 1200),
samples: {
inquiry: "Hallo Forma-Team, wir haben ein Grundstück am Stadtrand von Köln gekauft und möchten ein Einfamilienhaus mit viel Licht und Holz bauen lassen. Übernehmen Sie auch die Bauleitung, und wie hoch sind Ihre Honorare ungefähr? Viele Grüße, Nina und Tim",
topic: "Wie Tageslicht Räume verändert – fünf Prinzipien aus unserer Praxis",
},
palettes: [
{ key: "weiss", label: "Weiß", bg: "#f4f4f1", fg: "#0d0d0d", muted: "#6b6b68", surface: "#e7e7e2", accent: "#ff4d1a" },
{ key: "schwarz", label: "Schwarz", dark: true, bg: "#0a0a0a", fg: "#f2f2f0", muted: "#7c7c78", surface: "#161616", accent: "#d7ff3a" },
{ key: "blau", label: "Blau", bg: "#eceff7", fg: "#0b1030", muted: "#5b6280", surface: "#dde2f0", accent: "#2342ff" },
],
fonts: [
{ key: "mona", label: "Mona Sans (variabel)" },
{ key: "anybody", label: "Anybody & Host Grotesk" },
],
brand: {
name: "Forma",
tagline: "Büro für Architektur und Raum",
email: "studio@forma-architektur.de",
phone: "+49 221 9934 120",
address: "Ehrenstraße 42, 50672 Köln",
logo: "",
},
seed: [
{ type: "nav-leiste", data: { cta: "Projekt anfragen", href: "#kontakt" } },
{
type: "hero-kinetik",
data: {
eyebrow: "Architektur · Innenraum · Städtebau",
word: "FORMA",
text: "Wir gestalten Räume, die Menschen bewegen – präzise im Detail, mutig im Ganzen.",
cta: "Projekte",
href: "#projekte",
image: unsplash("1479839672679-a46483c0e7c8", 1600),
},
},
{ type: "laufband", data: { text: "Raum · Licht · Form · Material ·", secondary: "Architektur für Menschen ·" } },
{
type: "projekte-index",
data: {
eyebrow: "Index",
title: "Ausgewählte *Projekte*",
items: [
{ title: "Haus Weiß", location: "Köln", category: "Wohnen", year: "2026", image: unsplash("1487958449943-2429e8be8625", 800) },
{ title: "Pavillon Rhein", location: "Düsseldorf", category: "Kultur", year: "2025", image: unsplash("1518005020951-eccb494ad742", 800) },
{ title: "Atelierhaus 12", location: "Berlin", category: "Arbeiten", year: "2024", image: unsplash("1600047509807-ba8f99d2cdde", 800) },
{ title: "Villa Sol", location: "Mallorca", category: "Wohnen", year: "2023", image: unsplash("1512917774080-9991f1c4c750", 800) },
{ title: "Studio Lumen", location: "Hamburg", category: "Innenraum", year: "2022", image: unsplash("1517705008128-361805f42e86", 800) },
{ title: "Galerie Nord", location: "Aarhus", category: "Kultur", year: "2021", image: unsplash("1511818966892-d7d671e672a2", 800) },
],
},
},
{
type: "leistungen-karten",
data: {
eyebrow: "Leistungen",
title: "Was wir *tun.*",
items: [
{ title: "Architektur", text: "Neubau und Umbau – vom ersten Entwurf bis zur Schlüsselübergabe.", features: "Entwurf & Genehmigung\nAusführungsplanung\nBauleitung" },
{ title: "Innenarchitektur", text: "Räume, die sich nach Ihnen anfühlen.", features: "Raumkonzept & Licht\nMaterial & Möbel\nMaßanfertigungen" },
{ title: "Städtebau", text: "Quartiere, in denen man gerne lebt.", features: "Machbarkeitsstudien\nWettbewerbe\nBürgerbeteiligung" },
{ title: "Beratung", text: "Klarheit, bevor der erste Stein gesetzt ist.", features: "Grundstücksanalyse\nKosten & Termine\nNachhaltigkeit" },
],
},
},
{
type: "galerie-karussell",
data: {
eyebrow: "Galerie",
title: "Räume in *Bewegung.*",
images: [
{ src: unsplash("1487958449943-2429e8be8625", 900), caption: "Haus Weiß, Köln" },
{ src: unsplash("1518005020951-eccb494ad742", 900), caption: "Pavillon Rhein, Düsseldorf" },
{ src: unsplash("1479839672679-a46483c0e7c8", 900), caption: "Kubus, Köln" },
{ src: unsplash("1613490493576-7fde63acd811", 900), caption: "Haus am Wasser, Bonn" },
{ src: unsplash("1517705008128-361805f42e86", 900), caption: "Studio Lumen, Hamburg" },
{ src: unsplash("1512917774080-9991f1c4c750", 900), caption: "Villa Sol, Mallorca" },
{ src: unsplash("1592078615290-033ee584e267", 900), caption: "Stuhl S1, Eigenentwurf" },
{ src: unsplash("1581539250439-c96689b516dd", 900), caption: "Hocker H3, Eigenentwurf" },
],
},
},
{
type: "zahlen",
data: {
items: [
{ value: "48", suffix: "", label: "realisierte Projekte" },
{ value: "11", suffix: "", label: "Auszeichnungen" },
{ value: "16", suffix: "", label: "Menschen im Büro" },
{ value: "15", suffix: "", label: "Jahre Erfahrung" },
],
},
},
{
type: "team",
data: {
title: "Die *Köpfe.*",
text: "Architektinnen, Planer und Gestalterinnen mit einer gemeinsamen Haltung: Räume für Menschen.",
members: [
{ name: "Lea Hoffmann", role: "Gründerin & Architektin", image: unsplash("1534528741775-53994a69daeb", 700) },
{ name: "Jonas Richter", role: "Partner, Architekt", image: unsplash("1506794778202-cad84cf45f1d", 700) },
{ name: "Amara Diallo", role: "Innenarchitektin", image: unsplash("1531123897727-8f129e1688ce", 700) },
{ name: "Paul Schneider", role: "Städtebau", image: unsplash("1539571696357-5a69c17a67c6", 700) },
],
},
},
{
type: "kontakt-formular",
data: {
title: "Neues Projekt?\n*Erzählen* Sie.",
text: "Ob Neubau, Umbau oder Innenraum – wir freuen uns auf Ihr Vorhaben.",
topics: "Neubau, Umbau, Innenraum, Wettbewerb",
button: "Projekt anfragen",
success: "Danke! Wir melden uns innerhalb von zwei Werktagen.",
},
},
{ type: "footer-schlicht", data: { city: "Köln", note: "Architektur für Menschen." } },
],
})
export type PrismaData = typeof prisma.defaults

View file

@ -0,0 +1,4 @@
import type { FontPair } from "../../lib/types"
import { pairs } from "../blocks/fonts"
export const fonts: Record<string, FontPair> = { mona: pairs.mona, anybody: pairs.anybody }

View file

@ -0,0 +1,10 @@
"use client"
import type { TemplateProps } from "../../lib/types"
import { BlockPage } from "../blocks/page"
import { prisma, type PrismaData } from "./definition"
import { fonts } from "./fonts"
export function PrismaTemplate({ content }: TemplateProps<PrismaData>) {
return <BlockPage def={prisma} content={content} fonts={fonts[content.fonts] ?? fonts.mona} />
}

View file

@ -295,10 +295,12 @@ export function Accordion({ items, className, itemClassName, questionClassName,
)
}
// Vertikales Scrollen schiebt eine Bildreihe horizontal durch (Sektion bleibt dabei stehen)
export function HorizontalScroll({ children, header, className, trackClassName }: {
// Vertikales Scrollen schiebt eine Bildreihe horizontal durch (Sektion bleibt dabei stehen).
// `footer` bekommt den Fortschritt (0–1), z. B. für eine Fortschrittsleiste unter der Reihe.
export function HorizontalScroll({ children, header, footer, className, trackClassName }: {
children: ReactNode
header?: ReactNode
footer?: (progress: MotionValue<number>) => ReactNode
className?: string
trackClassName?: string
}) {
@ -330,6 +332,7 @@ export function HorizontalScroll({ children, header, className, trackClassName }
<motion.div ref={trackRef} style={{ x }} className={cn("flex w-max", trackClassName)}>
{children}
</motion.div>
{footer?.(scrollYProgress)}
</div>
</section>
)

View file

@ -184,10 +184,12 @@ export async function buildProject(def: TemplateDefinition, content: TemplateCon
)
const title = brandName(content) ?? def.name
const hero = content.data.hero
const description = hero && typeof hero.text === "string" ? plain(hero.text) : ""
// Baukasten-Seiten haben kein festes „hero“: dann der Text des ersten Start-Bausteins, sonst der Untertitel
const hero = content.data.hero ?? content.blocks.find((block) => !block.hidden && block.type.startsWith("hero-"))?.data
const heroText = hero && typeof hero.text === "string" ? hero.text : content.data.brand?.tagline
const description = typeof heroText === "string" ? plain(heroText) : ""
const theme = themeColors(def, content)
const unsplash = JSON.stringify(content.data).includes("images.unsplash.com")
const unsplash = JSON.stringify([content.data, content.blocks]).includes("images.unsplash.com")
// Ohne KI-Einstellungen: Chat & Co. brauchen das Portal-Backend
const site = {
palette: content.palette,
@ -197,6 +199,7 @@ export async function buildProject(def: TemplateDefinition, content: TemplateCon
order: content.order,
style: content.style,
data: content.data,
...(def.builder ? { blocks: content.blocks } : {}),
}
const project: Record<string, string> = {
@ -355,6 +358,9 @@ export default config;
"- `hidden` blendet optionale Sektionen aus, `order` legt ihre Reihenfolge fest.",
"- `palette` und `fonts` wählen Farbpalette und Schriftpaar (siehe `definition.ts`), `colors` überschreibt einzelne Farben (`#rrggbb`).",
"- `style.images` (original, mono, warm, soft, contrast) und `style.motion` (full, subtle) steuern Bildstil und Animationen.",
...(def.builder
? ["- `blocks` ist die Liste der Bausteine in Seitenreihenfolge (`type`, `hidden`, `data`); verfügbare Typen und Felder stehen in `site/catalog/blocks/definitions.ts`."]
: []),
"",
"## Aufbau",
"",
@ -385,7 +391,7 @@ export default config;
const zip: Zippable = {}
for (const [file, source] of Object.entries(project)) zip[`${slug}/${file}`] = strToU8(source)
for (const image of new Set(localImages(content.data))) {
for (const image of new Set(localImages([content.data, content.blocks]))) {
zip[`${slug}/public${IMAGE_PREFIX}${image}`] = [new Uint8Array(await readFile(path.join(IMAGE_DIR, image))), { level: 0 }]
}

View file

@ -1,9 +1,11 @@
import type { AiSettings, ColorOverrides, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition, TemplateStyle } from "./types"
import type { AiSettings, Block, BlockDefinition, ColorOverrides, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition, TemplateStyle } from "./types"
const MAX_TEXT = 4000
const MAX_ITEMS = 40
const MAX_BLOCKS = 40
const HEX_COLOR = /^#[0-9a-f]{6}$/i
const LIBRARY_KEY = /^[a-z0-9]+:[a-z0-9]+$/
const BLOCK_ID = /^[a-z0-9-]{1,40}$/i
export const IMAGE_STYLES: TemplateStyle["images"][] = ["original", "mono", "warm", "soft", "contrast"]
function libraryKey(value: unknown, own: { key: string }[]) {
@ -51,6 +53,10 @@ const FIELD_LABELS: Record<string, string> = {
training: "Training",
logo: "Logo (Bild-Link; leer = Name als Text)", link: "Externer Link (z. B. mobile.de)", linkLabel: "Text für den Link",
values: "Werte (mit Komma trennen)", steps: "Schritte (mit Komma trennen)",
menu: "Menüpunkt (leer = nicht im Menü)", href: "Ziel des Buttons (#abschnitt, mailto:… oder https://…)",
hrefSecondary: "Ziel des zweiten Buttons", keywords: "Schlagworte (mit Komma trennen)", word: "Schriftzug (leer = Name)",
edition: "Kopfzeile", topics: "Themen zur Auswahl (mit Komma trennen)", secondary: "Zweite Zeile (leer = keine)",
ribbon: "Hervorhebung (z. B. Beliebt; leer = keine)",
items: "Einträge", plans: "Tarife", members: "Personen", questions: "Fragen", milestones: "Meilensteine", facts: "Kennzahlen",
}
@ -72,25 +78,36 @@ function describeField(key: string, sample: string): FieldDef {
type SectionMeta = string | { label: string; optional?: boolean; items?: Record<string, string> }
// Felder einer Sektion aus ihren Standardinhalten; `items` benennt die Einträge von Listen
function describeFields(section: SectionData, items?: Record<string, string>): (FieldDef | ListFieldDef)[] {
return 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: items?.[fieldKey] ?? "Eintrag",
fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)),
}
})
}
// 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 }
return { key, label: info.label, optional: info.optional, fields: describeFields(section, info.items) }
})
}
// Baustein für den Baukasten; Felder werden wie bei describeSections aus den Standardinhalten abgeleitet.
// Listen brauchen mindestens einen Eintrag, er gibt die Felder neuer Einträge vor.
export function defineBlock<D extends SectionData>(block: Omit<BlockDefinition<D>, "fields"> & { items?: Record<string, string> }) {
const { items, ...rest } = block
return { ...rest, fields: describeFields(block.defaults, items) } satisfies BlockDefinition<D>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@ -121,6 +138,30 @@ function mergeSection(defaults: SectionData, input: unknown): SectionData {
return result
}
// Bausteine gegen die Bibliothek auflösen: unbekannte Typen fallen weg, Inhalte wie bei Sektionen.
// Ohne gespeicherte Liste (neue Variante) gilt die Startaufstellung der Vorlage.
function resolveBlocks(builder: NonNullable<TemplateDefinition["builder"]>, raw: unknown): Block[] {
const library = new Map(builder.library.map((block) => [block.type, block]))
const source: unknown[] = Array.isArray(raw) ? raw : builder.seed.map((seed, i) => ({ id: `b${i + 1}`, ...seed }))
const used = new Set<string>()
const blocks: Block[] = []
for (const item of source.slice(0, MAX_BLOCKS)) {
if (!isRecord(item)) continue
const block = typeof item.type === "string" ? library.get(item.type) : undefined
if (!block) continue
let id = typeof item.id === "string" && BLOCK_ID.test(item.id) && !used.has(item.id) ? item.id : ""
for (let n = blocks.length + 1; !id; n++) {
if (!used.has(`b${n}`)) id = `b${n}`
}
used.add(id)
blocks.push({ id, type: block.type, hidden: item.hidden === true, data: mergeSection(block.defaults, item.data) })
}
return blocks
}
// 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> {
@ -165,6 +206,7 @@ export function resolveContent<D extends TemplateData>(def: TemplateDefinition<D
data: Object.fromEntries(
Object.entries(def.defaults).map(([key, section]) => [key, mergeSection(section, data[key])])
) as D,
blocks: def.builder ? resolveBlocks(def.builder, source.blocks) : [],
}
}

View file

@ -1,6 +1,8 @@
// 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"
// Baukasten: Vorschau scrollt zum Baustein, der im Editor gerade bearbeitet wird
export const PREVIEW_FOCUS = "since3:preview-focus"
export const DEVICES = {
desktop: { label: "Desktop", width: 1440 },

View file

@ -1,3 +1,6 @@
import { atelier } from "../catalog/atelier/definition"
import { aurora } from "../catalog/aurora/definition"
import { baukasten } from "../catalog/baukasten/definition"
import { beauty } from "../catalog/beauty/definition"
import { coach } from "../catalog/coach/definition"
import { event } from "../catalog/event/definition"
@ -19,11 +22,18 @@ import { shop } from "../catalog/shop/definition"
import { agentur } from "../catalog/agentur/definition"
import { verein } from "../catalog/verein/definition"
import { linkinbio } from "../catalog/linkinbio/definition"
import { monolith } from "../catalog/monolith/definition"
import { prisma } from "../catalog/prisma/definition"
import { speisekarte } from "../catalog/speisekarte/definition"
import type { TemplateDefinition } from "./types"
// Reihenfolge = Reihenfolge im Admin
// Reihenfolge = Reihenfolge im Admin; Baukasten-Vorlagen (frei aus Bausteinen) zuerst
export const templates: TemplateDefinition[] = [
baukasten,
monolith,
aurora,
atelier,
prisma,
restaurant,
handwerk,
beauty,

View file

@ -43,6 +43,23 @@ export type ListItem = Record<string, string>
export type SectionData = Record<string, string | ListItem[]>
export type TemplateData = Record<string, SectionData>
// Baustein des Baukastens: eine frei platzierbare Sektion mit eigenen Feldern (catalog/blocks)
export type BlockDefinition<D extends SectionData = SectionData> = {
type: string
label: string
category: string
description: string
// Inhalte beim Hinzufügen; legen zugleich die erlaubte Struktur fest
defaults: D
fields: (FieldDef | ListFieldDef)[]
}
// Startaufstellung einer Baukasten-Vorlage; `data` überschreibt einzelne Standardinhalte des Bausteins
export type BlockSeed = { type: string; data?: SectionData }
// Platzierter Baustein einer Variante
export type Block = { id: string; type: string; hidden: boolean; data: SectionData }
export type TemplateDefinition<D extends TemplateData = TemplateData> = {
key: string
name: string
@ -56,6 +73,8 @@ export type TemplateDefinition<D extends TemplateData = TemplateData> = {
defaults: D
// Vorbelegung der KI-Demos (Angebots-Assistent, Content-Generator)
samples: { inquiry: string; topic: string }
// Baukasten: Seite besteht aus frei kombinierbaren Bausteinen statt aus festen Sektionen
builder?: { library: BlockDefinition[]; seed: BlockSeed[] }
}
// KI-Funktionen einer Variante; Wissen kommt aus den Inhalten plus `knowledge`
@ -87,6 +106,8 @@ export type TemplateContent<D extends TemplateData = TemplateData> = {
style: TemplateStyle
ai: AiSettings
data: D
// Nur Baukasten-Vorlagen, sonst leer
blocks: Block[]
}
export type TemplateProps<D extends TemplateData> = { content: TemplateContent<D> }

View file

@ -0,0 +1,297 @@
"use client"
import Image from "next/image"
import { useState } from "react"
import { BlocksIcon, ChevronDownIcon, ChevronUpIcon, CopyIcon, EyeIcon, EyeOffIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { Block, BlockDefinition, ListItem } from "../../../lib/types"
import { FieldInput, ListEditor } from "./content-panel"
// Gleiche Grenze wie resolveContent (lib/content.ts)
const MAX_BLOCKS = 40
const newId = () => crypto.randomUUID().slice(0, 8)
// Woran man den Baustein in der Liste wiedererkennt: Überschrift, Schriftzug oder erster Text
function summary(block: Block) {
const value = [block.data.title, block.data.word, block.data.text, block.data.cta].find((entry) => typeof entry === "string" && entry.trim())
return typeof value === "string" ? value.replace(/\*/g, "").replace(/\s+/g, " ").trim() : ""
}
// Baukasten im Tab „Inhalte“: Bausteine hinzufügen, sortieren, ausblenden, duplizieren, entfernen und bearbeiten.
// Aufklappen meldet den Baustein an die Vorschau (onFocus), damit sie dorthin scrollt.
export function BlocksPanel({ library, blocks, onChange, onFocus }: {
library: BlockDefinition[]
blocks: Block[]
onChange: (blocks: Block[]) => void
onFocus: (id: string) => void
}) {
const [open, setOpen] = useState<string | null>(null)
const [insertAt, setInsertAt] = useState<number | null>(null)
const byType = new Map(library.map((block) => [block.type, block]))
const full = blocks.length >= MAX_BLOCKS
function setField(id: string, field: string, value: string | ListItem[]) {
onChange(blocks.map((block) => (block.id === id ? { ...block, data: { ...block.data, [field]: value } } : block)))
}
function move(index: number, offset: number) {
const next = [...blocks]
const [block] = next.splice(index, 1)
next.splice(index + offset, 0, block)
onChange(next)
onFocus(block.id)
}
function insert(block: Block, at: number) {
onChange([...blocks.slice(0, at), block, ...blocks.slice(at)])
setOpen(block.id)
onFocus(block.id)
}
function add(type: string) {
const def = byType.get(type)
if (def && insertAt !== null) insert({ id: newId(), type, hidden: false, data: structuredClone(def.defaults) }, insertAt)
setInsertAt(null)
}
function remove(block: Block) {
const label = byType.get(block.type)?.label ?? "Baustein"
if (window.confirm(`„${label}“ entfernen? Die Inhalte dieses Bausteins gehen verloren.`)) {
onChange(blocks.filter((entry) => entry.id !== block.id))
}
}
return (
<section className="mt-6 flex flex-col">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-medium">Bausteine</h3>
<span className="text-xs text-muted-foreground">{blocks.length} auf der Seite</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">Aufklappen zum Bearbeiten – die Vorschau springt mit. Zwischen zwei Bausteinen erscheint beim Darüberfahren ein Plus zum Einfügen.</p>
<div className="mt-2 flex flex-col">
{blocks.map((block, index) => {
const def = byType.get(block.type)
if (!def) return null
const hint = summary(block)
return (
<div key={block.id}>
<InsertHere disabled={full} onClick={() => setInsertAt(index)} />
<Collapsible
open={open === block.id}
onOpenChange={(next) => {
setOpen(next ? block.id : null)
if (next) onFocus(block.id)
}}
className={cn("rounded-lg border bg-card transition-opacity", block.hidden && "opacity-55")}
>
<div className="flex items-center gap-0.5 pr-1">
<div className="flex flex-col pl-1">
<button
type="button"
aria-label={`${def.label} nach oben`}
disabled={index === 0}
onClick={() => move(index, -1)}
className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30"
>
<ChevronUpIcon className="size-3.5" />
</button>
<button
type="button"
aria-label={`${def.label} nach unten`}
disabled={index === blocks.length - 1}
onClick={() => move(index, 1)}
className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30"
>
<ChevronDownIcon className="size-3.5" />
</button>
</div>
<CollapsibleTrigger className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-2.5 pr-1 pl-1.5 text-left [&[data-panel-open]>svg]:rotate-180">
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm font-medium">
{def.label}
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">{def.category}</span>
{block.hidden && <span className="text-[11px] font-normal text-muted-foreground">ausgeblendet</span>}
</span>
{hint && <span className="mt-0.5 block truncate text-xs text-muted-foreground">{hint}</span>}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform" />
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-sm"
aria-label={block.hidden ? `${def.label} einblenden` : `${def.label} ausblenden`}
title={block.hidden ? "Einblenden" : "Ausblenden"}
onClick={() => onChange(blocks.map((entry) => (entry.id === block.id ? { ...entry, hidden: !entry.hidden } : entry)))}
>
{block.hidden ? <EyeOffIcon /> : <EyeIcon />}
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`${def.label} duplizieren`} title="Duplizieren" disabled={full} onClick={() => insert({ ...block, id: newId(), data: structuredClone(block.data) }, index + 1)}>
<CopyIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`${def.label} entfernen`} title="Entfernen" onClick={() => remove(block)}>
<Trash2Icon />
</Button>
</div>
<CollapsibleContent>
<div className="flex flex-col gap-4 border-t px-3 py-4">
{def.fields.map((field) =>
field.type === "list" ? (
<ListEditor
key={field.key}
field={field}
items={(block.data[field.key] as ListItem[] | undefined) ?? []}
onChange={(items) => setField(block.id, field.key, items)}
/>
) : (
<FieldInput
key={field.key}
field={field}
value={(block.data[field.key] as string | undefined) ?? ""}
onChange={(value) => setField(block.id, field.key, value)}
/>
)
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
})}
</div>
{blocks.length === 0 && (
<p className="mt-2 rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">Noch leer – füge den ersten Baustein hinzu, zum Beispiel eine Navigation.</p>
)}
<Button variant="outline" disabled={full} onClick={() => setInsertAt(blocks.length)} className="mt-3">
<PlusIcon /> Baustein hinzufügen
</Button>
{full && <p className="mt-2 text-xs text-muted-foreground">Maximal {MAX_BLOCKS} Bausteine pro Seite.</p>}
<BlockPicker library={library} open={insertAt !== null} onClose={() => setInsertAt(null)} onPick={add} />
</section>
)
}
function InsertHere({ disabled, onClick }: { disabled: boolean; onClick: () => void }) {
return (
<div className="group/insert relative flex h-3 items-center justify-center">
<span aria-hidden className="absolute inset-x-6 top-1/2 h-px bg-border opacity-0 transition-opacity group-hover/insert:opacity-100" />
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label="Hier einen Baustein einfügen"
className="relative z-10 grid size-5 cursor-pointer place-items-center rounded-full border bg-background text-muted-foreground opacity-0 transition-opacity group-hover/insert:opacity-100 hover:text-foreground focus-visible:opacity-100 disabled:hidden"
>
<PlusIcon className="size-3" />
</button>
</div>
)
}
// Vorschaubild je Baustein (public/pictures/blocks/<typ>.webp); fehlt es, erscheint ein Symbol
function BlockThumb({ type }: { type: string }) {
const [failed, setFailed] = useState(false)
return (
<span className="relative block aspect-[16/10] overflow-hidden border-b bg-surface">
{failed ? (
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
<BlocksIcon className="size-6" />
</span>
) : (
<Image
src={`/pictures/blocks/${type}.webp`}
alt=""
fill
sizes="(min-width: 1024px) 280px, (min-width: 640px) 45vw, 90vw"
onError={() => setFailed(true)}
className="object-cover object-top transition-transform duration-500 group-hover:scale-[1.03]"
/>
)}
</span>
)
}
function BlockPicker({ library, open, onClose, onPick }: {
library: BlockDefinition[]
open: boolean
onClose: () => void
onPick: (type: string) => void
}) {
const [category, setCategory] = useState("Alle")
const [query, setQuery] = useState("")
const categories = ["Alle", ...new Set(library.map((block) => block.category))]
const term = query.trim().toLowerCase()
const visible = library.filter(
(block) =>
(category === "Alle" || block.category === category) &&
(!term || `${block.label} ${block.category} ${block.description}`.toLowerCase().includes(term))
)
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onClose()
}}
>
<DialogContent className="flex max-h-[min(880px,calc(100dvh-2rem))] flex-col gap-4 overflow-hidden p-0 sm:max-w-5xl">
<DialogHeader className="px-5 pt-5">
<DialogTitle>Baustein hinzufügen</DialogTitle>
<DialogDescription>Jeder Baustein übernimmt Farben und Schriften der Seite und lässt sich mit allen anderen kombinieren.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 px-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-1">
{categories.map((option) => (
<button
key={option}
type="button"
aria-pressed={category === option}
onClick={() => setCategory(option)}
className={cn(
"cursor-pointer rounded-md px-2.5 py-1 text-xs transition-colors",
category === option ? "bg-surface font-medium text-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
{option}
</button>
))}
</div>
<label className="relative shrink-0 lg:w-60">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Suchen, z. B. Galerie" aria-label="Bausteine durchsuchen" className="pl-9" />
</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((block) => (
<button
key={block.type}
type="button"
onClick={() => onPick(block.type)}
className="group flex cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left outline-none transition-[border-color,box-shadow] hover:border-foreground/30 hover:shadow-md focus-visible:ring-3 focus-visible:ring-ring/50"
>
<BlockThumb type={block.type} />
<span className="flex flex-col gap-1 p-3">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{block.label}
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">{block.category}</span>
</span>
<span className="text-xs leading-relaxed text-muted-foreground">{block.description}</span>
</span>
</button>
))}
</div>
{visible.length === 0 && <p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">Kein passender Baustein gefunden.</p>}
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -20,7 +20,8 @@ export function ContentPanel({ def, data, hidden, onChange }: {
return (
<div className="flex flex-col gap-2">
{def.sections.map((section, index) => (
<Collapsible key={section.key} defaultOpen={index === 0} className="rounded-lg border">
// Im Baukasten stehen die Bausteine im Vordergrund – „Marke & Kontakt“ startet zugeklappt
<Collapsible key={section.key} defaultOpen={index === 0 && !def.builder} 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}
@ -57,7 +58,7 @@ export function ContentPanel({ def, data, hidden, onChange }: {
)
}
function FieldInput({ field, value, onChange }: { field: FieldDef; value: string; onChange: (value: string) => void }) {
export function FieldInput({ field, value, onChange }: { field: FieldDef; value: string; onChange: (value: string) => void }) {
const id = useId()
return (
@ -82,7 +83,7 @@ function FieldInput({ field, value, onChange }: { field: FieldDef; value: string
)
}
function ListEditor({ field, items, onChange }: { field: ListFieldDef; items: ListItem[]; onChange: (items: ListItem[]) => void }) {
export 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)))
}

View file

@ -190,33 +190,36 @@ export function DesignPanel({ def, content, onChange }: { def: TemplateDefinitio
</div>
</section>
<section>
<Heading title="Sektionen" text="Reihenfolge per Pfeil ändern. Ausgeblendete Sektionen behalten ihre Inhalte." />
<div className="flex flex-col divide-y rounded-lg border">
{optional.map((section, index) => (
<div key={section.key} className="flex items-center gap-1 pl-1">
<div className="flex flex-col">
<button type="button" aria-label={`${section.label} nach oben`} disabled={index === 0} onClick={() => move(index, -1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
<ChevronUpIcon className="size-3.5" />
</button>
<button type="button" aria-label={`${section.label} nach unten`} disabled={index === optional.length - 1} onClick={() => move(index, 1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
<ChevronDownIcon className="size-3.5" />
</button>
{/* Baukasten-Vorlagen haben keine optionalen Sektionen; Reihenfolge und Sichtbarkeit steuern dort die Bausteine (Tab „Inhalte“) */}
{optional.length > 0 && (
<section>
<Heading title="Sektionen" text="Reihenfolge per Pfeil ändern. Ausgeblendete Sektionen behalten ihre Inhalte." />
<div className="flex flex-col divide-y rounded-lg border">
{optional.map((section, index) => (
<div key={section.key} className="flex items-center gap-1 pl-1">
<div className="flex flex-col">
<button type="button" aria-label={`${section.label} nach oben`} disabled={index === 0} onClick={() => move(index, -1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
<ChevronUpIcon className="size-3.5" />
</button>
<button type="button" aria-label={`${section.label} nach unten`} disabled={index === optional.length - 1} onClick={() => move(index, 1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
<ChevronDownIcon className="size-3.5" />
</button>
</div>
<div className="min-w-0 flex-1">
<Toggle
checked={!content.hidden.includes(section.key)}
onChange={(visible) =>
onChange({ hidden: visible ? content.hidden.filter((key) => key !== section.key) : [...content.hidden, section.key] })
}
>
{section.label}
</Toggle>
</div>
</div>
<div className="min-w-0 flex-1">
<Toggle
checked={!content.hidden.includes(section.key)}
onChange={(visible) =>
onChange({ hidden: visible ? content.hidden.filter((key) => key !== section.key) : [...content.hidden, section.key] })
}
>
{section.label}
</Toggle>
</div>
</div>
))}
</div>
</section>
))}
</div>
</section>
)}
</div>
)
}

View file

@ -3,14 +3,20 @@
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 { DEVICES, PREVIEW_CONTENT, PREVIEW_FOCUS, 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 }) {
// `focus` (Baukasten): Baustein, zu dem die Vorschau scrollen soll; `n` erlaubt erneutes Springen zum selben Baustein.
export function PreviewFrame({ templateKey, variantId, content, focus }: {
templateKey: string
variantId: string
content: TemplateContent
focus?: { id: string; n: number } | null
}) {
const frameRef = useRef<HTMLIFrameElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
@ -24,6 +30,15 @@ export function PreviewFrame({ templateKey, variantId, content }: { templateKey:
post()
}, [content])
useEffect(() => {
if (!focus) return
// Kurz warten, damit ein gerade hinzugefügter Baustein in der Vorschau schon gerendert ist
const timer = setTimeout(() => {
frameRef.current?.contentWindow?.postMessage({ type: PREVIEW_FOCUS, id: focus.id }, window.location.origin)
}, 120)
return () => clearTimeout(timer)
}, [focus])
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin === window.location.origin && event.source === frameRef.current?.contentWindow && event.data?.type === PREVIEW_READY) {

View file

@ -3,11 +3,25 @@
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 { PREVIEW_CONTENT, PREVIEW_FOCUS, PREVIEW_READY } from "../../lib/preview"
import { getTemplate } from "../../lib/registry"
import type { TemplateContent } from "../../lib/types"
import { TemplateRenderer } from "./template-renderer"
// Baukasten: zum Baustein scrollen und ihn kurz umranden, damit man sieht, was man gerade bearbeitet
function focusBlock(id: string) {
const element = document.querySelector<HTMLElement>(`[data-block="${CSS.escape(id)}"]`)
if (!element) return
element.scrollIntoView({ behavior: "smooth", block: "start" })
element.animate(
[
{ outline: "3px solid rgb(99 102 241 / 0.9)", outlineOffset: "-3px" },
{ outline: "3px solid rgb(99 102 241 / 0)", outlineOffset: "-3px" },
],
{ duration: 1600, easing: "ease-out" }
)
}
// Läuft im iframe des Editors und übernimmt ungespeicherte Änderungen per postMessage
export function LivePreview({ templateKey, initialContent, access }: {
templateKey: string
@ -21,8 +35,9 @@ export function LivePreview({ templateKey, initialContent, access }: {
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))
if (event.origin !== window.location.origin || !def) return
if (event.data?.type === PREVIEW_FOCUS && typeof event.data.id === "string") focusBlock(event.data.id)
if (event.data?.type === PREVIEW_CONTENT) setContent(resolveContent(def, event.data.content))
}
window.addEventListener("message", onMessage)

View file

@ -2,23 +2,27 @@
import Image from "next/image"
import { useState } from "react"
import { EyeIcon, SearchIcon } from "lucide-react"
import { BlocksIcon, EyeIcon, SearchIcon } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { templates } from "../../lib/registry"
import type { TemplateDefinition } from "../../lib/types"
import { CreateVariantButton } from "./create-variant-button"
// Seiten-Typen für bestimmte Situationen, alles andere sind Branchen-Webseiten
// Seiten-Typen für bestimmte Situationen, Baukasten-Vorlagen sind frei aus Bausteinen, alles andere sind Branchen-Webseiten
const PAGE_TYPES = new Set(["karriere", "kampagne", "event", "shop", "linkinbio", "speisekarte"])
const GROUPS = ["Alle", "Branchen", "Seiten-Typen"] as const
const GROUPS = ["Alle", "Baukasten", "Branchen", "Seiten-Typen"] as const
const groupOf = (template: TemplateDefinition): (typeof GROUPS)[number] =>
template.builder ? "Baukasten" : PAGE_TYPES.has(template.key) ? "Seiten-Typen" : "Branchen"
export function TemplateGrid() {
const [query, setQuery] = useState("")
const [group, setGroup] = useState<(typeof GROUPS)[number]>("Alle")
const term = query.trim().toLowerCase()
const visible = templates.filter((template) => {
const inGroup = group === "Alle" || (group === "Seiten-Typen") === PAGE_TYPES.has(template.key)
const inGroup = group === "Alle" || groupOf(template) === group
const text = `${template.name} ${template.industry} ${template.description}`.toLowerCase()
return inGroup && (!term || text.includes(term))
})
@ -58,6 +62,11 @@ export function TemplateGrid() {
/>
<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>
{template.builder && (
<span className="absolute top-3 right-3 flex items-center gap-1 rounded-full bg-white/90 px-2.5 py-1 text-xs font-medium text-black">
<BlocksIcon className="size-3.5" /> Bausteine
</span>
)}
<div className="absolute bottom-3 left-3 flex gap-1.5">
{template.palettes.map((palette) => (
<span

View file

@ -4,6 +4,9 @@ import type { ComponentType } from "react"
import { AiAccessProvider } from "../../ai/access-context"
import type { AiAccess } from "../../ai/demos"
import { ChatWidget } from "../../ai/ui/chat-widget"
import { AtelierTemplate } from "../../catalog/atelier/template"
import { AuroraTemplate } from "../../catalog/aurora/template"
import { BaukastenTemplate } from "../../catalog/baukasten/template"
import { BeautyTemplate } from "../../catalog/beauty/template"
import { CoachTemplate } from "../../catalog/coach/template"
import { EventTemplate } from "../../catalog/event/template"
@ -13,6 +16,8 @@ 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 { MonolithTemplate } from "../../catalog/monolith/template"
import { PrismaTemplate } from "../../catalog/prisma/template"
import { PraxisTemplate } from "../../catalog/praxis/template"
import { RestaurantTemplate } from "../../catalog/restaurant/template"
import { SaasTemplate } from "../../catalog/saas/template"
@ -33,6 +38,11 @@ import type { TemplateContent } from "../../lib/types"
type TemplateComponent = ComponentType<{ content: TemplateContent }>
const components: Record<string, TemplateComponent> = {
baukasten: BaukastenTemplate as TemplateComponent,
monolith: MonolithTemplate as TemplateComponent,
aurora: AuroraTemplate as TemplateComponent,
atelier: AtelierTemplate as TemplateComponent,
prisma: PrismaTemplate as TemplateComponent,
restaurant: RestaurantTemplate as TemplateComponent,
handwerk: HandwerkTemplate as TemplateComponent,
beauty: BeautyTemplate as TemplateComponent,

View file

@ -22,7 +22,7 @@ const TemplatesView = async () => {
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold md:text-3xl">Vorlagen</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Demo-Webseiten für Pitches: Vorlage wählen, für den Kunden anpassen (Inhalte, Farben, Schriften, Reihenfolge) und als Link teilen.
Demo-Webseiten für Pitches: Vorlage wählen oder im Baukasten aus Bausteinen selbst zusammenstellen, für den Kunden anpassen (Inhalte, Farben, Schriften, Reihenfolge) und als Link teilen.
</p>
</div>

View file

@ -10,6 +10,7 @@ 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 { BlocksPanel } from "../components/editor/blocks-panel"
import { ContentPanel } from "../components/editor/content-panel"
import { DesignPanel } from "../components/editor/design-panel"
import { PreviewFrame } from "../components/editor/preview-frame"
@ -46,6 +47,8 @@ export function VariantEditor({ variant, clients }: { variant: EditorVariant; cl
const [content, setContent] = useState(variant.content)
const [saved, setSaved] = useState(() => snapshot(variant.name, variant.clientId, variant.content))
const [tab, setTab] = useState<Tab>("content")
// Baukasten: Baustein, zu dem die Vorschau springt (n zählt hoch, damit erneutes Aufklappen wieder springt)
const [focus, setFocus] = useState<{ id: string; n: number } | null>(null)
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
@ -150,7 +153,17 @@ export function VariantEditor({ variant, clients }: { variant: EditorVariant; cl
</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} />
<>
<ContentPanel def={def} data={content.data} hidden={content.hidden} onChange={updateField} />
{def.builder && (
<BlocksPanel
library={def.builder.library}
blocks={content.blocks}
onChange={(blocks) => setContent((c) => ({ ...c, blocks }))}
onFocus={(id) => setFocus((current) => ({ id, n: (current?.n ?? 0) + 1 }))}
/>
)}
</>
)}
{tab === "design" && (
<DesignPanel def={def} content={content} onChange={(patch) => setContent((c) => ({ ...c, ...patch }))} />
@ -164,7 +177,7 @@ export function VariantEditor({ variant, clients }: { variant: EditorVariant; cl
</div>
</aside>
<PreviewFrame templateKey={def.key} variantId={variant.id} content={content} />
<PreviewFrame templateKey={def.key} variantId={variant.id} content={content} focus={focus} />
</div>
</div>
)

6
package-lock.json generated
View file

@ -2642,7 +2642,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -4271,7 +4271,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -10008,7 +10008,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",