mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
353 lines
22 KiB
TypeScript
353 lines
22 KiB
TypeScript
"use client"
|
|
|
|
import { AnimatePresence, motion } from "motion/react"
|
|
import { useEffect, useState } from "react"
|
|
import { ArrowRightIcon, BedDoubleIcon, ChevronLeftIcon, ChevronRightIcon, MapPinIcon, RulerIcon, SearchIcon } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import type { TemplateProps } from "../../lib/types"
|
|
import { DemoForm } from "../shared/demo-form"
|
|
import { TField, TSelect } from "../shared/fields"
|
|
import { TImage } from "../shared/media"
|
|
import { ParallaxImage, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
|
|
import { TemplateShell } from "../shared/shell"
|
|
import { immobilien, type ImmobilienData } 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 TYPES = ["Alle", "Kaufen", "Mieten"]
|
|
const ALL_PLACES = "Alle Lagen"
|
|
|
|
function Eyebrow({ children }: { children: string }) {
|
|
return (
|
|
<p className={cn(label, "mb-6 flex items-center gap-4 text-t-muted")}>
|
|
<span className="h-px w-12 bg-t-accent" />
|
|
{children}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
export function ImmobilienTemplate({ content }: TemplateProps<ImmobilienData>) {
|
|
const { brand, hero, slides, listings, about, projects, valuation, testimonial } = content.data
|
|
const show = (key: string) => !content.hidden.includes(key)
|
|
const [type, setType] = useState("Alle")
|
|
const [place, setPlace] = useState(ALL_PLACES)
|
|
const places = [ALL_PLACES, ...new Set(listings.items.map((item) => item.location))]
|
|
const links = [
|
|
{ href: "#objekte", label: "Objekte", visible: show("listings") },
|
|
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
|
|
{ href: "#referenzen", label: "Referenzen", visible: show("projects") },
|
|
{ href: "#bewertung", label: "Wertermittlung", visible: true },
|
|
].filter((link) => link.visible)
|
|
|
|
return (
|
|
<TemplateShell def={immobilien} content={content} fonts={fonts[content.fonts] ?? fonts.elegant}>
|
|
<Nav brand={brand} links={links} />
|
|
<HeroSlider hero={hero} slides={slides.items} />
|
|
|
|
{show("listings") && (
|
|
<div data-order="listings" className={cn(container, "relative z-10 -mt-12")}>
|
|
<Reveal>
|
|
<div className="grid gap-4 bg-t-bg p-5 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.45)] ring-1 ring-t-fg/10 md:grid-cols-[auto_1fr_auto] md:items-end md:p-6">
|
|
<div className="flex gap-1">
|
|
{TYPES.map((option) => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
aria-pressed={type === option}
|
|
onClick={() => setType(option)}
|
|
className={cn("cursor-pointer px-4 py-3 text-sm transition-colors", type === option ? "bg-t-fg text-t-bg" : "hover:bg-t-surface")}
|
|
>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<TSelect label="Lage" variant="line" value={place} onChange={(event) => setPlace(event.target.value)} options={places} />
|
|
<a href="#objekte" className="flex items-center justify-center gap-2 bg-t-accent px-6 py-3.5 text-sm text-t-accent-fg">
|
|
<SearchIcon className="size-4" /> Objekte anzeigen
|
|
</a>
|
|
</div>
|
|
</Reveal>
|
|
</div>
|
|
)}
|
|
|
|
{show("listings") && <Listings data-order="listings" listings={listings} type={type} place={place} onType={setType} onReset={() => setPlace(ALL_PLACES)} />}
|
|
|
|
{show("about") && (
|
|
<section data-order="about" id="ueber-uns" className={cn(container, "grid items-center gap-14 py-24 md:grid-cols-2 md:py-36")}>
|
|
<ParallaxImage src={about.image} offset={50} className="aspect-[4/5]" sizes="(min-width: 768px) 45vw, 100vw" />
|
|
<div>
|
|
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
|
|
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
|
|
<Reveal delay={0.1}><p className="mt-8 text-lg leading-relaxed text-t-muted">{about.text}</p></Reveal>
|
|
<dl className="mt-12 grid grid-cols-3 border-t border-t-fg/15">
|
|
{about.facts.map((fact, i) => (
|
|
<Reveal key={i} delay={0.15 + i * 0.08} className={cn("pt-6", i > 0 && "border-l border-t-fg/15 pl-5")}>
|
|
<dd className={cn(heading, "text-4xl text-t-accent md:text-5xl")}>{fact.value}</dd>
|
|
<dt className="mt-2 text-xs text-t-muted">{fact.label}</dt>
|
|
</Reveal>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{show("projects") && <ProjectIndex data-order="projects" projects={projects} />}
|
|
|
|
<section id="bewertung" className="relative overflow-hidden py-24 md:py-36">
|
|
<TImage src={valuation.image} sizes="100vw" />
|
|
<div className="absolute inset-0 bg-black/60" />
|
|
<div className={cn(container, "relative grid items-center gap-12 text-white lg:grid-cols-2")}>
|
|
<div>
|
|
<p className={cn(label, "mb-6 flex items-center gap-4 text-white/70")}><span className="h-px w-12 bg-t-accent" />{valuation.eyebrow}</p>
|
|
<RevealWords text={valuation.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5.5vw,5rem)] leading-[1.02]")} />
|
|
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg leading-relaxed text-white/80">{valuation.text}</p></Reveal>
|
|
</div>
|
|
<Reveal delay={0.1}>
|
|
<DemoForm
|
|
className="grid gap-5 bg-t-bg p-7 text-t-fg sm:grid-cols-2 md:p-10"
|
|
success={
|
|
<div className="py-8">
|
|
<p className={cn(heading, "text-4xl")}>Vielen <span className={em}>Dank.</span></p>
|
|
<p className="mt-3 text-t-muted">Wir melden uns innerhalb von 48 Stunden mit Ihrer Einschätzung.</p>
|
|
</div>
|
|
}
|
|
>
|
|
<TSelect label="Objektart" name="kind" variant="line" options={["Wohnung", "Haus", "Grundstück", "Gewerbe"]} />
|
|
<TField label="PLZ" name="zip" inputMode="numeric" required variant="line" />
|
|
<TField label="Wohnfläche in m²" name="area" inputMode="numeric" variant="line" />
|
|
<TField label="Baujahr" name="year" inputMode="numeric" variant="line" />
|
|
<TField label="Name" name="name" required autoComplete="name" variant="line" />
|
|
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="line" />
|
|
<button type="submit" className="flex cursor-pointer items-center justify-center gap-2 bg-t-fg py-4 text-sm text-t-bg transition-colors hover:bg-t-accent hover:text-t-accent-fg sm:col-span-2">
|
|
Kostenlos bewerten lassen <ArrowRightIcon className="size-4" />
|
|
</button>
|
|
</DemoForm>
|
|
</Reveal>
|
|
</div>
|
|
</section>
|
|
|
|
{show("testimonial") && (
|
|
<section data-order="testimonial" className="mx-auto max-w-[1000px] px-5 py-24 text-center md:py-36">
|
|
<RevealWords as="p" text={testimonial.quote} className={cn(heading, "text-[clamp(1.75rem,3.6vw,3.25rem)] leading-[1.2]")} />
|
|
<Reveal delay={0.2}><p className={cn(label, "mt-10 text-t-muted")}>{testimonial.author}</p></Reveal>
|
|
</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 tracking-[0.3em] text-t-fg uppercase")}>{brand.name}</p>
|
|
<p className={cn(label, "mt-2")}>{brand.suffix}</p>
|
|
</div>
|
|
<p>{brand.address}</p>
|
|
<div className="grid gap-1">
|
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hover:text-t-fg">{brand.phone}</a>
|
|
<a href={`mailto:${brand.email}`} className="hover:text-t-fg">{brand.email}</a>
|
|
</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, links }: { brand: ImmobilienData["brand"]; 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 text-t-fg backdrop-blur-xl" : "text-white")}
|
|
>
|
|
<nav className={cn(container, "flex h-24 items-center justify-between gap-8")}>
|
|
<a href="#top" className="leading-none">
|
|
<span className={cn(heading, "block text-2xl tracking-[0.35em] uppercase")}>{brand.name}</span>
|
|
<span className={cn(label, "mt-1.5 block opacity-70")}>{brand.suffix}</span>
|
|
</a>
|
|
<div className={cn(label, "hidden items-center gap-9 lg:flex")}>
|
|
{links.map((link) => (
|
|
<a key={link.href} href={link.href} className="opacity-80 transition-opacity hover:opacity-100">{link.label}</a>
|
|
))}
|
|
</div>
|
|
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hidden border border-current/40 px-5 py-3 text-sm transition-colors hover:border-t-accent hover:bg-t-accent hover:text-t-accent-fg sm:block">
|
|
{brand.phone}
|
|
</a>
|
|
</nav>
|
|
</motion.header>
|
|
)
|
|
}
|
|
|
|
function HeroSlider({ hero, slides }: { hero: ImmobilienData["hero"]; slides: ImmobilienData["slides"]["items"] }) {
|
|
const [index, setIndex] = useState(0)
|
|
const count = Math.max(slides.length, 1)
|
|
const current = index % count
|
|
const slide = slides[current]
|
|
|
|
useEffect(() => {
|
|
if (count < 2) return
|
|
const timer = setTimeout(() => setIndex((i) => (i + 1) % count), 6500)
|
|
return () => clearTimeout(timer)
|
|
}, [index, count])
|
|
|
|
return (
|
|
<section id="top" className="relative h-svh min-h-[640px] overflow-hidden bg-black text-white">
|
|
<AnimatePresence initial={false}>
|
|
<motion.div key={current} className="absolute inset-0" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 1.4, ease: "easeInOut" }}>
|
|
<motion.div className="absolute inset-0" initial={{ scale: 1.12 }} animate={{ scale: 1 }} transition={{ duration: 7.5, ease: "linear" }}>
|
|
<TImage src={slide?.image ?? ""} eager={current === 0} sizes="100vw" />
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-black/45" />
|
|
|
|
<div className={cn(container, "relative flex h-full flex-col justify-end pb-28 md:pb-32")}>
|
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.3 }} className={cn(label, "mb-6 text-white/75")}>
|
|
{hero.eyebrow}
|
|
</motion.p>
|
|
<RevealWords as="h1" onMount delay={0.4} text={hero.title} emClassName="italic text-t-accent" className={cn(heading, "text-[clamp(3.2rem,9vw,9.5rem)] leading-[0.95]")} />
|
|
<div className="mt-12 flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
|
<a href="#objekte" className="group flex w-fit items-center gap-4 text-sm tracking-wide">
|
|
<span className="grid size-14 place-items-center rounded-full border border-white/40 transition-colors group-hover:border-t-accent group-hover:bg-t-accent group-hover:text-t-accent-fg">
|
|
<ArrowRightIcon className="size-5" />
|
|
</span>
|
|
{hero.cta}
|
|
</a>
|
|
{slide && (
|
|
<div className="flex items-end gap-8">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div key={current} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.6, ease }} className="text-right">
|
|
<p className={cn(label, "text-white/60")}>{slide.location}</p>
|
|
<p className={cn(heading, "mt-1 text-2xl md:text-3xl")}>{slide.title}</p>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
<div className="flex gap-2">
|
|
<button type="button" aria-label="Vorheriges Objekt" onClick={() => setIndex((current - 1 + count) % count)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-white/30 hover:bg-white/10">
|
|
<ChevronLeftIcon className="size-5" />
|
|
</button>
|
|
<button type="button" aria-label="Nächstes Objekt" onClick={() => setIndex((current + 1) % count)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-white/30 hover:bg-white/10">
|
|
<ChevronRightIcon className="size-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-8 flex gap-2">
|
|
{slides.map((_, i) => (
|
|
<span key={i} className="h-0.5 flex-1 overflow-hidden bg-white/25">
|
|
{i === current && <motion.span key={index} className="block h-full bg-t-accent" initial={{ width: "0%" }} animate={{ width: "100%" }} transition={{ duration: 6.5, ease: "linear" }} />}
|
|
{i < current && <span className="block h-full w-full bg-white/70" />}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function Listings({ listings, type, place, onType, onReset }: {
|
|
listings: ImmobilienData["listings"]
|
|
type: string
|
|
place: string
|
|
onType: (type: string) => void
|
|
onReset: () => void
|
|
}) {
|
|
const visible = listings.items.filter((item) => (type === "Alle" || item.type === type) && (place === ALL_PLACES || item.location === place))
|
|
|
|
return (
|
|
<section id="objekte" 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><Eyebrow>{listings.eyebrow}</Eyebrow></Reveal>
|
|
<RevealWords text={listings.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{TYPES.map((option) => (
|
|
<button key={option} type="button" aria-pressed={type === option} onClick={() => onType(option)} className={cn(label, "cursor-pointer border px-4 py-2.5 transition-colors", type === option ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/20 hover:border-t-fg")}>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<motion.div layout className="mt-14 grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
|
<AnimatePresence mode="popLayout">
|
|
{visible.map((item) => (
|
|
<motion.article
|
|
layout
|
|
key={item.title}
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.96 }}
|
|
transition={{ duration: 0.5, ease }}
|
|
className="group"
|
|
>
|
|
<div className="relative aspect-[4/3] overflow-hidden bg-t-surface">
|
|
<TImage src={item.image} alt={item.title} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
|
<span className={cn(label, "absolute top-4 left-4 bg-t-bg/90 px-3 py-1.5 text-t-fg")}>{item.type}</span>
|
|
<p className={cn(heading, "absolute bottom-4 left-4 text-2xl text-white")}>{item.price}</p>
|
|
</div>
|
|
<h3 className={cn(heading, "mt-5 text-2xl")}>{item.title}</h3>
|
|
<p className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm text-t-muted">
|
|
<span className="flex items-center gap-1.5"><RulerIcon className="size-4" /> {item.sqm} m²</span>
|
|
<span className="flex items-center gap-1.5"><BedDoubleIcon className="size-4" /> {item.rooms} Zimmer</span>
|
|
<span className="flex items-center gap-1.5"><MapPinIcon className="size-4" /> {item.location}</span>
|
|
</p>
|
|
</motion.article>
|
|
))}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
{visible.length === 0 && (
|
|
<p className="mt-10 text-t-muted">
|
|
Aktuell keine passenden Objekte.{" "}
|
|
<button type="button" onClick={onReset} className="cursor-pointer text-t-accent underline-offset-4 hover:underline">Alle Lagen zeigen</button>
|
|
</p>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function ProjectIndex({ projects }: { projects: ImmobilienData["projects"] }) {
|
|
const [active, setActive] = useState(0)
|
|
const current = projects.items[Math.min(active, projects.items.length - 1)]
|
|
|
|
return (
|
|
<section id="referenzen" className="bg-t-surface py-24 md:py-36">
|
|
<div className={container}>
|
|
<Reveal><Eyebrow>{projects.eyebrow}</Eyebrow></Reveal>
|
|
<RevealWords text={projects.title} emClassName={em} className={cn(heading, "max-w-3xl text-[clamp(2.5rem,5vw,4.75rem)] leading-[1.02]")} />
|
|
<div className="mt-14 grid gap-10 lg:grid-cols-[1fr_440px]">
|
|
<ul className="border-t border-t-fg/15">
|
|
{projects.items.map((project, i) => (
|
|
<li key={i} onMouseEnter={() => setActive(i)} className="group border-b border-t-fg/15">
|
|
<div className="flex items-baseline gap-6 py-6 transition-[padding] duration-500 md:group-hover:pl-4">
|
|
<span className={cn(label, "w-12 text-t-accent")}>{project.year}</span>
|
|
<span className={cn(heading, "flex-1 text-3xl transition-colors md:text-5xl", active === i ? "text-t-fg" : "text-t-fg/45")}>{project.title}</span>
|
|
<span className="hidden text-sm text-t-muted sm:block">{project.place}</span>
|
|
</div>
|
|
<div className="relative mb-6 aspect-[16/10] overflow-hidden lg:hidden">
|
|
<TImage src={project.image} alt={project.title} sizes="100vw" />
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="hidden lg:block">
|
|
<div className="sticky top-32 aspect-[4/5] overflow-hidden bg-t-bg">
|
|
<AnimatePresence initial={false}>
|
|
<motion.div key={active} className="absolute inset-0" initial={{ opacity: 0, scale: 1.06 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.7, ease }}>
|
|
<TImage src={current?.image ?? ""} alt={current?.title} sizes="440px" />
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|