since3-portal/modules/templates/catalog/karriere/template.tsx
2026-10-05 08:23:02 +02:00

443 lines
27 KiB
TypeScript

"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import {
ArrowRightIcon,
BanknoteIcon,
BriefcaseIcon,
CalendarDaysIcon,
CheckIcon,
GraduationCapIcon,
MapPinIcon,
PartyPopperIcon,
PlayIcon,
PlusIcon,
TabletSmartphoneIcon,
TruckIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { TField, TSelect } from "../shared/fields"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Counter, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { karriere, type KarriereData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1320px] px-5 md:px-8"
const heading = "font-t-display font-bold tracking-[-0.03em]"
const icons = [BanknoteIcon, CalendarDaysIcon, TruckIcon, GraduationCapIcon, PartyPopperIcon, TabletSmartphoneIcon]
const INITIATIVE = "Initiativ bewerben"
function Eyebrow({ children }: { children: string }) {
return <p className="mb-4 inline-block -rotate-1 rounded-lg bg-t-accent px-3 py-1 text-sm font-bold text-t-accent-fg">{children}</p>
}
export function KarriereTemplate({ content }: TemplateProps<KarriereData>) {
const { brand, hero, benefits, facts, jobs, voices, process, apply } = content.data
const show = (key: string) => !content.hidden.includes(key)
const [job, setJob] = useState<string | null>(null)
const links = [
{ href: "#benefits", label: "Benefits", visible: show("benefits") },
{ href: "#stellen", label: "Stellen", visible: true },
{ href: "#team", label: "Team", visible: show("voices") },
].filter((link) => link.visible)
return (
<TemplateShell def={karriere} content={content} fonts={fonts[content.fonts] ?? fonts.freundlich}>
<Nav name={brand.name} links={links} />
<Hero hero={hero} openJobs={jobs.items.length} />
{show("benefits") && (
<section id="benefits" className={cn(container, "py-20 md:py-28")}>
<Reveal><Eyebrow>{benefits.eyebrow}</Eyebrow></Reveal>
<RevealWords text={benefits.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{benefits.items.map((item, i) => {
const Icon = icons[i % icons.length]
return (
<Reveal key={i} delay={(i % 3) * 0.08}>
<motion.article
whileHover={{ rotate: i % 2 ? 1.5 : -1.5, y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 18 }}
className="h-full rounded-3xl border-2 border-t-fg/10 bg-t-bg p-7 transition-colors hover:border-t-accent"
>
<span className="grid size-12 place-items-center rounded-2xl bg-t-accent text-t-accent-fg">
<Icon className="size-6" />
</span>
<h3 className={cn(heading, "mt-5 text-xl")}>{item.title}</h3>
<p className="mt-2 text-t-muted">{item.text}</p>
</motion.article>
</Reveal>
)
})}
</div>
</section>
)}
{show("facts") && (
<section className="bg-t-fg py-14 text-t-bg">
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
{facts.items.map((fact, i) => (
<div key={i}>
<dd className={cn(heading, "text-5xl md:text-6xl")}>
<Counter value={fact.value} />
<span className="text-t-accent">{fact.suffix}</span>
</dd>
<dt className="mt-1 text-sm opacity-70">{fact.label}</dt>
</div>
))}
</dl>
</section>
)}
<Jobs jobs={jobs} onApply={setJob} />
{show("voices") && (
<section id="team" className="bg-t-surface py-20 md:py-28">
<div className={container}>
<Reveal><Eyebrow>{voices.eyebrow}</Eyebrow></Reveal>
<RevealWords text={voices.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
<div className="mt-12 grid gap-5 md:grid-cols-3">
{voices.items.map((voice, i) => (
<Reveal key={i} delay={i * 0.1}>
<figure className="group h-full overflow-hidden rounded-3xl bg-t-bg">
<div className="relative aspect-[4/3] overflow-hidden">
<TImage src={voice.image} alt={voice.name} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
<span className="absolute bottom-4 left-4 grid size-12 place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-lg transition-transform group-hover:scale-110">
<PlayIcon className="ml-0.5 size-5 fill-current" />
</span>
</div>
<blockquote className="p-6 text-lg leading-snug">„{voice.quote}“</blockquote>
<figcaption className="px-6 pb-6 text-sm">
<span className="font-bold">{voice.name}</span> <span className="text-t-muted">· {voice.role}</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
)}
{show("process") && (
<section className={cn(container, "py-20 md:py-28")}>
<Reveal><Eyebrow>{process.eyebrow}</Eyebrow></Reveal>
<RevealWords text={process.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
<ol className="relative mt-14 grid gap-10 md:grid-cols-4 md:gap-6">
<span aria-hidden className="absolute top-7 right-[12%] left-[12%] hidden border-t-2 border-dashed border-t-fg/20 md:block" />
{process.items.map((step, i) => (
<Reveal key={i} delay={i * 0.12}>
<li className="relative md:text-center">
<span className={cn(heading, "relative grid size-14 place-items-center rounded-full bg-t-accent text-xl text-t-accent-fg md:mx-auto")}>{i + 1}</span>
<p className={cn(heading, "mt-5 text-xl")}>{step.title}</p>
<p className="mt-1 text-t-muted">{step.text}</p>
</li>
</Reveal>
))}
</ol>
</section>
)}
<Apply apply={apply} jobs={jobs.items} job={job} onJob={setJob} />
<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} · {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 Nav({ name, links }: { name: string; 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 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
<a href="#top" className="flex items-center gap-2">
<span className={cn(heading, "text-xl")}>{name}</span>
<span className="rounded-full bg-t-accent px-2.5 py-0.5 text-xs font-bold text-t-accent-fg">Karriere</span>
</a>
<div className="hidden items-center gap-8 font-medium 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="#bewerben" className="rounded-full bg-t-fg px-5 py-2.5 text-sm font-bold text-t-bg transition-transform hover:scale-105">Jetzt bewerben</a>
</nav>
</motion.header>
)
}
function Hero({ hero, openJobs }: { hero: KarriereData["hero"]; openJobs: number }) {
const roles = splitList(hero.roles)
const [index, setIndex] = useState(0)
useEffect(() => {
if (roles.length < 2) return
const timer = setInterval(() => setIndex((i) => i + 1), 2200)
return () => clearInterval(timer)
}, [roles.length])
const role = roles.length ? roles[index % roles.length] : ""
return (
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-20 lg:grid-cols-[1.1fr_1fr] lg:pt-40")}>
<div>
<motion.p initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className="mb-6 inline-flex items-center gap-2 rounded-full border-2 border-t-fg/10 px-4 py-1.5 text-sm font-medium">
<span className="size-2 animate-pulse rounded-full bg-emerald-500" /> {hero.eyebrow}
</motion.p>
<RevealWords as="h1" onMount delay={0.15} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(3.25rem,8vw,7.5rem)] leading-[0.92]")} />
{role && (
<div className={cn(heading, "mt-6 flex flex-wrap items-center gap-3 text-2xl md:text-3xl")}>
<span className="text-t-muted">Wir suchen</span>
<span className="relative inline-flex h-[1.4em] overflow-hidden rounded-xl bg-t-accent px-3 text-t-accent-fg">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={role} initial={{ y: "100%" }} animate={{ y: "0%" }} exit={{ y: "-100%" }} transition={{ duration: 0.45, ease }} className="leading-[1.4em]">
{role}
</motion.span>
</AnimatePresence>
</span>
</div>
)}
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
<p className="mt-8 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-9 flex flex-wrap gap-3">
<a href="#stellen" className="inline-flex items-center gap-2 rounded-full bg-t-fg px-7 py-4 font-bold text-t-bg transition-transform hover:scale-105">
{hero.cta} <span className="rounded-full bg-t-accent px-2 text-sm text-t-accent-fg">{openJobs}</span>
</a>
<a href="#bewerben" className="inline-flex items-center gap-2 rounded-full border-2 border-t-fg px-7 py-4 font-bold transition-colors hover:bg-t-fg hover:text-t-bg">
{hero.ctaSecondary} <ArrowRightIcon className="size-4" />
</a>
</div>
</motion.div>
</div>
<div className="relative mx-auto aspect-square w-full max-w-[560px]">
{[
{ src: hero.imageSecondary, className: "top-0 left-0 w-[46%] -rotate-6", delay: 0.4 },
{ src: hero.image, className: "top-[12%] right-0 w-[58%] rotate-3", delay: 0.25 },
{ src: hero.imageThird, className: "bottom-0 left-[14%] w-[52%] -rotate-2", delay: 0.55 },
].map((photo, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 40, rotate: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: photo.delay }}
className={cn("absolute aspect-[4/5] overflow-hidden rounded-3xl border-[6px] border-t-bg bg-t-surface shadow-xl", photo.className)}
>
<TImage src={photo.src} eager={i === 1} sizes="(min-width: 1024px) 30vw, 60vw" />
</motion.div>
))}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1, rotate: [0, 360] }}
transition={{ scale: { type: "spring", bounce: 0.4, delay: 1 }, rotate: { duration: 22, repeat: Infinity, ease: "linear" } }}
className="absolute right-[6%] bottom-[8%] grid size-28 place-items-center rounded-full bg-t-accent p-3 text-center text-sm leading-tight font-black text-t-accent-fg shadow-xl md:size-32"
>
{hero.badge}
</motion.div>
</div>
</section>
)
}
function Jobs({ jobs, onApply }: { jobs: KarriereData["jobs"]; onApply: (job: string) => void }) {
const types = ["Alle", ...new Set(jobs.items.map((item) => item.type))]
const [filter, setFilter] = useState("Alle")
const [open, setOpen] = useState<number | null>(0)
const visible = jobs.items.map((item, i) => ({ item, i })).filter(({ item }) => filter === "Alle" || item.type === filter)
return (
<section id="stellen" className={cn(container, "py-20 md:py-28")}>
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><Eyebrow>{jobs.eyebrow}</Eyebrow></Reveal>
<RevealWords text={jobs.title} emClassName={em} className={cn(heading, "text-[clamp(2.4rem,5vw,4.5rem)] leading-[1]")} />
</div>
<div className="flex flex-wrap gap-2">
{types.map((type) => (
<button
key={type}
type="button"
aria-pressed={filter === type}
onClick={() => setFilter(type)}
className={cn("cursor-pointer rounded-full border-2 px-4 py-2 text-sm font-bold transition-colors", filter === type ? "border-t-fg bg-t-fg text-t-bg" : "border-t-fg/15 hover:border-t-fg")}
>
{type}
</button>
))}
</div>
</div>
<ul className="mt-10 space-y-3">
{visible.map(({ item, i }) => {
const isOpen = open === i
return (
<motion.li layout key={item.title} className={cn("overflow-hidden rounded-3xl border-2 transition-colors", isOpen ? "border-t-fg" : "border-t-fg/10")}>
<button type="button" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? null : i)} className="flex w-full cursor-pointer items-center gap-4 p-5 text-left md:p-7">
<span className="hidden size-12 shrink-0 place-items-center rounded-2xl bg-t-surface sm:grid">
<BriefcaseIcon className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className={cn(heading, "block text-lg md:text-2xl")}>{item.title}</span>
<span className="mt-2 flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-t-accent px-2.5 py-1 text-t-accent-fg">{item.type}</span>
<span className="rounded-full bg-t-surface px-2.5 py-1">{item.area}</span>
<span className="flex items-center gap-1 rounded-full bg-t-surface px-2.5 py-1"><MapPinIcon className="size-3" /> {item.location}</span>
</span>
</span>
<PlusIcon className={cn("size-6 shrink-0 transition-transform duration-300", isOpen && "rotate-45")} />
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }} transition={{ duration: 0.4, ease }}>
<div className="flex flex-col gap-6 px-5 pb-6 md:flex-row md:items-end md:justify-between md:px-7 md:pb-7">
<ul className="space-y-2">
{splitList(item.tasks, "\n").map((task) => (
<li key={task} className="flex items-start gap-2.5">
<CheckIcon className="mt-1 size-4 shrink-0 text-t-accent" /> {task}
</li>
))}
</ul>
<a href="#bewerben" onClick={() => onApply(item.title)} className="inline-flex shrink-0 items-center gap-2 rounded-full bg-t-accent px-6 py-3.5 font-bold text-t-accent-fg">
Jetzt bewerben <ArrowRightIcon className="size-4" />
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.li>
)
})}
</ul>
</section>
)
}
function Apply({ apply, jobs, job, onJob }: { apply: KarriereData["apply"]; jobs: KarriereData["jobs"]["items"]; job: string | null; onJob: (job: string | null) => void }) {
const [step, setStep] = useState(0)
const [experience, setExperience] = useState<string | null>(null)
const [done, setDone] = useState(false)
const levels = splitList(apply.experience)
const current = job && step === 0 ? 1 : step
const progress = done ? 100 : ((current + 1) / 3) * 100
return (
<section id="bewerben" className="bg-t-accent py-20 text-t-accent-fg md:py-28">
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-2")}>
<div>
<RevealWords text={apply.title} emClassName="underline decoration-4 underline-offset-8" className={cn(heading, "text-[clamp(2.6rem,6vw,5.5rem)] leading-[0.95]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg opacity-80">{apply.text}</p></Reveal>
</div>
<div className="relative rounded-[2rem] bg-t-bg p-6 text-t-fg shadow-2xl md:p-9">
<div className="mb-8 h-2 overflow-hidden rounded-full bg-t-surface">
<motion.div className="h-full rounded-full bg-t-accent" animate={{ width: `${progress}%` }} transition={{ duration: 0.5, ease }} />
</div>
{done ? (
<div className="relative py-10 text-center">
{Array.from({ length: 18 }, (_, i) => {
const angle = (i / 18) * Math.PI * 2
return (
<motion.span
key={i}
aria-hidden
className={cn("absolute top-1/2 left-1/2 size-2.5 rounded-sm", i % 3 === 0 ? "bg-t-accent" : i % 3 === 1 ? "bg-t-fg" : "bg-t-muted")}
initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }}
animate={{ x: Math.cos(angle) * 160, y: Math.sin(angle) * 110, opacity: 0, rotate: 200 }}
transition={{ duration: 1.2, ease: "easeOut" }}
/>
)
})}
<p className={cn(heading, "text-4xl")}>Stark! 🎉</p>
<p className="mt-3 text-t-muted">Wir melden uns innerhalb von 48 Stunden bei dir.</p>
<p className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Bewerbung direkt im Postfach der Personalabteilung.</p>
</div>
) : (
<StepPanel step={current}>
{current === 0 && (
<div>
<p className={cn(heading, "text-2xl")}>Welche Stelle?</p>
<div className="mt-5 grid gap-2">
{[...jobs.map((item) => item.title), INITIATIVE].map((title) => (
<button
key={title}
type="button"
onClick={() => {
onJob(title)
setStep(1)
}}
className="flex cursor-pointer items-center justify-between rounded-2xl border-2 border-t-fg/10 px-4 py-3.5 text-left font-medium transition-colors hover:border-t-accent"
>
{title} <ArrowRightIcon className="size-4 shrink-0" />
</button>
))}
</div>
</div>
)}
{current === 1 && (
<div>
<p className="text-sm text-t-muted">{job}</p>
<p className={cn(heading, "mt-1 text-2xl")}>Wie viel Erfahrung bringst du mit?</p>
<div className="mt-5 grid grid-cols-2 gap-2">
{levels.map((level) => (
<button
key={level}
type="button"
onClick={() => {
setExperience(level)
setStep(2)
}}
className={cn("cursor-pointer rounded-2xl border-2 px-4 py-5 font-bold transition-colors", experience === level ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/10 hover:border-t-accent")}
>
{level}
</button>
))}
</div>
<button
type="button"
onClick={() => {
onJob(null)
setStep(0)
}}
className="mt-5 cursor-pointer text-sm text-t-muted hover:text-t-fg"
>
Andere Stelle wählen
</button>
</div>
)}
{current === 2 && (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
setDone(true)
}}
>
<p className={cn(heading, "text-2xl")}>Wie erreichen wir dich?</p>
<TField label="Vorname und Name" name="name" required autoComplete="name" variant="soft" />
<TField label="Handynummer" name="phone" type="tel" required autoComplete="tel" variant="soft" />
<TSelect label="Wann passt ein Anruf?" name="time" options={["Vormittags", "Mittags", "Nachmittags", "Abends"]} variant="soft" />
<button type="submit" className="mt-2 inline-flex cursor-pointer items-center justify-center gap-2 rounded-full bg-t-fg py-4 font-bold text-t-bg">
Bewerbung abschicken <ArrowRightIcon className="size-4" />
</button>
<button type="button" onClick={() => setStep(1)} className="cursor-pointer text-sm text-t-muted hover:text-t-fg">Zurück</button>
</form>
)}
</StepPanel>
)}
</div>
</div>
</section>
)
}