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

479 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { motion } from "motion/react"
import { useState } from "react"
import {
ArrowRightIcon,
BabyIcon,
CalendarCheckIcon,
CircleCheckIcon,
HeartHandshakeIcon,
MapPinIcon,
PhoneIcon,
ScanFaceIcon,
ShieldCheckIcon,
SmileIcon,
SparklesIcon,
StarIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoNote } from "../shared/demo"
import { TField, TSelect } from "../shared/fields"
import { minutesOf, splitList, useNow } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, ParallaxImage, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { praxis, type PraxisData } from "./definition"
import { fonts } from "./fonts"
type Hours = PraxisData["hours"]["items"]
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1280px] px-5 md:px-8"
const heading = "font-t-display font-semibold tracking-[-0.025em]"
const icons = [SparklesIcon, SmileIcon, ShieldCheckIcon, ScanFaceIcon, BabyIcon, HeartHandshakeIcon]
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]
function Eyebrow({ children }: { children: string }) {
return (
<p className="mb-5 inline-flex items-center gap-2 rounded-full bg-t-surface px-3 py-1.5 text-xs font-semibold text-t-accent">
<span className="size-1.5 rounded-full bg-t-accent" />
{children}
</p>
)
}
function useOpenStatus(hours: Hours) {
const now = useNow()
if (now === null) return null
const date = new Date(now)
const minutes = date.getHours() * 60 + date.getMinutes()
const today = WEEKDAYS[date.getDay()]
const entry = hours.find((h) => h.day === today)
const open = entry ? minutesOf(entry.open) : null
const close = entry ? minutesOf(entry.close) : null
if (entry && open !== null && close !== null && minutes >= open && minutes < close) {
return { open: true, today, label: `Jetzt geöffnet · bis ${entry.close} Uhr` }
}
for (let offset = 0; offset < 7; offset++) {
const name = WEEKDAYS[(date.getDay() + offset) % 7]
const next = hours.find((h) => h.day === name)
const opens = next ? minutesOf(next.open) : null
if (next && opens !== null && (offset > 0 || minutes < opens)) {
const when = offset === 0 ? "heute" : offset === 1 ? "morgen" : name
return { open: false, today, label: `Geschlossen · öffnet ${when} ${next.open} Uhr` }
}
}
return { open: false, today, label: "Derzeit geschlossen" }
}
function OpenPill({ hours, className }: { hours: Hours; className?: string }) {
const status = useOpenStatus(hours)
return (
<span className={cn("inline-flex items-center gap-2 rounded-full bg-t-bg/90 px-3 py-1.5 text-xs font-medium shadow-sm ring-1 ring-t-fg/5", className)}>
<span className={cn("size-2 rounded-full", status?.open ? "animate-pulse bg-emerald-500" : "bg-t-muted/50")} />
{status?.label ?? "Öffnungszeiten"}
</span>
)
}
export function PraxisTemplate({ content }: TemplateProps<PraxisData>) {
const { brand, hero, hours, services, about, booking, team, testimonial, faq, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const bookingHref = show("booking") ? "#termin" : "#kontakt"
const links = [
{ href: "#leistungen", label: "Behandlungen", visible: show("services") },
{ href: "#team", label: "Team", visible: show("team") },
{ href: "#faq", label: "FAQ", visible: show("faq") },
{ href: "#kontakt", label: "Kontakt", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={praxis} content={content} fonts={fonts[content.fonts] ?? fonts.klar}>
<Nav brand={brand} hours={hours.items} cta={hero.cta} href={bookingHref} links={links} />
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-20 md:grid-cols-2 md:pt-40 md:pb-28")}>
<div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.1 }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(2.75rem,6vw,5.25rem)] leading-[1.02]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.7 }}>
<p className="mt-7 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-9 flex flex-wrap items-center gap-4">
<a href={bookingHref} className="group inline-flex items-center gap-3 rounded-full bg-t-accent px-7 py-4 font-semibold text-t-accent-fg shadow-[0_14px_40px_-12px_var(--t-accent)] transition-transform hover:-translate-y-0.5">
<CalendarCheckIcon className="size-5" /> {hero.cta}
</a>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="inline-flex items-center gap-2 rounded-full px-5 py-4 font-medium ring-1 ring-t-fg/15 transition-colors hover:bg-t-surface">
<PhoneIcon className="size-4" /> {brand.phone}
</a>
</div>
<div className="mt-9 flex items-center gap-3 text-sm">
<span className="flex text-amber-400">
{Array.from({ length: 5 }, (_, i) => (
<StarIcon key={i} className="size-4 fill-current" />
))}
</span>
<span className="font-semibold">{hero.rating}</span>
<span className="text-t-muted">· {hero.reviews}</span>
</div>
</motion.div>
</div>
<motion.div initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 1.1, ease, delay: 0.2 }} className="relative">
<div className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-t-surface">
<TImage src={hero.image} eager sizes="(min-width: 768px) 45vw, 100vw" />
</div>
<motion.div animate={{ y: [0, -10, 0] }} transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }} className="absolute top-8 -left-4 md:-left-10">
<OpenPill hours={hours.items} className="px-4 py-2.5 text-sm shadow-xl" />
</motion.div>
<motion.div
animate={{ y: [0, 10, 0] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-3 bottom-10 flex items-center gap-3 rounded-2xl bg-t-bg p-4 shadow-xl ring-1 ring-t-fg/5 md:-right-8"
>
<span className="grid size-11 place-items-center rounded-xl bg-t-accent text-t-accent-fg">
<HeartHandshakeIcon className="size-5" />
</span>
<span className="max-w-40 text-sm font-medium leading-snug">{hero.note}</span>
</motion.div>
</motion.div>
</section>
{show("services") && (
<section data-order="services" id="leistungen" className={cn(container, "py-20 md:py-28")}>
<div className="max-w-2xl">
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
<RevealWords text={services.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
</div>
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{services.items.map((item, i) => {
const Icon = icons[i % icons.length]
return (
<Reveal key={i} delay={(i % 3) * 0.08}>
<article className="group h-full rounded-[1.75rem] bg-t-surface p-7 transition-all duration-500 hover:-translate-y-1 hover:bg-t-bg hover:shadow-[0_30px_60px_-30px_rgba(0,0,0,0.25)] hover:ring-1 hover:ring-t-fg/5">
<span className="grid size-12 place-items-center rounded-2xl bg-t-bg text-t-accent transition-colors duration-500 group-hover:bg-t-accent group-hover:text-t-accent-fg">
<Icon className="size-5" />
</span>
<h3 className={cn(heading, "mt-6 text-xl")}>{item.title}</h3>
<p className="mt-2 leading-relaxed text-t-muted">{item.text}</p>
<p className="mt-5 text-sm font-medium text-t-accent">{item.duration}</p>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("about") && (
<section data-order="about" className={cn(container, "grid items-center gap-14 py-20 md:grid-cols-2 md:py-28")}>
<div className="relative">
<ParallaxImage src={about.image} offset={40} className="aspect-[5/4] rounded-[2rem]" sizes="(min-width: 768px) 45vw, 100vw" />
<Reveal delay={0.2} className="absolute -right-2 -bottom-10 w-[42%] md:-right-10">
<div className="relative aspect-square overflow-hidden rounded-[1.5rem] border-8 border-t-bg">
<TImage src={about.imageSecondary} sizes="20vw" />
</div>
</Reveal>
</div>
<div>
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
<RevealWords text={about.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{about.text}</p></Reveal>
<dl className="mt-10 grid grid-cols-3 gap-4">
{about.facts.map((fact, i) => (
<Reveal key={i} delay={0.15 + i * 0.08} className="rounded-2xl bg-t-surface p-4">
<dd className={cn(heading, "text-3xl text-t-accent")}>{fact.value}</dd>
<dt className="mt-1 text-sm text-t-muted">{fact.label}</dt>
</Reveal>
))}
</dl>
</div>
</section>
)}
{show("booking") && <Booking data-order="booking" booking={booking} services={services.items} hours={hours.items} />}
{show("team") && (
<section data-order="team" id="team" className={cn(container, "py-20 md:py-28")}>
<Reveal><Eyebrow>{team.eyebrow}</Eyebrow></Reveal>
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<div className="mt-12 grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-6">
{team.members.map((member, i) => (
<Reveal key={i} delay={i * 0.08}>
<div className="group">
<div className="relative aspect-[4/5] overflow-hidden rounded-[1.75rem] bg-t-surface">
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="transition-transform duration-700 group-hover:scale-105" />
</div>
<p className={cn(heading, "mt-4 text-lg")}>{member.name}</p>
<p className="text-sm text-t-muted">{member.role}</p>
</div>
</Reveal>
))}
</div>
</section>
)}
{show("testimonial") && (
<section data-order="testimonial" className={cn(container, "py-16 md:py-24")}>
<Reveal>
<figure className="relative overflow-hidden rounded-[2.5rem] bg-t-accent px-7 py-14 text-t-accent-fg md:px-16 md:py-20">
<span aria-hidden className="absolute -top-10 right-8 font-t-display text-[14rem] leading-none opacity-15">“</span>
<blockquote className={cn(heading, "relative max-w-4xl text-[clamp(1.6rem,3.2vw,2.75rem)] leading-[1.2]")}>{testimonial.quote}</blockquote>
<figcaption className="relative mt-8 text-sm font-medium opacity-80">{testimonial.author}</figcaption>
</figure>
</Reveal>
</section>
)}
{show("faq") && (
<section data-order="faq" id="faq" className={cn(container, "grid gap-10 py-20 md:grid-cols-[1fr_1.4fr] md:py-28")}>
<div>
<Reveal><Eyebrow>{faq.eyebrow}</Eyebrow></Reveal>
<RevealWords text={faq.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
</div>
<Accordion
items={faq.items}
className="space-y-3"
itemClassName="rounded-2xl bg-t-surface px-6"
questionClassName="py-5 font-semibold"
answerClassName="pb-6 leading-relaxed text-t-muted"
/>
</section>
)}
<section id="kontakt" className={cn(container, "pb-20 md:pb-28")}>
<div className="grid gap-6 rounded-[2.5rem] bg-t-surface p-6 md:grid-cols-2 md:p-10">
<div className="p-2 md:p-4">
<Eyebrow>{contact.eyebrow}</Eyebrow>
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4vw,3.25rem)] leading-[1.05]")} />
<p className="mt-5 leading-relaxed text-t-muted">{contact.text}</p>
<div className="mt-8 space-y-3 text-sm">
<p className="flex items-center gap-3"><MapPinIcon className="size-4 text-t-accent" /> {brand.address}</p>
<p className="flex items-center gap-3"><PhoneIcon className="size-4 text-t-accent" /> {brand.phone}</p>
<p className="flex items-center gap-3"><CalendarCheckIcon className="size-4 text-t-accent" /> {brand.email}</p>
</div>
</div>
<HoursCard hours={hours.items} />
</div>
</section>
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p>© {brand.name} · {brand.suffix}</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, hours, cta, href, links }: { brand: PraxisData["brand"]; hours: Hours; cta: string; href: string; links: { href: string; label: string }[] }) {
const { scrolled, hidden } = useScrollNav()
return (
<motion.header animate={{ y: hidden ? "-130%" : "0%" }} transition={{ duration: 0.45, ease }} className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:pt-4">
<nav className={cn("mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 rounded-full px-3 transition-all duration-500 md:px-4", scrolled && "bg-t-bg/85 shadow-[0_10px_40px_-20px_rgba(0,0,0,0.3)] backdrop-blur-xl")}>
<a href="#top" className="flex items-center gap-2.5">
<span className="grid size-10 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<SmileIcon className="size-5" />
</span>
<span className="leading-tight">
<span className={cn(heading, "block text-lg")}>{brand.name}</span>
<span className="hidden text-xs text-t-muted sm:block">{brand.suffix}</span>
</span>
</a>
<div className="hidden items-center gap-7 text-sm font-medium 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>
<div className="flex items-center gap-3">
<OpenPill hours={hours} className="hidden md:inline-flex" />
<a href={href} className="rounded-full bg-t-accent px-5 py-2.5 text-sm font-semibold whitespace-nowrap text-t-accent-fg">{cta}</a>
</div>
</nav>
</motion.header>
)
}
function HoursCard({ hours }: { hours: Hours }) {
const status = useOpenStatus(hours)
return (
<div className="rounded-[2rem] bg-t-bg p-6 md:p-8">
<div className="flex items-center justify-between gap-4">
<p className={cn(heading, "text-xl")}>Sprechzeiten</p>
<span className={cn("rounded-full px-3 py-1 text-xs font-medium", status?.open ? "bg-emerald-500/15 text-emerald-600" : "bg-t-surface text-t-muted")}>
{status?.open ? "Geöffnet" : "Geschlossen"}
</span>
</div>
<dl className="mt-6 divide-y divide-t-fg/5">
{hours.map((entry) => (
<div key={entry.day} className={cn("flex justify-between py-3 text-sm", status?.today === entry.day && "font-semibold text-t-accent")}>
<dt>{entry.day}</dt>
<dd>{minutesOf(entry.open) !== null ? `${entry.open} – ${entry.close} Uhr` : "geschlossen"}</dd>
</div>
))}
</dl>
</div>
)
}
function nextOpenDays(now: number, hours: Hours, count: number) {
const days: { key: string; weekday: string; date: string }[] = []
for (let offset = 1; days.length < count && offset < 21; offset++) {
const date = new Date(now + offset * 86_400_000)
const name = WEEKDAYS[date.getDay()]
const entry = hours.find((h) => h.day === name)
if (!entry || minutesOf(entry.open) === null) continue
days.push({
key: date.toDateString(),
weekday: name.slice(0, 2),
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
})
}
return days
}
function Booking({ booking, services, hours }: { booking: PraxisData["booking"]; services: PraxisData["services"]["items"]; hours: Hours }) {
const now = useNow()
const [step, setStep] = useState(0)
const [service, setService] = useState<string | null>(null)
const [day, setDay] = useState<{ key: string; label: string } | null>(null)
const [slot, setSlot] = useState<string | null>(null)
const [done, setDone] = useState(false)
const days = now === null ? [] : nextOpenDays(now, hours, 6)
const slots = splitList(booking.slots)
const labels = ["Behandlung", "Termin", "Kontakt"]
const canContinue = step === 0 ? Boolean(service) : step === 1 ? Boolean(day && slot) : true
return (
<section id="termin" className="bg-t-surface py-20 md:py-28">
<div className={cn(container, "grid gap-12 lg:grid-cols-[0.8fr_1.2fr]")}>
<div>
<Reveal><Eyebrow>{booking.eyebrow}</Eyebrow></Reveal>
<RevealWords text={booking.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{booking.text}</p></Reveal>
</div>
<Reveal delay={0.1}>
<div className="rounded-[2rem] bg-t-bg p-6 shadow-[0_40px_80px_-40px_rgba(0,0,0,0.25)] md:p-9">
{done ? (
<motion.div initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} className="py-8 text-center">
<span className="mx-auto grid size-16 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<CircleCheckIcon className="size-8" />
</span>
<p className={cn(heading, "mt-6 text-2xl")}>Termin angefragt!</p>
<p className="mt-2 text-t-muted">{service} · {day?.label} um {slot} Uhr</p>
<DemoNote className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Buchung direkt im Praxiskalender.</DemoNote>
</motion.div>
) : (
<>
<div className="mb-8 grid grid-cols-3 gap-2">
{labels.map((label, i) => (
<div key={label}>
<div className="h-1.5 overflow-hidden rounded-full bg-t-surface">
<motion.div className="h-full bg-t-accent" initial={false} animate={{ width: i <= step ? "100%" : "0%" }} transition={{ duration: 0.5, ease }} />
</div>
<p className={cn("mt-2 text-xs font-medium", i <= step ? "text-t-fg" : "text-t-muted")}>{i + 1}. {label}</p>
</div>
))}
</div>
<StepPanel step={step}>
{step === 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{services.map((item) => (
<button
key={item.title}
type="button"
onClick={() => setService(item.title)}
className={cn(
"cursor-pointer rounded-2xl p-4 text-left ring-1 transition-all",
service === item.title ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50"
)}
>
<span className="block font-semibold">{item.title}</span>
<span className="mt-1 block text-xs opacity-70">{item.duration}</span>
</button>
))}
</div>
)}
{step === 1 && (
<div>
<p className="text-sm font-medium">Tag wählen</p>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{days.map((d) => (
<button
key={d.key}
type="button"
onClick={() => setDay({ key: d.key, label: `${d.weekday}, ${d.date}` })}
className={cn("cursor-pointer rounded-2xl py-3 text-center ring-1 transition-all", day?.key === d.key ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50")}
>
<span className="block text-xs opacity-70">{d.weekday}</span>
<span className="block font-semibold">{d.date}</span>
</button>
))}
</div>
<p className="mt-6 text-sm font-medium">Uhrzeit wählen</p>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((time) => (
<button
key={time}
type="button"
onClick={() => setSlot(time)}
className={cn("cursor-pointer rounded-xl py-2.5 text-sm font-medium ring-1 transition-all", slot === time ? "bg-t-accent text-t-accent-fg ring-t-accent" : "ring-t-fg/10 hover:ring-t-accent/50")}
>
{time}
</button>
))}
</div>
</div>
)}
{step === 2 && (
<form
id="praxis-booking"
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
setDone(true)
}}
>
<TField label="Vor- und Nachname" name="name" required autoComplete="name" variant="soft" className="sm:col-span-2" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="soft" />
<TField label="E-Mail" name="email" type="email" autoComplete="email" variant="soft" />
<TSelect label="Versicherung" name="insurance" options={["Gesetzlich versichert", "Privat versichert", "Selbstzahler:in"]} variant="soft" className="sm:col-span-2" />
</form>
)}
</StepPanel>
<div className="mt-8 flex items-center justify-between gap-4">
<button type="button" onClick={() => setStep((s) => Math.max(0, s - 1))} className={cn("cursor-pointer text-sm font-medium text-t-muted hover:text-t-fg", step === 0 && "invisible")}>
Zurück
</button>
{step < 2 ? (
<button
type="button"
disabled={!canContinue}
onClick={() => setStep((s) => s + 1)}
className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg transition-opacity disabled:opacity-40"
>
Weiter <ArrowRightIcon className="size-4" />
</button>
) : (
<button type="submit" form="praxis-booking" className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">
Termin anfragen <ArrowRightIcon className="size-4" />
</button>
)}
</div>
</>
)}
</div>
</Reveal>
</div>
</section>
)
}