since3-portal/modules/templates/catalog/kanzlei/template.tsx
2026-10-10 02:38:50 +02:00

370 lines
22 KiB
TypeScript

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