since3-website/modules/landingPage/ui/components/pricing/price-calculator.tsx
2026-09-25 00:45:33 +02:00

376 lines
15 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 { useEffect, useId, useRef, useState } from "react"
import { ArrowDown, ArrowRight, Check, Plus } from "lucide-react"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import { pricing, type ProjectTypeId } from "@/modules/landingPage/data/pricing"
import { prefillContact } from "@/modules/landingPage/lib/contact-prefill"
import {
estimate,
extrasFor,
formatEuro,
formatNumber,
getProjectType,
initialCalculatorState,
summarize,
toDisplayPrice,
vatNote,
type CalculatorState,
} from "@/modules/landingPage/lib/pricing"
import { AnimatedNumber } from "./animated-number"
// Selectable tile: the real input is visually hidden, the label shows its state
const tileClass =
"group relative flex cursor-pointer flex-col gap-1 rounded-2xl border border-line p-5 transition-[background-color,border-color,color] duration-300 hover:border-fg/60 has-checked:border-fg has-checked:bg-fg has-checked:text-bg has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg"
const tileMuted = "text-fg-muted transition-colors duration-300 group-has-checked:text-bg/70"
export function PriceCalculator({ className }: { className?: string }) {
const id = useId()
const [state, setState] = useState<CalculatorState>(initialCalculatorState)
const rootRef = useRef<HTMLDivElement>(null)
const resultRef = useRef<HTMLDivElement>(null)
const [barVisible, setBarVisible] = useState(false)
const type = getProjectType(state.type)
const result = estimate(state)
const extras = extrasFor(state.type)
const update = (patch: Partial<CalculatorState>) => setState((current) => ({ ...current, ...patch }))
const toggleIn = (key: "extras" | "monthly", value: string) =>
setState((current) => ({
...current,
[key]: current[key].includes(value)
? current[key].filter((item) => item !== value)
: [...current[key], value],
}))
const selectType = (next: ProjectTypeId) => {
const nextType = getProjectType(next)
update({
type: next,
pages: nextType.pages ? Math.min(Math.max(state.pages, 1), nextType.pages.max) : state.pages,
})
}
// Mobile: a floating price bar while the calculator is on screen but the result card is not
useEffect(() => {
const root = rootRef.current
const card = resultRef.current
if (!root || !card) return
let rootVisible = false
let cardVisible = false
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.target === root) rootVisible = entry.isIntersecting
if (entry.target === card) cardVisible = entry.isIntersecting
}
setBarVisible(rootVisible && !cardVisible)
})
observer.observe(root)
observer.observe(card)
return () => observer.disconnect()
}, [])
// Steps adapt to the project type, so their numbers are computed
let step = 0
const next = () => ++step
return (
<div ref={rootRef} className={cn("grid gap-12 lg:grid-cols-12", className)}>
<div className="lg:col-span-7">
<Step index={next()} title="Was brauchst du?">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{pricing.projectTypes.map((option) => (
<label key={option.id} className={tileClass}>
<input
type="radio"
name={`${id}-type`}
value={option.id}
checked={state.type === option.id}
onChange={() => selectType(option.id)}
className="sr-only"
/>
<span className="flex items-start justify-between gap-3">
<span className="text-xl leading-tight font-semibold">{option.label}</span>
<Check
aria-hidden
className="size-4 shrink-0 opacity-0 transition-opacity group-has-checked:opacity-100"
/>
</span>
<span className={cn("text-sm", tileMuted)}>{option.description}</span>
<span className={cn("mt-3 font-mono text-label", tileMuted)}>
ab {formatEuro(toDisplayPrice(option.base))}
</span>
</label>
))}
</div>
</Step>
{type.pages ? (
<Step index={next()} title="Wie viele Seiten?">
<div className="flex items-end justify-between gap-4">
<output
htmlFor={`${id}-pages`}
className="text-display leading-none tabular-nums type-poster"
>
{state.pages}
</output>
<span className="pb-2 font-mono text-label text-fg-muted">
{state.pages === 1 ? "Seite" : "Seiten"}
</span>
</div>
<input
id={`${id}-pages`}
type="range"
min={1}
max={type.pages.max}
value={state.pages}
onChange={(event) => {
update({ pages: Number(event.target.value) })
// A tick per step makes the slider feel mechanical
play("hover")
}}
aria-label="Anzahl Seiten"
className="mt-6 h-2 w-full cursor-pointer accent-fg"
/>
<p className="mt-3 font-mono text-label text-fg-muted">
{type.pages.included} Seiten inklusive, jede weitere{" "}
{formatEuro(toDisplayPrice(type.pages.pricePerPage))}
</p>
</Step>
) : null}
{type.sizedByComplexity ? (
<Step index={next()} title="Wie umfangreich?">
<div className="grid gap-3 sm:grid-cols-3">
{pricing.complexity.map((level) => (
<label key={level.id} className={tileClass}>
<input
type="radio"
name={`${id}-complexity`}
value={level.id}
checked={state.complexity === level.id}
onChange={() => update({ complexity: level.id })}
className="sr-only"
/>
<span className="text-lg font-semibold">{level.label}</span>
<span className={cn("text-sm", tileMuted)}>{level.hint}</span>
</label>
))}
</div>
</Step>
) : null}
<Step index={next()} title="Was soll dazu?" hint="Mehrfachauswahl">
<div className="flex flex-wrap gap-2">
{extras.map((extra) => (
<label
key={extra.id}
className="group flex cursor-pointer items-center gap-2.5 rounded-full border border-line py-2.5 pr-4 pl-3 text-sm transition-[background-color,border-color,color] duration-300 hover:border-fg/60 has-checked:border-fg has-checked:bg-fg has-checked:text-bg has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg"
>
<input
type="checkbox"
checked={state.extras.includes(extra.id)}
onChange={() => toggleIn("extras", extra.id)}
className="sr-only"
/>
<Plus
aria-hidden
className="size-3.5 transition-transform duration-300 ease-out-expo group-has-checked:rotate-45"
/>
{extra.label}
<span className="font-mono text-label opacity-60">
+{formatEuro(toDisplayPrice(extra.price))}
</span>
</label>
))}
</div>
<ul className="mt-6 flex flex-wrap gap-x-5 gap-y-2 font-mono text-label text-fg-muted">
{pricing.included.map((item) => (
<li key={item} className="flex items-center gap-1.5">
<Check aria-hidden className="size-3.5 text-fg" />
{item}
<span className="sr-only"> (immer inklusive)</span>
</li>
))}
</ul>
</Step>
<Step index={next()} title="Betreuung danach?" hint="Optional, monatlich">
<div className="grid gap-3 sm:grid-cols-2">
{pricing.monthly.map((service) => (
<label key={service.id} className={tileClass}>
<input
type="checkbox"
checked={state.monthly.includes(service.id)}
onChange={() => toggleIn("monthly", service.id)}
className="sr-only"
/>
<span className="flex items-start justify-between gap-3">
<span className="text-lg font-semibold">{service.label}</span>
<Check
aria-hidden
className="size-4 shrink-0 opacity-0 transition-opacity group-has-checked:opacity-100"
/>
</span>
<span className={cn("text-sm", tileMuted)}>{service.description}</span>
<span className={cn("mt-3 font-mono text-label", tileMuted)}>
{formatEuro(toDisplayPrice(service.price))} / Monat
</span>
</label>
))}
</div>
</Step>
<Step index={next()} title="Wie schnell?">
<div className="grid gap-3 sm:grid-cols-2">
{[
{ express: false, label: "Standard", hint: "Normale Planung, kein Aufpreis" },
{
express: true,
label: pricing.express.label,
hint: `${pricing.express.hint}, +${Math.round((pricing.express.factor - 1) * 100)} %`,
},
].map((option) => (
<label key={option.label} className={tileClass}>
<input
type="radio"
name={`${id}-tempo`}
checked={state.express === option.express}
onChange={() => update({ express: option.express })}
className="sr-only"
/>
<span className="text-lg font-semibold">{option.label}</span>
<span className={cn("text-sm", tileMuted)}>{option.hint}</span>
</label>
))}
</div>
</Step>
</div>
{/* Result */}
<div className="lg:col-span-5">
<div
ref={resultRef}
id="preis-ergebnis"
data-theme="dark"
className="rounded-glass p-6 sm:p-8 lg:sticky lg:top-28"
>
<div className="flex items-center justify-between gap-4">
<p className="font-mono text-label text-fg-muted">(Deine Schätzung)</p>
{pricing.isExample ? (
<span className="rounded-full border border-line px-3 py-1 font-mono text-label text-fg-muted">
Beispielpreise
</span>
) : null}
</div>
<p className="mt-6 text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.9] tabular-nums type-poster">
<AnimatedNumber value={result.low} format={formatNumber} />
<span className="text-fg-muted"> – </span>
<br className="sm:hidden" />
<AnimatedNumber value={result.high} format={formatEuro} />
</p>
<dl className="mt-6 grid grid-cols-2 gap-4 border-y border-line py-4 font-mono text-label">
<div>
<dt className="text-fg-muted">Dauer</dt>
<dd className="mt-1">
ca. {result.weeks[0]}–{result.weeks[1]} Wochen
</dd>
</div>
<div>
<dt className="text-fg-muted">Laufend</dt>
<dd className="mt-1">
{result.monthly ? `${formatEuro(result.monthly)} / Monat` : "keine"}
</dd>
</div>
</dl>
<p className="mt-4 font-mono text-label text-fg-muted">Zusammensetzung (Richtwert)</p>
<ul className="mt-2 flex flex-col gap-1.5 font-mono text-label">
{result.lines.map((line) => (
<li key={line.label} className="flex justify-between gap-4">
<span className="text-fg-muted">{line.label}</span>
<span className="tabular-nums">{formatEuro(line.amount)}</span>
</li>
))}
</ul>
<p className="mt-6 text-xs leading-relaxed text-fg-muted">
{vatNote} Unverbindliche Schätzung. Den verbindlichen Festpreis bekommst du
nach dem kostenlosen Erstgespräch.
</p>
<a
href="#kontakt"
onClick={() => prefillContact(summarize(state, result))}
className="group mt-6 flex h-14 w-full items-center justify-center gap-2 rounded-full bg-white font-medium text-black transition-[scale] duration-500 ease-out-expo hover:scale-[1.02]"
>
Mit dieser Auswahl anfragen
<ArrowRight
aria-hidden
className="size-4 transition-transform duration-300 group-hover:translate-x-1"
/>
</a>
{/* One calm announcement instead of every animation frame */}
<p className="sr-only" aria-live="polite">
Geschätzt {formatNumber(result.low)} bis {formatEuro(result.high)}, Dauer etwa{" "}
{result.weeks[0]} bis {result.weeks[1]} Wochen.
</p>
</div>
</div>
{/* Mobile: keeps the estimate in sight while choosing */}
<a
href="#preis-ergebnis"
inert={!barVisible}
aria-hidden={!barVisible}
className={cn(
"fixed inset-x-4 bottom-4 z-70 flex items-center justify-between gap-4 rounded-full bg-black py-3 pr-3 pl-6 text-white shadow-[0_20px_60px_-10px_rgb(0_0_0/0.6)] transition-[translate,opacity] duration-500 ease-out-expo lg:hidden",
barVisible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-[150%] opacity-0"
)}
>
<span className="text-xl tabular-nums type-poster">
{formatNumber(result.low)} – {formatEuro(result.high)}
</span>
<span className="grid size-10 place-items-center rounded-full bg-white text-black">
<ArrowDown aria-hidden className="size-4" />
<span className="sr-only">Zur Schätzung</span>
</span>
</a>
</div>
)
}
function Step({
index,
title,
hint,
children,
}: {
index: number
title: string
hint?: string
children: React.ReactNode
}) {
return (
<fieldset className="min-w-0 border-t border-line py-8 first:border-t-0 first:pt-0">
<legend className="float-left mb-6 flex w-full items-baseline gap-4">
<span className="font-mono text-label text-fg-muted">
{String(index).padStart(2, "0")}
</span>
<span className="type-title">{title}</span>
{hint ? (
<span className="ml-auto hidden font-mono text-label text-fg-muted sm:inline">{hint}</span>
) : null}
</legend>
<div className="clear-both">{children}</div>
</fieldset>
)
}