mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 20:51:17 +00:00
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
// 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: `
|
||
}
|