feat: Centralize pricing calculations into a new calculateTotals utility function and Totals interface.

This commit is contained in:
2026-02-04 16:59:30 +01:00
parent 4bba61078a
commit 39006c16b1
6 changed files with 95 additions and 78 deletions

View File

@@ -4,7 +4,7 @@ import * as readline from 'node:readline/promises';
import { fileURLToPath } from 'node:url';
import { createElement } from 'react';
import { renderToFile } from '@react-pdf/renderer';
import { calculatePositions } from '../src/logic/pricing/calculator.js';
import { calculatePositions, calculateTotals } from '../src/logic/pricing/calculator.js';
import { CombinedQuotePDF } from '../src/components/CombinedQuotePDF.js';
import { initialState, PRICING } from '../src/logic/pricing/constants.js';
import { getTechDetails, getPrinciples } from '../src/logic/content-provider.js';
@@ -35,9 +35,8 @@ async function main() {
console.warn('⚠️ Missing recipient name or email. Document might look incomplete.');
}
const totalPrice = calculateTotal(state);
const monthlyPrice = calculateMonthly(state);
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
const totals = calculateTotals(state, PRICING);
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
const finalOutputPath = generateDefaultPath(state);
const outputDir = path.dirname(finalOutputPath);
@@ -115,32 +114,6 @@ async function runWizard(state: any) {
return state;
}
function calculateTotal(state: any) {
// Basic duplication of logic from ContactForm for consistency
if (state.projectType !== 'website') return 0;
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
let total = PRICING.BASE_WEBSITE;
total += totalPagesCount * PRICING.PAGE;
total += ((state.features?.length || 0) + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0)) * PRICING.FEATURE;
total += ((state.functions?.length || 0) + (state.otherFunctions?.length || 0) + (state.otherFunctionsCount || 0)) * PRICING.FUNCTION;
total += ((state.apiSystems?.length || 0) + (state.otherTech?.length || 0) + (state.otherTechCount || 0)) * PRICING.API_INTEGRATION;
if (state.cmsSetup) {
total += PRICING.CMS_SETUP;
total += ((state.features?.length || 0) + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0)) * PRICING.CMS_CONNECTION_PER_FEATURE;
}
const languagesCount = state.languagesList?.length || 1;
if (languagesCount > 1) {
total *= (1 + (languagesCount - 1) * 0.2);
}
return Math.round(total);
}
function calculateMonthly(state: any) {
if (state.projectType !== 'website') return 0;
return PRICING.HOSTING_MONTHLY + ((state.storageExpansion || 0) * PRICING.STORAGE_EXPANSION_MONTHLY);
}
function generateDefaultPath(state: any) {
const now = new Date();

View File

@@ -10,6 +10,7 @@ import * as confetti from 'canvas-confetti';
import { FormState, Step } from './ContactForm/types';
import { PRICING, initialState } from './ContactForm/constants';
import { calculateTotals } from '../logic/pricing/calculator';
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
import { ShareModal } from './ShareModal';
@@ -182,39 +183,12 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
}
}, [currentUrl, router, isRemotion]);
const totalPagesCount = useMemo(() => {
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
}, [state.selectedPages, state.otherPages, state.otherPagesCount]);
const totalPrice = useMemo(() => {
if (state.projectType !== 'website') return 0;
let total = PRICING.BASE_WEBSITE;
total += totalPagesCount * PRICING.PAGE;
total += (state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0)) * PRICING.FEATURE;
total += (state.functions.length + state.otherFunctions.length + (state.otherFunctionsCount || 0)) * PRICING.FUNCTION;
total += (state.apiSystems.length + state.otherTech.length + (state.otherTechCount || 0)) * PRICING.API_INTEGRATION;
total += (state.newDatasets || 0) * PRICING.NEW_DATASET;
if (state.cmsSetup) {
total += PRICING.CMS_SETUP;
total += (state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0)) * PRICING.CMS_CONNECTION_PER_FEATURE;
}
// Multi-language factor (e.g. +20% per additional language)
const languagesCount = state.languagesList.length || 1;
if (languagesCount > 1) {
total *= (1 + (languagesCount - 1) * 0.2);
}
return Math.round(total);
}, [state, totalPagesCount]);
const monthlyPrice = useMemo(() => {
if (state.projectType !== 'website') return 0;
return PRICING.HOSTING_MONTHLY + (state.storageExpansion * PRICING.STORAGE_EXPANSION_MONTHLY);
const totals = useMemo(() => {
return calculateTotals(state, PRICING);
}, [state]);
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
const updateState = (updates: Partial<FormState>) => {
setState((s: FormState) => ({ ...s, ...updates }));
};
@@ -598,9 +572,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
</div>
<PriceCalculation
state={state}
totalPrice={totalPrice}
monthlyPrice={monthlyPrice}
totalPagesCount={totalPagesCount}
totals={totals}
isClient={isClient}
qrCodeData={qrCodeData}
onShare={handleShare}

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { FormState, Totals } from '../types';
import { PRICING } from '../constants';
import { AnimatedNumber } from './AnimatedNumber';
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
@@ -20,9 +20,7 @@ const PDFDownloadLink = dynamic(
interface PriceCalculationProps {
state: FormState;
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totals: Totals;
isClient: boolean;
qrCodeData: string;
onShare?: () => void;
@@ -30,20 +28,15 @@ interface PriceCalculationProps {
export function PriceCalculation({
state,
totalPrice,
monthlyPrice,
totalPagesCount,
totals,
isClient,
qrCodeData,
onShare
}: PriceCalculationProps) {
const totalPages = totalPagesCount + (state.otherPagesCount || 0);
const totalFeatures = state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0);
const totalFunctions = state.functions.length + state.otherFunctions.length + (state.otherFunctionsCount || 0);
const totalApis = state.apiSystems.length + state.otherTech.length + (state.otherTechCount || 0);
const { totalPrice, monthlyPrice, totalPagesCount, totalFeatures, totalFunctions, totalApis, languagesCount } = totals;
const totalPages = totalPagesCount;
const [pdfLoading, setPdfLoading] = React.useState(false);
const languagesCount = state.languagesList.length || 1;
const handleDownload = async () => {
if (pdfLoading) return;
@@ -59,7 +52,6 @@ export function PriceCalculation({
monthlyPrice={monthlyPrice}
totalPagesCount={totalPagesCount}
pricing={PRICING}
qrCodeData={qrCodeData}
headerIcon={typeof IconWhite === 'string' ? IconWhite : (IconWhite as any).src}
footerLogo={typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src}
/>;

View File

@@ -60,6 +60,16 @@ export interface FormState {
complexInteractions: string;
}
export interface Totals {
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totalFeatures: number;
totalFunctions: number;
totalApis: number;
languagesCount: number;
}
export interface Step {
id: string;
title: string;

View File

@@ -1,6 +1,67 @@
import { FormState, Position } from './types';
import { FormState, Position, Totals } from './types';
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS, PAGE_LABELS } from './constants';
export function calculateTotals(state: FormState, pricing: any): Totals {
if (state.projectType !== 'website') {
return {
totalPrice: 0,
monthlyPrice: 0,
totalPagesCount: 0,
totalFeatures: 0,
totalFunctions: 0,
totalApis: 0,
languagesCount: 0
};
}
const sitemapPagesCount = state.sitemap?.reduce((acc: number, cat: any) => acc + (cat.pages?.length || 0), 0) || 0;
const totalPagesCount = Math.max(
(state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0),
sitemapPagesCount
);
const totalFeatures = (state.features?.length || 0) + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0);
const totalFunctions = (state.functions?.length || 0) + (state.otherFunctions?.length || 0) + (state.otherFunctionsCount || 0);
const totalApis = (state.apiSystems?.length || 0) + (state.otherTech?.length || 0) + (state.otherTechCount || 0);
let total = pricing.BASE_WEBSITE;
total += totalPagesCount * pricing.PAGE;
total += totalFeatures * pricing.FEATURE;
total += totalFunctions * pricing.FUNCTION;
total += totalApis * pricing.API_INTEGRATION;
total += (state.newDatasets || 0) * pricing.NEW_DATASET;
if (state.cmsSetup) {
total += pricing.CMS_SETUP;
total += totalFeatures * pricing.CMS_CONNECTION_PER_FEATURE;
}
// Optional visual/complexity boosters (from calculator.ts logic)
if (state.visualStaging && !isNaN(Number(state.visualStaging)) && Number(state.visualStaging) > 0) {
total += Number(state.visualStaging) * pricing.VISUAL_STAGING;
}
if (state.complexInteractions && !isNaN(Number(state.complexInteractions)) && Number(state.complexInteractions) > 0) {
total += Number(state.complexInteractions) * pricing.COMPLEX_INTERACTION;
}
const languagesCount = state.languagesList?.length || 1;
if (languagesCount > 1) {
total *= (1 + (languagesCount - 1) * 0.2);
}
const monthlyPrice = pricing.HOSTING_MONTHLY + ((state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY);
return {
totalPrice: Math.round(total),
monthlyPrice: Math.round(monthlyPrice),
totalPagesCount,
totalFeatures,
totalFunctions,
totalApis,
languagesCount
};
}
export function calculatePositions(state: FormState, pricing: any): Position[] {
const positions: Position[] = [];
let pos = 1;

View File

@@ -78,3 +78,12 @@ export interface Position {
price: number;
isRecurring?: boolean;
}
export interface Totals {
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totalFeatures: number;
totalFunctions: number;
totalApis: number;
languagesCount: number;
}