mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
418 lines
22 KiB
TypeScript
418 lines
22 KiB
TypeScript
"use client"
|
|
|
|
import { AnimatePresence, motion, useMotionValue, useScroll, useSpring, useTransform } from "motion/react"
|
|
import { useEffect, useId, useRef, useState, type InputHTMLAttributes } from "react"
|
|
import { ArrowUpRightIcon, StarIcon } 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 { Marquee, Reveal, RevealWords, ScrollWords, ease, useScrollNav } from "../shared/motion"
|
|
import { TemplateShell } from "../shared/shell"
|
|
import { beauty, type BeautyData } from "./definition"
|
|
import { fonts } from "./fonts"
|
|
|
|
const em = "italic text-t-accent"
|
|
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
|
|
const eyebrow = "mb-6 text-xs uppercase tracking-[0.3em] text-t-muted"
|
|
const pill = "inline-flex items-center gap-2 rounded-full bg-t-fg px-6 py-3.5 text-sm text-t-bg transition-transform duration-300 hover:scale-[1.04]"
|
|
|
|
export function BeautyTemplate({ content }: TemplateProps<BeautyData>) {
|
|
const { brand, hero, intro, services, gallery, team, reviews, booking } = content.data
|
|
const show = (key: string) => !content.hidden.includes(key)
|
|
const links = [
|
|
{ href: "#leistungen", label: "Leistungen", visible: show("services") },
|
|
{ href: "#team", label: "Team", visible: show("team") },
|
|
{ href: "#stimmen", label: "Stimmen", visible: show("reviews") },
|
|
].filter((link) => link.visible)
|
|
|
|
return (
|
|
<TemplateShell def={beauty} content={content} fonts={fonts[content.fonts] ?? fonts.editorial}>
|
|
<Nav name={brand.name} cta={hero.cta} links={links} />
|
|
<Hero hero={hero} />
|
|
|
|
{show("intro") && (
|
|
<section className="mx-auto max-w-[1200px] px-5 py-24 md:px-10 md:py-40">
|
|
<ScrollWords
|
|
text={intro.text}
|
|
className="text-center font-t-display text-[clamp(1.9rem,4.3vw,4rem)] leading-[1.14] tracking-[-0.015em]"
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{show("services") && <Services services={services} />}
|
|
|
|
{show("gallery") && (
|
|
<section aria-label="Bildband" className="py-10 md:py-16">
|
|
<Marquee duration={55}>
|
|
{gallery.images.map((image, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"relative mx-2 h-[40vh] w-[29vh] shrink-0 overflow-hidden md:mx-3 md:h-[54vh] md:w-[39vh]",
|
|
i % 2 === 0 ? "rounded-t-full" : "rounded-[28px]"
|
|
)}
|
|
>
|
|
<TImage src={image.src} sizes="40vh" />
|
|
</div>
|
|
))}
|
|
</Marquee>
|
|
</section>
|
|
)}
|
|
|
|
{show("team") && (
|
|
<section id="team" className={cn(container, "py-24 md:py-40")}>
|
|
<Reveal><p className={eyebrow}>{team.eyebrow}</p></Reveal>
|
|
<RevealWords text={team.title} emClassName={em} className="font-t-display text-[clamp(2.75rem,6vw,5.75rem)] leading-[0.95] tracking-[-0.025em]" />
|
|
<div className="mt-16 grid grid-cols-2 gap-x-4 gap-y-12 md:grid-cols-4 md:gap-x-6">
|
|
{team.members.map((member, i) => (
|
|
<Reveal key={i} delay={i * 0.08} className={cn(i % 2 === 1 && "md:mt-20")}>
|
|
<div className="group">
|
|
<div className="relative aspect-[3/4] overflow-hidden rounded-t-full bg-t-surface">
|
|
<TImage
|
|
src={member.image}
|
|
alt={member.name}
|
|
sizes="(min-width: 768px) 25vw, 50vw"
|
|
className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0"
|
|
/>
|
|
</div>
|
|
<p className="mt-5 font-t-display text-2xl md:text-3xl">{member.name}</p>
|
|
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
|
|
</div>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{show("reviews") && <Reviews reviews={reviews} />}
|
|
|
|
<section id="termin" className="p-3 md:p-6">
|
|
<div className="relative overflow-hidden rounded-[32px] bg-t-accent px-5 py-20 text-t-accent-fg md:rounded-[56px] md:px-16 md:py-28">
|
|
<div aria-hidden className="absolute -top-48 -right-48 size-[36rem] rounded-full border border-current opacity-20" />
|
|
<div aria-hidden className="absolute -top-28 -right-28 size-[36rem] rounded-full border border-current opacity-15" />
|
|
<div className="relative grid gap-14 md:grid-cols-2 md:gap-20">
|
|
<div>
|
|
<RevealWords text={booking.title} emClassName="italic" className="font-t-display text-[clamp(2.75rem,6vw,5.5rem)] leading-[0.95] tracking-[-0.025em]" />
|
|
<Reveal delay={0.15}>
|
|
<p className="mt-8 max-w-md text-lg leading-relaxed opacity-80">{booking.text}</p>
|
|
<dl className="mt-12 grid gap-6 text-sm">
|
|
{[
|
|
["Adresse", booking.address],
|
|
["Telefon", booking.phone],
|
|
["Öffnungszeiten", booking.hours],
|
|
].map(([label, value]) => (
|
|
<div key={label}>
|
|
<dt className="text-xs uppercase tracking-[0.25em] opacity-60">{label}</dt>
|
|
<dd className="mt-1 text-base">{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</Reveal>
|
|
</div>
|
|
<Reveal delay={0.1}>
|
|
<DemoForm
|
|
className="rounded-[28px] bg-t-bg p-6 text-t-fg md:p-10"
|
|
success={
|
|
<div className="py-10 text-center">
|
|
<p className="font-t-display text-5xl italic text-t-accent">Wir freuen uns!</p>
|
|
<p className="mt-4 text-t-muted">Wir bestätigen Ihren Termin in Kürze per SMS oder Anruf.</p>
|
|
</div>
|
|
}
|
|
>
|
|
<p className="font-t-display text-3xl">Termin anfragen</p>
|
|
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
|
<Soft label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
|
|
<Soft label="Telefon" name="phone" type="tel" autoComplete="tel" required />
|
|
<Soft label="Wunschtermin" name="date" type="date" />
|
|
<label className="block sm:col-span-2">
|
|
<span className="px-4 text-xs uppercase tracking-[0.2em] text-t-muted">Leistung</span>
|
|
<select name="service" className="mt-1.5 w-full rounded-full border border-t-fg/15 bg-t-surface px-5 py-3.5 outline-none focus:border-t-accent">
|
|
{services.items.map((item, i) => (
|
|
<option key={i}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<button type="submit" className={cn(pill, "mt-8 w-full cursor-pointer justify-center py-4")}>
|
|
Termin anfragen <ArrowUpRightIcon className="size-4" />
|
|
</button>
|
|
</DemoForm>
|
|
</Reveal>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<footer className={cn(container, "flex flex-col gap-8 py-14 text-sm md:flex-row md:items-end md:justify-between")}>
|
|
<div>
|
|
<p className="font-t-display text-4xl tracking-tight">{brand.name}</p>
|
|
<p className="mt-1 text-t-muted">{brand.tagline}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-8 gap-y-2 text-t-muted">
|
|
<a href="#" className="hover:text-t-fg">Instagram {brand.instagram}</a>
|
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
|
</div>
|
|
</footer>
|
|
</TemplateShell>
|
|
)
|
|
}
|
|
|
|
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
|
|
const { scrolled, hidden } = useScrollNav()
|
|
|
|
return (
|
|
<motion.header
|
|
animate={{ y: hidden ? "-120%" : "0%" }}
|
|
transition={{ duration: 0.45, ease }}
|
|
className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-5"
|
|
>
|
|
<nav
|
|
className={cn(
|
|
"mx-auto grid h-16 max-w-[1440px] grid-cols-[1fr_auto_1fr] items-center rounded-full px-4 transition-colors duration-500 md:px-6",
|
|
scrolled ? "bg-t-bg/80 shadow-[0_8px_30px_-12px_rgba(0,0,0,0.25)] backdrop-blur-xl" : "bg-transparent"
|
|
)}
|
|
>
|
|
<div className="hidden gap-8 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>
|
|
))}
|
|
</div>
|
|
<a href="#top" className="col-start-1 justify-self-start font-t-display text-2xl tracking-tight md:col-start-2 md:justify-self-center md:text-3xl">
|
|
{name}
|
|
</a>
|
|
<a href="#termin" className={cn(pill, "col-start-3 justify-self-end px-5 py-2.5")}>{cta}</a>
|
|
</nav>
|
|
</motion.header>
|
|
)
|
|
}
|
|
|
|
function Hero({ hero }: { hero: BeautyData["hero"] }) {
|
|
const ref = useRef<HTMLElement>(null)
|
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
|
|
const left = useTransform(scrollYProgress, [0, 1], [0, -160])
|
|
const middle = useTransform(scrollYProgress, [0, 1], [0, -60])
|
|
const right = useTransform(scrollYProgress, [0, 1], [0, -240])
|
|
const reveal = (delay: number) => ({
|
|
initial: { opacity: 0, y: 80, scale: 0.94 },
|
|
animate: { opacity: 1, y: 0, scale: 1 },
|
|
transition: { duration: 1.3, ease, delay },
|
|
})
|
|
|
|
return (
|
|
<section ref={ref} id="top" className="relative overflow-hidden pt-32 pb-16 md:pt-40 md:pb-24">
|
|
<div className={container}>
|
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }} className={cn(eyebrow, "text-center")}>
|
|
{hero.eyebrow}
|
|
</motion.p>
|
|
<RevealWords
|
|
as="h1"
|
|
onMount
|
|
delay={0.3}
|
|
text={hero.title}
|
|
emClassName={em}
|
|
className="text-center font-t-display text-[clamp(3.6rem,12.5vw,13rem)] leading-[0.86] tracking-[-0.04em]"
|
|
/>
|
|
|
|
<div className="relative mt-14 grid grid-cols-12 items-start gap-3 md:mt-20 md:gap-6">
|
|
<motion.div style={{ y: left }} className="col-span-5 mt-20 md:col-span-3 md:mt-32">
|
|
<motion.div {...reveal(0.7)} className="relative aspect-[3/4] overflow-hidden rounded-t-full">
|
|
<TImage src={hero.imageLeft} sizes="(min-width: 768px) 25vw, 40vw" eager />
|
|
</motion.div>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 1, ease, delay: 1.2 }}
|
|
className="mt-8 hidden md:block"
|
|
>
|
|
<p className="text-lg leading-relaxed text-t-muted">{hero.text}</p>
|
|
</motion.div>
|
|
</motion.div>
|
|
|
|
<motion.div style={{ y: middle }} className="relative col-span-7 md:col-span-5 md:col-start-5">
|
|
<motion.div {...reveal(0.5)} className="relative aspect-[4/5] overflow-hidden rounded-[32px] md:rounded-[48px]">
|
|
<TImage src={hero.image} sizes="(min-width: 768px) 42vw, 60vw" eager />
|
|
</motion.div>
|
|
<motion.div
|
|
initial={{ scale: 0, rotate: -90 }}
|
|
animate={{ scale: 1, rotate: 0 }}
|
|
transition={{ type: "spring", bounce: 0.35, duration: 1.2, delay: 1.3 }}
|
|
className="absolute -bottom-10 -left-6 md:-left-16"
|
|
>
|
|
<RotatingBadge text={hero.cta} href="#termin" />
|
|
</motion.div>
|
|
</motion.div>
|
|
|
|
<motion.div style={{ y: right }} className="hidden md:col-span-3 md:col-start-10 md:mt-48 md:block">
|
|
<motion.div {...reveal(0.9)} className="relative aspect-[3/4] overflow-hidden rounded-[28px]">
|
|
<TImage src={hero.imageRight} sizes="25vw" eager />
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
<p className="mt-24 text-center text-lg leading-relaxed text-t-muted md:hidden">{hero.text}</p>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function RotatingBadge({ text, href }: { text: string; href: string }) {
|
|
const pathId = `badge-${useId().replace(/[^a-zA-Z0-9]/g, "")}`
|
|
|
|
return (
|
|
<a href={href} className="group relative grid size-28 place-items-center rounded-full bg-t-accent text-t-accent-fg md:size-36">
|
|
<motion.svg
|
|
viewBox="0 0 100 100"
|
|
aria-hidden
|
|
className="absolute inset-0 size-full"
|
|
animate={{ rotate: 360 }}
|
|
transition={{ duration: 16, ease: "linear", repeat: Infinity }}
|
|
>
|
|
<defs>
|
|
<path id={pathId} d="M50,50 m-36,0 a36,36 0 1,1 72,0 a36,36 0 1,1 -72,0" />
|
|
</defs>
|
|
<text className="fill-current text-[9.5px] uppercase" style={{ letterSpacing: "0.18em" }}>
|
|
<textPath href={`#${pathId}`} textLength="222" lengthAdjust="spacing">
|
|
{`${text} ✦ ${text} ✦ `}
|
|
</textPath>
|
|
</text>
|
|
</motion.svg>
|
|
<span className="sr-only">{text}</span>
|
|
<ArrowUpRightIcon className="size-7 transition-transform duration-500 group-hover:rotate-45" />
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function Services({ services }: { services: BeautyData["services"] }) {
|
|
const areaRef = useRef<HTMLDivElement>(null)
|
|
const [active, setActive] = useState<number | null>(null)
|
|
const x = useMotionValue(0)
|
|
const y = useMotionValue(0)
|
|
const springX = useSpring(x, { stiffness: 260, damping: 28 })
|
|
const springY = useSpring(y, { stiffness: 260, damping: 28 })
|
|
const activeItem = active === null ? undefined : services.items[active]
|
|
|
|
return (
|
|
<section id="leistungen" className={cn(container, "py-24 md:py-36")}>
|
|
<Reveal><p className={eyebrow}>{services.eyebrow}</p></Reveal>
|
|
<RevealWords text={services.title} emClassName={em} className="font-t-display text-[clamp(2.75rem,6vw,5.75rem)] leading-[0.95] tracking-[-0.025em]" />
|
|
|
|
<div
|
|
ref={areaRef}
|
|
className="relative mt-14 md:mt-20"
|
|
onPointerMove={(event) => {
|
|
const rect = areaRef.current?.getBoundingClientRect()
|
|
if (!rect) return
|
|
x.set(event.clientX - rect.left)
|
|
y.set(event.clientY - rect.top)
|
|
}}
|
|
onPointerLeave={() => setActive(null)}
|
|
>
|
|
<ul className="border-t border-t-fg/15">
|
|
{services.items.map((item, 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.8, ease, delay: i * 0.06 }}
|
|
onPointerEnter={() => setActive(i)}
|
|
className="group grid grid-cols-12 items-baseline gap-x-4 gap-y-2 border-b border-t-fg/15 py-7 transition-[padding] duration-500 md:py-9 md:hover:pl-6"
|
|
>
|
|
<span className="col-span-12 font-t-display text-3xl tracking-tight transition-colors duration-300 group-hover:text-t-accent md:col-span-5 md:text-5xl">
|
|
{item.name}
|
|
</span>
|
|
<span className="col-span-12 text-t-muted md:col-span-4">{item.text}</span>
|
|
<span className="col-span-6 text-sm text-t-muted md:col-span-1">{item.duration}</span>
|
|
<span className="col-span-6 text-right font-t-display text-2xl md:col-span-2">{item.price}</span>
|
|
</motion.li>
|
|
))}
|
|
</ul>
|
|
|
|
<motion.div aria-hidden style={{ x: springX, y: springY }} className="pointer-events-none absolute top-0 left-0 z-10 hidden md:block">
|
|
<AnimatePresence>
|
|
{activeItem && (
|
|
<motion.div
|
|
key={active}
|
|
initial={{ opacity: 0, scale: 0.7, rotate: -8 }}
|
|
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
|
exit={{ opacity: 0, scale: 0.7, rotate: 8 }}
|
|
transition={{ duration: 0.4, ease }}
|
|
className="relative -mt-[160px] -ml-[120px] h-[320px] w-[240px] overflow-hidden rounded-t-full rounded-b-[28px] shadow-2xl"
|
|
>
|
|
<TImage src={activeItem.image} sizes="240px" />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function Reviews({ reviews }: { reviews: BeautyData["reviews"] }) {
|
|
const [index, setIndex] = useState(0)
|
|
const count = reviews.items.length
|
|
|
|
useEffect(() => {
|
|
if (count < 2) return
|
|
const timer = setInterval(() => setIndex((i) => (i + 1) % count), 6500)
|
|
return () => clearInterval(timer)
|
|
}, [count])
|
|
|
|
if (count === 0) return null
|
|
const current = index % count
|
|
const review = reviews.items[current]
|
|
|
|
return (
|
|
<section id="stimmen" className="bg-t-surface py-24 md:py-36">
|
|
<div className="mx-auto max-w-[1000px] px-5 text-center">
|
|
<div className="flex justify-center gap-1.5 text-t-accent">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<StarIcon key={i} className="size-5 fill-current" />
|
|
))}
|
|
</div>
|
|
<div className="mt-10 grid min-h-[15rem] items-center md:min-h-[13rem]">
|
|
<AnimatePresence mode="wait">
|
|
<motion.blockquote
|
|
key={current}
|
|
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
|
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
exit={{ opacity: 0, y: -24, filter: "blur(8px)" }}
|
|
transition={{ duration: 0.7, ease }}
|
|
>
|
|
<p className="font-t-display text-[clamp(1.6rem,3.4vw,2.9rem)] leading-[1.2] tracking-[-0.01em]">„{review.text}“</p>
|
|
<footer className="mt-8 text-xs uppercase tracking-[0.3em] text-t-muted">{review.author}</footer>
|
|
</motion.blockquote>
|
|
</AnimatePresence>
|
|
</div>
|
|
<div className="mt-10 flex justify-center gap-2">
|
|
{reviews.items.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
aria-label={`Bewertung ${i + 1} anzeigen`}
|
|
aria-current={i === current}
|
|
onClick={() => setIndex(i)}
|
|
className={cn("h-1.5 cursor-pointer rounded-full transition-all duration-500", i === current ? "w-10 bg-t-accent" : "w-1.5 bg-t-fg/25")}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function Soft({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
|
return (
|
|
<label className={cn("block", className)}>
|
|
<span className="px-4 text-xs uppercase tracking-[0.2em] text-t-muted">{label}</span>
|
|
<input
|
|
{...props}
|
|
className="mt-1.5 w-full rounded-full border border-t-fg/15 bg-t-surface px-5 py-3.5 outline-none transition-colors focus:border-t-accent"
|
|
/>
|
|
</label>
|
|
)
|
|
}
|