mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
478 lines
29 KiB
TypeScript
478 lines
29 KiB
TypeScript
"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 { 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 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 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 booking={booking} services={services.items} hours={hours.items} />}
|
||
|
||
{show("team") && (
|
||
<section 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 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 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>
|
||
<p className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Buchung direkt im Praxiskalender.</p>
|
||
</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>
|
||
)
|
||
}
|