"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(initialCalculatorState) const rootRef = useRef(null) const resultRef = useRef(null) const [barVisible, setBarVisible] = useState(false) const type = getProjectType(state.type) const result = estimate(state) const extras = extrasFor(state.type) const update = (patch: Partial) => 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 (
{pricing.projectTypes.map((option) => ( ))}
{type.pages ? (
{state.pages} {state.pages === 1 ? "Seite" : "Seiten"}
{ 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" />

{type.pages.included} Seiten inklusive, jede weitere{" "} {formatEuro(toDisplayPrice(type.pages.pricePerPage))}

) : null} {type.sizedByComplexity ? (
{pricing.complexity.map((level) => ( ))}
) : null}
{extras.map((extra) => ( ))}
    {pricing.included.map((item) => (
  • {item} (immer inklusive)
  • ))}
{pricing.monthly.map((service) => ( ))}
{[ { 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) => ( ))}
{/* Result */}

(Deine Schätzung)

{pricing.isExample ? ( Beispielpreise ) : null}

–

Dauer
ca. {result.weeks[0]}–{result.weeks[1]} Wochen
Laufend
{result.monthly ? `${formatEuro(result.monthly)} / Monat` : "keine"}

Zusammensetzung (Richtwert)

    {result.lines.map((line) => (
  • {line.label} {formatEuro(line.amount)}
  • ))}

{vatNote} Unverbindliche Schätzung. Den verbindlichen Festpreis bekommst du nach dem kostenlosen Erstgespräch.

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 {/* One calm announcement instead of every animation frame */}

Geschätzt {formatNumber(result.low)} bis {formatEuro(result.high)}, Dauer etwa{" "} {result.weeks[0]} bis {result.weeks[1]} Wochen.

{/* Mobile: keeps the estimate in sight while choosing */} {formatNumber(result.low)} – {formatEuro(result.high)} Zur Schätzung
) } function Step({ index, title, hint, children, }: { index: number title: string hint?: string children: React.ReactNode }) { return (
{String(index).padStart(2, "0")} {title} {hint ? ( {hint} ) : null}
{children}
) }