mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
315 lines
18 KiB
TypeScript
315 lines
18 KiB
TypeScript
"use client"
|
||
|
||
import { AnimatePresence, motion } from "motion/react"
|
||
import { useState } from "react"
|
||
import { ArrowRightIcon, BrainIcon, CheckIcon, ClipboardCheckIcon, HandHeartIcon, HeartHandshakeIcon, HouseIcon, MapPinIcon, PhoneCallIcon, PillIcon, UsersIcon, XIcon } 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 { splitList } from "../shared/hooks"
|
||
import { TImage } from "../shared/media"
|
||
import { Reveal, RevealWords, ease } from "../shared/motion"
|
||
import { TemplateShell } from "../shared/shell"
|
||
import { pflege, type PflegeData } from "./definition"
|
||
import { fonts } from "./fonts"
|
||
|
||
const em = "text-t-accent"
|
||
const container = "mx-auto w-full max-w-[1240px] px-5 md:px-8"
|
||
const heading = "font-t-display font-bold tracking-[-0.015em]"
|
||
const icons = [HandHeartIcon, PillIcon, HouseIcon, BrainIcon, UsersIcon, ClipboardCheckIcon]
|
||
// Schriftgröße per zoom, damit alles mitwächst (auch Abstände und feste Leisten)
|
||
const SCALES = [
|
||
{ label: "A", className: "" },
|
||
{ label: "A+", className: "[zoom:1.15]" },
|
||
{ label: "A++", className: "[zoom:1.3]" },
|
||
]
|
||
|
||
export function PflegeTemplate({ content }: TemplateProps<PflegeData>) {
|
||
const { brand, hero, services, budget, steps, team, testimonials, jobs, contact } = content.data
|
||
const show = (key: string) => !content.hidden.includes(key)
|
||
const [scale, setScale] = useState(0)
|
||
const tel = `tel:${brand.phone.replace(/\s/g, "")}`
|
||
|
||
return (
|
||
<TemplateShell def={pflege} content={content} fonts={fonts[content.fonts] ?? fonts.lesbar} className={cn("text-[17px]", SCALES[scale].className)}>
|
||
<div className="bg-t-accent py-2.5 text-center font-bold text-t-accent-fg">
|
||
<a href={tel} className="inline-flex items-center gap-2">
|
||
<PhoneCallIcon className="size-5" /> {brand.emergency}: {brand.phone}
|
||
</a>
|
||
</div>
|
||
|
||
<header className="sticky top-0 z-50 border-b border-t-fg/10 bg-t-bg/95 backdrop-blur">
|
||
<nav className={cn(container, "flex h-20 items-center justify-between gap-4")}>
|
||
<a href="#top" className="flex items-center gap-3">
|
||
<span className="grid size-11 place-items-center rounded-2xl bg-t-accent text-t-accent-fg"><HeartHandshakeIcon className="size-6" /></span>
|
||
<span className="leading-tight">
|
||
<span className={cn(heading, "block text-xl")}>{brand.name}</span>
|
||
<span className="text-sm text-t-muted">{brand.suffix}</span>
|
||
</span>
|
||
</a>
|
||
<div className="flex items-center gap-3">
|
||
<div role="group" aria-label="Schriftgröße" className="flex rounded-full border-2 border-t-fg/15 p-1">
|
||
{SCALES.map((option, i) => (
|
||
<button
|
||
key={option.label}
|
||
type="button"
|
||
aria-pressed={scale === i}
|
||
aria-label={`Schriftgröße ${option.label}`}
|
||
onClick={() => setScale(i)}
|
||
className={cn("cursor-pointer rounded-full px-2.5 py-1 font-bold transition-colors", scale === i ? "bg-t-fg text-t-bg" : "hover:bg-t-fg/5")}
|
||
style={{ fontSize: `${0.8 + i * 0.15}rem` }}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<a href="#kontakt" className="hidden rounded-full bg-t-fg px-5 py-3 font-bold text-t-bg md:block">{hero.cta}</a>
|
||
</div>
|
||
</nav>
|
||
</header>
|
||
|
||
<section id="top" className={cn(container, "grid items-center gap-12 py-14 md:py-20 lg:grid-cols-2")}>
|
||
<div>
|
||
<RevealWords as="h1" onMount delay={0.1} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5.5vw,4.5rem)] leading-[1.08]")} />
|
||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.5 }}>
|
||
<p className="mt-6 text-xl leading-relaxed text-t-muted">{hero.text}</p>
|
||
<div className="mt-8 flex flex-wrap gap-3">
|
||
<a href="#kontakt" className="inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 text-lg font-bold text-t-accent-fg">
|
||
{hero.cta} <ArrowRightIcon className="size-5" />
|
||
</a>
|
||
<a href={tel} className="inline-flex items-center gap-2 rounded-full border-2 border-t-fg px-7 py-4 text-lg font-bold">
|
||
<PhoneCallIcon className="size-5" /> Anrufen
|
||
</a>
|
||
</div>
|
||
<AreaCheck areas={hero.areas} />
|
||
</motion.div>
|
||
</div>
|
||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.9, ease }} className="relative">
|
||
<div className="relative aspect-[4/3.6] overflow-hidden rounded-[2rem]">
|
||
<TImage src={hero.image} eager sizes="(min-width: 1024px) 50vw, 100vw" />
|
||
</div>
|
||
{["Feste Bezugspersonen", "Alle Pflegekassen"].map((badge, i) => (
|
||
<span
|
||
key={badge}
|
||
className={cn("absolute flex items-center gap-2 rounded-2xl bg-t-bg px-4 py-3 font-bold shadow-xl", i === 0 ? "-bottom-5 left-4 md:-left-6" : "top-6 -right-2 md:-right-6")}
|
||
>
|
||
<CheckIcon className="size-5 text-t-accent" /> {badge}
|
||
</span>
|
||
))}
|
||
</motion.div>
|
||
</section>
|
||
|
||
<section id="leistungen" className="bg-t-surface py-20 md:py-28">
|
||
<div className={container}>
|
||
<RevealWords text={services.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.5rem)]")} />
|
||
<div className="mt-12 grid gap-5 md: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="h-full rounded-3xl bg-t-bg p-7 ring-2 ring-transparent transition-shadow hover:ring-t-accent">
|
||
<span className="grid size-14 place-items-center rounded-2xl bg-t-surface text-t-accent"><Icon className="size-7" /></span>
|
||
<h3 className={cn(heading, "mt-5 text-2xl")}>{item.title}</h3>
|
||
<p className="mt-2 text-lg leading-relaxed text-t-muted">{item.text}</p>
|
||
</article>
|
||
</Reveal>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{show("budget") && <Budget data-order="budget" budget={budget} />}
|
||
|
||
{show("steps") && (
|
||
<section data-order="steps" className={cn(container, "py-20 md:py-28")}>
|
||
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.5rem)]")} />
|
||
<ol className="mt-12 grid gap-6 md:grid-cols-4">
|
||
{steps.items.map((step, i) => (
|
||
<Reveal key={i} delay={i * 0.1}>
|
||
<li className="text-center">
|
||
<span className={cn(heading, "mx-auto grid size-16 place-items-center rounded-full bg-t-accent text-2xl text-t-accent-fg")}>{i + 1}</span>
|
||
<p className={cn(heading, "mt-4 text-xl")}>{step.title}</p>
|
||
<p className="mt-1 text-lg text-t-muted">{step.text}</p>
|
||
</li>
|
||
</Reveal>
|
||
))}
|
||
</ol>
|
||
</section>
|
||
)}
|
||
|
||
{show("team") && (
|
||
<section data-order="team" className="bg-t-surface py-20 md:py-28">
|
||
<div className={cn(container, "grid gap-12 lg:grid-cols-2 lg:items-center")}>
|
||
<div>
|
||
<RevealWords text={team.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.1]")} />
|
||
<Reveal delay={0.1}><p className="mt-6 text-xl leading-relaxed text-t-muted">{team.text}</p></Reveal>
|
||
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||
{team.members.map((member, i) => (
|
||
<Reveal key={i} delay={i * 0.06} className="text-center">
|
||
<div className="relative mx-auto aspect-square w-full overflow-hidden rounded-full">
|
||
<TImage src={member.image} alt={member.name} sizes="140px" />
|
||
</div>
|
||
<p className="mt-2 font-bold leading-tight">{member.name}</p>
|
||
<p className="text-sm text-t-muted">{member.role}</p>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Reveal delay={0.1}>
|
||
<div className="relative aspect-[4/3] overflow-hidden rounded-[2rem]">
|
||
<TImage src={team.image} sizes="(min-width: 1024px) 50vw, 100vw" />
|
||
</div>
|
||
</Reveal>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{show("testimonials") && (
|
||
<section data-order="testimonials" className={cn(container, "grid gap-6 py-20 md:grid-cols-2 md:py-28")}>
|
||
{testimonials.items.map((item, i) => (
|
||
<Reveal key={i} delay={i * 0.1}>
|
||
<figure className="h-full rounded-3xl border-2 border-t-fg/10 p-8">
|
||
<blockquote className="text-2xl leading-snug">„{item.quote}“</blockquote>
|
||
<figcaption className="mt-6 font-bold text-t-accent">{item.author}</figcaption>
|
||
</figure>
|
||
</Reveal>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{show("jobs") && (
|
||
<section data-order="jobs" className={cn(container, "pb-20")}>
|
||
<div className="flex flex-col items-start gap-6 rounded-[2rem] bg-t-fg p-8 text-t-bg md:flex-row md:items-center md:justify-between md:p-12">
|
||
<div>
|
||
<RevealWords text={jobs.title} emClassName={em} className={cn(heading, "text-3xl md:text-4xl")} />
|
||
<p className="mt-3 max-w-xl text-lg opacity-80">{jobs.text}</p>
|
||
</div>
|
||
<a href="#kontakt" className="shrink-0 rounded-full bg-t-accent px-7 py-4 text-lg font-bold text-t-accent-fg">{jobs.cta}</a>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section id="kontakt" className="bg-t-surface py-20 md:py-28">
|
||
<div className={cn(container, "grid gap-12 lg:grid-cols-2")}>
|
||
<div>
|
||
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
|
||
<p className="mt-5 text-xl text-t-muted">{contact.text}</p>
|
||
<a href={tel} className={cn(heading, "mt-8 flex items-center gap-3 text-3xl text-t-accent md:text-4xl")}>
|
||
<PhoneCallIcon className="size-8" /> {brand.phone}
|
||
</a>
|
||
<p className="mt-4 flex items-center gap-2 text-lg"><MapPinIcon className="size-5 text-t-accent" /> {brand.address}</p>
|
||
</div>
|
||
<DemoForm
|
||
className="grid gap-5 rounded-3xl bg-t-bg p-6 md:p-9"
|
||
success={<p className={cn(heading, "py-8 text-3xl")}>Vielen Dank! Wir rufen Sie <span className={em}>heute noch</span> zurück.</p>}
|
||
>
|
||
<p className={cn(heading, "text-2xl")}>Rückruf erbitten</p>
|
||
<TSelect label="Worum geht es?" name="topic" options={["Pflege für mich", "Pflege für Angehörige", "Beratung", "Bewerbung"]} />
|
||
<TField label="Ihr Name" name="name" required autoComplete="name" />
|
||
<TField label="Telefonnummer" name="phone" type="tel" required autoComplete="tel" />
|
||
<TSelect label="Wann sind Sie erreichbar?" name="time" options={["Vormittags", "Nachmittags", "Abends", "Egal"]} />
|
||
<button type="submit" className="cursor-pointer rounded-full bg-t-accent py-4 text-lg font-bold text-t-accent-fg">Rückruf anfordern</button>
|
||
</DemoForm>
|
||
</div>
|
||
</section>
|
||
|
||
<footer className={cn(container, "flex flex-col gap-3 py-10 text-t-muted md:flex-row md:justify-between")}>
|
||
<p>{brand.name} · {brand.suffix} · {brand.email}</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 AreaCheck({ areas }: { areas: string }) {
|
||
const [zip, setZip] = useState("")
|
||
const [result, setResult] = useState<boolean | null>(null)
|
||
const prefixes = splitList(areas)
|
||
|
||
return (
|
||
<form
|
||
className="mt-10 rounded-3xl bg-t-surface p-5"
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
setResult(prefixes.some((prefix) => zip.startsWith(prefix)))
|
||
}}
|
||
>
|
||
<label htmlFor="pflege-zip" className="font-bold">Sind wir in Ihrer Nähe? Postleitzahl prüfen:</label>
|
||
<div className="mt-3 flex gap-2">
|
||
<input
|
||
id="pflege-zip"
|
||
value={zip}
|
||
onChange={(e) => {
|
||
setZip(e.target.value.replace(/\D/g, "").slice(0, 5))
|
||
setResult(null)
|
||
}}
|
||
inputMode="numeric"
|
||
placeholder="z. B. 44135"
|
||
className="min-w-0 flex-1 rounded-full border-2 border-t-fg/15 bg-t-bg px-5 py-3 text-lg outline-none focus:border-t-accent"
|
||
/>
|
||
<button type="submit" disabled={zip.length < 5} className="cursor-pointer rounded-full bg-t-fg px-6 font-bold text-t-bg disabled:opacity-40">Prüfen</button>
|
||
</div>
|
||
<AnimatePresence mode="wait">
|
||
{result !== null && (
|
||
<motion.p
|
||
key={String(result)}
|
||
initial={{ opacity: 0, y: -6 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0 }}
|
||
className={cn("mt-3 flex items-center gap-2 font-bold", result ? "text-emerald-700" : "text-t-muted")}
|
||
>
|
||
{result ? <CheckIcon className="size-5" /> : <XIcon className="size-5" />}
|
||
{result ? "Gute Nachricht: Wir sind in Ihrer Gegend unterwegs!" : "Leider nicht in unserem Gebiet – rufen Sie uns trotzdem an, wir helfen gern weiter."}
|
||
</motion.p>
|
||
)}
|
||
</AnimatePresence>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function Budget({ budget }: { budget: PflegeData["budget"] }) {
|
||
const [index, setIndex] = useState(1)
|
||
const current = budget.items[Math.min(index, budget.items.length - 1)]
|
||
|
||
return (
|
||
<section className={cn(container, "py-20 md:py-28")}>
|
||
<div className="grid gap-10 rounded-[2rem] border-2 border-t-fg/10 p-6 md:p-12 lg:grid-cols-2 lg:items-center">
|
||
<div>
|
||
<RevealWords text={budget.title} emClassName={em} className={cn(heading, "text-[clamp(2rem,4vw,3.25rem)] leading-[1.1]")} />
|
||
<p className="mt-5 text-lg leading-relaxed text-t-muted">{budget.text}</p>
|
||
</div>
|
||
<div>
|
||
<div role="tablist" aria-label="Pflegegrad" className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{budget.items.map((item, i) => (
|
||
<button
|
||
key={item.grade}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={index === i}
|
||
onClick={() => setIndex(i)}
|
||
className={cn("cursor-pointer rounded-2xl px-3 py-3 font-bold transition-colors", index === i ? "bg-t-accent text-t-accent-fg" : "bg-t-surface hover:bg-t-fg/10")}
|
||
>
|
||
{item.grade}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{current && (
|
||
<div className="mt-6 rounded-3xl bg-t-surface p-7 text-center">
|
||
<p className="text-lg text-t-muted">bis zu</p>
|
||
<motion.p key={current.amount} initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className={cn(heading, "text-6xl text-t-accent")}>
|
||
{current.amount} €
|
||
</motion.p>
|
||
<p className="mt-1 text-lg">pro Monat für Pflege durch uns</p>
|
||
</div>
|
||
)}
|
||
<p className="mt-3 text-sm text-t-muted">{budget.note}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|