mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 19:51:16 +00:00
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
"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>
|
||
)
|
||
}
|