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

441 lines
26 KiB
TypeScript

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