mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
added internal account code and content
This commit is contained in:
parent
6268dbac76
commit
5880e82b23
59 changed files with 5618 additions and 72 deletions
10
app/admin/internal-accounts/page.tsx
Normal file
10
app/admin/internal-accounts/page.tsx
Normal 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 />
|
||||
}
|
||||
101
lib/supabase/migrations/20261011100000_internal_credentials.sql
Normal file
101
lib/supabase/migrations/20261011100000_internal_credentials.sql
Normal 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;
|
||||
173
modules/admin/actions/internal-credentials.ts
Normal file
173
modules/admin/actions/internal-credentials.ts
Normal 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 }
|
||||
}
|
||||
30
modules/admin/lib/internal-costs.ts
Normal file
30
modules/admin/lib/internal-costs.ts
Normal 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)
|
||||
}
|
||||
30
modules/admin/queries/internal-credentials.ts
Normal file
30
modules/admin/queries/internal-credentials.ts
Normal 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)}))
|
||||
}
|
||||
251
modules/admin/ui/components/internal-credential-dialog.tsx
Normal file
251
modules/admin/ui/components/internal-credential-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
297
modules/admin/ui/components/internal-credentials-table.tsx
Normal file
297
modules/admin/ui/components/internal-credentials-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
modules/admin/ui/views/internal-accounts-view.tsx
Normal file
44
modules/admin/ui/views/internal-accounts-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
124
modules/templates/catalog/atelier/definition.ts
Normal file
124
modules/templates/catalog/atelier/definition.ts
Normal 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
|
||||
4
modules/templates/catalog/atelier/fonts.ts
Normal file
4
modules/templates/catalog/atelier/fonts.ts
Normal 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 }
|
||||
10
modules/templates/catalog/atelier/template.tsx
Normal file
10
modules/templates/catalog/atelier/template.tsx
Normal 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} />
|
||||
}
|
||||
157
modules/templates/catalog/aurora/definition.ts
Normal file
157
modules/templates/catalog/aurora/definition.ts
Normal 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
|
||||
4
modules/templates/catalog/aurora/fonts.ts
Normal file
4
modules/templates/catalog/aurora/fonts.ts
Normal 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 }
|
||||
10
modules/templates/catalog/aurora/template.tsx
Normal file
10
modules/templates/catalog/aurora/template.tsx
Normal 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} />
|
||||
}
|
||||
40
modules/templates/catalog/baukasten/definition.ts
Normal file
40
modules/templates/catalog/baukasten/definition.ts
Normal 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
|
||||
4
modules/templates/catalog/baukasten/fonts.ts
Normal file
4
modules/templates/catalog/baukasten/fonts.ts
Normal 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 }
|
||||
10
modules/templates/catalog/baukasten/template.tsx
Normal file
10
modules/templates/catalog/baukasten/template.tsx
Normal 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} />
|
||||
}
|
||||
151
modules/templates/catalog/blocks/about.tsx
Normal file
151
modules/templates/catalog/blocks/about.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
modules/templates/catalog/blocks/accents.tsx
Normal file
126
modules/templates/catalog/blocks/accents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
modules/templates/catalog/blocks/builder.ts
Normal file
18
modules/templates/catalog/blocks/builder.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
642
modules/templates/catalog/blocks/definitions.ts
Normal file
642
modules/templates/catalog/blocks/definitions.ts
Normal 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
|
||||
44
modules/templates/catalog/blocks/fonts.ts
Normal file
44
modules/templates/catalog/blocks/fonts.ts
Normal 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>
|
||||
128
modules/templates/catalog/blocks/footer.tsx
Normal file
128
modules/templates/catalog/blocks/footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
236
modules/templates/catalog/blocks/navigation.tsx
Normal file
236
modules/templates/catalog/blocks/navigation.tsx
Normal 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 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
255
modules/templates/catalog/blocks/offer.tsx
Normal file
255
modules/templates/catalog/blocks/offer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
modules/templates/catalog/blocks/page.tsx
Normal file
63
modules/templates/catalog/blocks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
modules/templates/catalog/blocks/process.tsx
Normal file
115
modules/templates/catalog/blocks/process.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
modules/templates/catalog/blocks/proof.tsx
Normal file
281
modules/templates/catalog/blocks/proof.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
modules/templates/catalog/blocks/registry.tsx
Normal file
52
modules/templates/catalog/blocks/registry.tsx
Normal 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,
|
||||
}
|
||||
261
modules/templates/catalog/blocks/services.tsx
Normal file
261
modules/templates/catalog/blocks/services.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
496
modules/templates/catalog/blocks/start.tsx
Normal file
496
modules/templates/catalog/blocks/start.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
modules/templates/catalog/blocks/types.ts
Normal file
9
modules/templates/catalog/blocks/types.ts
Normal 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[] }
|
||||
329
modules/templates/catalog/blocks/ui.tsx
Normal file
329
modules/templates/catalog/blocks/ui.tsx
Normal 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
|
||||
}
|
||||
275
modules/templates/catalog/blocks/work.tsx
Normal file
275
modules/templates/catalog/blocks/work.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
132
modules/templates/catalog/monolith/definition.ts
Normal file
132
modules/templates/catalog/monolith/definition.ts
Normal 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
|
||||
4
modules/templates/catalog/monolith/fonts.ts
Normal file
4
modules/templates/catalog/monolith/fonts.ts
Normal 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 }
|
||||
10
modules/templates/catalog/monolith/template.tsx
Normal file
10
modules/templates/catalog/monolith/template.tsx
Normal 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} />
|
||||
}
|
||||
128
modules/templates/catalog/prisma/definition.ts
Normal file
128
modules/templates/catalog/prisma/definition.ts
Normal 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
|
||||
4
modules/templates/catalog/prisma/fonts.ts
Normal file
4
modules/templates/catalog/prisma/fonts.ts
Normal 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 }
|
||||
10
modules/templates/catalog/prisma/template.tsx
Normal file
10
modules/templates/catalog/prisma/template.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> }
|
||||
|
|
|
|||
297
modules/templates/ui/components/editor/blocks-panel.tsx
Normal file
297
modules/templates/ui/components/editor/blocks-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
6
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue