since3-website/modules/landingPage/lib/pricing.ts
2026-09-25 00:45:33 +02:00

152 lines
4.9 KiB
TypeScript
Raw Permalink 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.

// Pure pricing logic for the calculator. No React, so it is easy to test.
import {
pricing,
type ComplexityId,
type ProjectType,
type ProjectTypeId,
} from "@/modules/landingPage/data/pricing"
export type CalculatorState = {
type: ProjectTypeId
pages: number
complexity: ComplexityId
extras: string[]
monthly: string[]
express: boolean
}
export type Estimate = {
low: number
high: number
monthly: number
weeks: [number, number]
lines: { label: string; amount: number }[]
}
export const initialCalculatorState: CalculatorState = {
type: "website",
pages: 5,
complexity: "small",
extras: [],
monthly: ["care"],
express: false,
}
export function getProjectType(id: ProjectTypeId): ProjectType {
return pricing.projectTypes.find((type) => type.id === id) ?? pricing.projectTypes[0]
}
export function extrasFor(id: ProjectTypeId) {
return pricing.extras.filter((extra) => (extra.for as ProjectTypeId[]).includes(id))
}
function roundTo(value: number, step: number) {
return Math.round(value / step) * step
}
// Converts net prices into what the visitor should see
export function toDisplayPrice(value: number) {
return pricing.vat === "gross" ? value * (1 + pricing.vatRate) : value
}
export function estimate(state: CalculatorState): Estimate {
const type = getProjectType(state.type)
const lines: Estimate["lines"] = [{ label: type.label, amount: type.base }]
let project = type.base
let weeks: [number, number] = [...type.weeks]
if (type.pages) {
const extraPages = Math.max(0, state.pages - type.pages.included)
if (extraPages > 0) {
const amount = extraPages * type.pages.pricePerPage
project += amount
lines.push({ label: `${extraPages} weitere Seiten`, amount })
// Roughly one more week per ten extra pages
const extraWeeks = Math.ceil(extraPages / 10)
weeks = [weeks[0] + extraWeeks, weeks[1] + extraWeeks]
}
}
if (type.sizedByComplexity) {
const level = pricing.complexity.find((c) => c.id === state.complexity) ?? pricing.complexity[0]
if (level.factor !== 1) {
const amount = project * (level.factor - 1)
project += amount
lines.push({ label: `Umfang: ${level.label}`, amount })
weeks = [Math.round(weeks[0] * level.factor), Math.round(weeks[1] * level.factor)]
}
}
// Only extras that exist for this project type count
for (const extra of extrasFor(state.type)) {
if (state.extras.includes(extra.id)) {
project += extra.price
lines.push({ label: extra.label, amount: extra.price })
}
}
if (state.express) {
const amount = project * (pricing.express.factor - 1)
project += amount
lines.push({ label: "Express-Zuschlag", amount })
weeks = [
Math.max(1, Math.round(weeks[0] * pricing.express.weeksFactor)),
Math.max(1, Math.round(weeks[1] * pricing.express.weeksFactor)),
]
}
const monthly = pricing.monthly
.filter((service) => state.monthly.includes(service.id))
.reduce((sum, service) => sum + service.price, 0)
return {
low: roundTo(toDisplayPrice(project * pricing.spread.low), pricing.roundTo),
high: roundTo(toDisplayPrice(project * pricing.spread.high), pricing.roundTo),
monthly: toDisplayPrice(monthly),
weeks,
lines: lines.map((line) => ({ ...line, amount: toDisplayPrice(line.amount) })),
}
}
const currency = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})
const plain = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 })
export function formatEuro(value: number) {
return currency.format(Math.round(value))
}
export function formatNumber(value: number) {
return plain.format(Math.round(value))
}
export const vatNote = {
net: "Netto, zzgl. 19 % MwSt. Angebot für Unternehmen.",
gross: "Inkl. 19 % MwSt.",
"small-business": "Gemäß §19 UStG wird keine Umsatzsteuer berechnet.",
}[pricing.vat]
/** Plain-text summary that prefills the contact form. */
export function summarize(state: CalculatorState, result: Estimate) {
const type = getProjectType(state.type)
const parts = [`Projekt: ${type.label}`]
if (type.pages) parts.push(`Seiten: ca. ${state.pages}`)
if (type.sizedByComplexity) {
const level = pricing.complexity.find((c) => c.id === state.complexity)
if (level) parts.push(`Umfang: ${level.label}`)
}
const extras = extrasFor(state.type).filter((extra) => state.extras.includes(extra.id))
if (extras.length) parts.push(`Extras: ${extras.map((extra) => extra.label).join(", ")}`)
const monthly = pricing.monthly.filter((service) => state.monthly.includes(service.id))
if (monthly.length) parts.push(`Laufend: ${monthly.map((service) => service.label).join(", ")}`)
if (state.express) parts.push("Tempo: Express")
parts.push(`Schätzung laut Preisrechner: ${formatEuro(result.low)} – ${formatEuro(result.high)}`)
return `Hallo since3,\n\n${parts.join("\n")}\n\nKurz zu meinem Vorhaben: `
}