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

263 lines
16 KiB
TypeScript

"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { ArrowRightIcon, CameraIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { TArea, TField, TSelect } from "../shared/fields"
import { splitList } from "../shared/hooks"
import { Lightbox } from "../shared/lightbox"
import { TImage } from "../shared/media"
import { Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { fotograf, type FotografData } from "./definition"
import { fonts } from "./fonts"
const em = "italic text-t-accent"
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-10"
const heading = "font-t-display font-normal tracking-[-0.01em]"
const label = "text-[11px] uppercase tracking-[0.3em]"
const ALL = "Alle"
function Column({ images, reverse, duration }: { images: { src: string }[]; reverse?: boolean; duration: number }) {
return (
<motion.div
className="flex flex-col gap-4"
animate={{ y: reverse ? ["-50%", "0%"] : ["0%", "-50%"] }}
transition={{ duration, ease: "linear", repeat: Infinity }}
>
{[...images, ...images].map((image, i) => (
<div key={i} className="relative aspect-[3/4] w-full shrink-0 overflow-hidden">
<TImage src={image.src} sizes="(min-width: 1024px) 20vw, 45vw" eager={i < 2} />
</div>
))}
</motion.div>
)
}
export function FotografTemplate({ content }: TemplateProps<FotografData>) {
const { brand, hero, portfolio, packages, about, process, testimonials, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const half = Math.ceil(hero.strip.length / 2)
const links = [
{ href: "#portfolio", label: "Portfolio", visible: true },
{ href: "#pakete", label: "Pakete", visible: show("packages") },
{ href: "#ueber-mich", label: "Über mich", visible: show("about") },
].filter((link) => link.visible)
return (
<TemplateShell def={fotograf} content={content} fonts={fonts[content.fonts] ?? fonts.zeitlos}>
<Nav brand={brand} cta={hero.cta} links={links} />
<section id="top" className={cn(container, "grid items-center gap-12 pt-28 lg:grid-cols-[1fr_1.1fr] lg:pt-24")}>
<div className="py-10">
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className={cn(label, "flex items-center gap-3 text-t-muted")}>
<CameraIcon className="size-4 text-t-accent" /> {brand.tagline}
</motion.p>
<RevealWords as="h1" onMount delay={0.3} text={hero.title} emClassName={em} className={cn(heading, "mt-8 text-[clamp(3rem,6.5vw,6.5rem)] leading-[1]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.8 }}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-10 flex items-center gap-6">
<a href="#anfrage" className="group inline-flex items-center gap-3 border-b border-t-fg pb-1 text-lg">
{hero.cta} <ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</a>
<span className="text-sm text-t-muted">{brand.location}</span>
</div>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.2, delay: 0.3 }}
className="relative grid h-[70vh] grid-cols-2 gap-4 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)] lg:h-[88vh]"
>
<Column images={hero.strip.slice(0, half)} duration={38} />
<Column images={hero.strip.slice(half)} duration={44} reverse />
</motion.div>
</section>
<Portfolio portfolio={portfolio} />
{show("packages") && (
<section data-order="packages" id="pakete" className="bg-t-surface py-24 md:py-32">
<div className={container}>
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{packages.eyebrow}</p></Reveal>
<RevealWords text={packages.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)]")} />
<div className="mt-14 grid gap-px bg-t-fg/15 md:grid-cols-3">
{packages.items.map((item, i) => {
const featured = Boolean(item.highlight.trim())
return (
<Reveal key={i} delay={i * 0.1} className={cn("flex flex-col p-8 md:p-10", featured ? "bg-t-fg text-t-bg" : "bg-t-surface")}>
<p className={cn(label, featured ? "opacity-60" : "text-t-muted")}>{featured ? "Am beliebtesten" : `Paket ${i + 1}`}</p>
<h3 className={cn(heading, "mt-4 text-4xl")}>{item.name}</h3>
<p className={cn(heading, "mt-2 text-2xl italic", featured ? "text-t-accent" : "text-t-accent")}>{item.price}</p>
<ul className="mt-8 flex-1 space-y-3 text-sm">
{splitList(item.features, "\n").map((feature) => (
<li key={feature} className={cn("border-b pb-3", featured ? "border-t-bg/15" : "border-t-fg/10")}>{feature}</li>
))}
</ul>
<a href="#anfrage" className={cn("mt-8 text-center text-sm underline-offset-4 hover:underline", featured && "text-t-accent")}>Anfragen →</a>
</Reveal>
)
})}
</div>
</div>
</section>
)}
{show("about") && (
<section data-order="about" id="ueber-mich" className={cn(container, "grid items-center gap-14 py-24 md:grid-cols-[0.9fr_1.1fr] md:py-32")}>
<Reveal>
<div className="relative aspect-[4/5] overflow-hidden">
<TImage src={about.image} sizes="(min-width: 768px) 40vw, 100vw" />
</div>
</Reveal>
<div>
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)]")} />
<Reveal delay={0.1}>
<p className="mt-8 text-lg leading-relaxed text-t-muted">{about.text}</p>
<p className={cn(heading, "mt-10 text-5xl italic text-t-accent")}>{about.signature}</p>
</Reveal>
</div>
</section>
)}
{show("process") && (
<section data-order="process" className="border-y border-t-fg/10">
<ol className={cn(container, "grid gap-10 py-16 md:grid-cols-4")}>
{process.items.map((step, i) => (
<Reveal key={i} delay={i * 0.08}>
<li>
<span className={cn(heading, "text-5xl italic text-t-accent")}>{String(i + 1).padStart(2, "0")}</span>
<p className={cn(heading, "mt-4 text-2xl")}>{step.title}</p>
<p className="mt-1 text-sm text-t-muted">{step.text}</p>
</li>
</Reveal>
))}
</ol>
</section>
)}
{show("testimonials") && (
<section data-order="testimonials" className="mx-auto max-w-[1000px] space-y-16 px-5 py-24 text-center md:py-32">
{testimonials.items.map((item, i) => (
<figure key={i}>
<RevealWords as="p" text={`„${item.quote}“`} className={cn(heading, "text-[clamp(1.5rem,3vw,2.5rem)] italic leading-[1.3]")} />
<Reveal delay={0.2}><figcaption className={cn(label, "mt-6 text-t-muted")}>{item.author}</figcaption></Reveal>
</figure>
))}
</section>
)}
<section id="anfrage" className="bg-t-fg py-24 text-t-bg md:py-32">
<div className={cn(container, "grid gap-12 lg:grid-cols-2")}>
<div>
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.75rem,6vw,5.5rem)] leading-[1]")} />
<Reveal delay={0.1}>
<p className="mt-6 max-w-md text-lg opacity-75">{contact.text}</p>
<a href={`mailto:${brand.email}`} className="mt-8 inline-block border-b border-current pb-1">{brand.email}</a>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-6 sm:grid-cols-2"
success={<p className={cn(heading, "py-10 text-4xl")}>Danke! Ich melde mich <span className={em}>in zwei Tagen.</span></p>}
>
<TField label="Euer Datum" name="date" type="date" required variant="line" />
<TField label="Location" name="location" variant="line" />
<TField label="Namen" name="name" required autoComplete="name" variant="line" />
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="line" />
<TSelect label="Paket" name="package" variant="line" options={[...packages.items.map((p) => p.name), "Noch unsicher"]} className="sm:col-span-2" />
<TArea label="Erzählt mir von euch" name="message" variant="line" className="sm:col-span-2" />
<button type="submit" className="cursor-pointer border border-current py-4 transition-colors hover:bg-t-bg hover:text-t-fg sm:col-span-2">Anfrage senden</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className={cn(container, "flex flex-col gap-4 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p><span className={cn(heading, "text-lg text-t-fg")}>{brand.name}</span> · {brand.tagline}</p>
<div className="flex gap-6">
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ brand, cta, links }: { brand: FotografData["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/90 backdrop-blur-xl")}
>
<nav className={cn(container, "grid h-20 grid-cols-[1fr_auto_1fr] items-center")}>
<div className={cn(label, "hidden gap-8 md:flex")}>
{links.map((link) => (
<a key={link.href} href={link.href} className="text-t-fg/70 hover:text-t-fg">{link.label}</a>
))}
</div>
<a href="#top" className={cn(heading, "col-start-1 text-2xl md:col-start-2")}>{brand.name}</a>
<a href="#anfrage" className={cn(label, "col-start-3 justify-self-end border-b border-t-fg pb-1")}>{cta}</a>
</nav>
</motion.header>
)
}
function Portfolio({ portfolio }: { portfolio: FotografData["portfolio"] }) {
const categories = [ALL, ...new Set(portfolio.items.map((item) => item.category))]
const [filter, setFilter] = useState(ALL)
const [open, setOpen] = useState<number | null>(null)
const visible = portfolio.items.filter((item) => filter === ALL || item.category === filter)
return (
<section id="portfolio" className={cn(container, "py-24 md:py-32")}>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{portfolio.eyebrow}</p></Reveal>
<RevealWords text={portfolio.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)]")} />
</div>
<div className="flex flex-wrap gap-6">
{categories.map((category) => (
<button
key={category}
type="button"
aria-pressed={filter === category}
onClick={() => setFilter(category)}
className={cn(label, "cursor-pointer border-b pb-1 transition-colors", filter === category ? "border-t-accent text-t-fg" : "border-transparent text-t-muted hover:text-t-fg")}
>
{category}
</button>
))}
</div>
</div>
<AnimatePresence mode="wait">
<motion.div key={filter} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.4 }} className="mt-14 columns-1 gap-5 sm:columns-2 lg:columns-3">
{visible.map((item, i) => (
<button
key={item.caption}
type="button"
onClick={() => setOpen(i)}
className={cn("group relative mb-5 block w-full cursor-zoom-in overflow-hidden break-inside-avoid", ["aspect-[3/4]", "aspect-[4/5]", "aspect-[4/3]"][i % 3])}
>
<TImage src={item.src} alt={item.caption} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
<span className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-5 text-left text-white opacity-0 transition-opacity duration-500 group-hover:opacity-100">
<span>
<span className={cn(label, "block opacity-70")}>{item.category}</span>
<span className={cn(heading, "text-2xl")}>{item.caption}</span>
</span>
</span>
</button>
))}
</motion.div>
</AnimatePresence>
<Lightbox images={visible} index={open} onIndex={setOpen} />
</section>
)
}