From 39006c16b1bcd4e56b435da02ff4bf49c8324fef Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 4 Feb 2026 16:59:30 +0100 Subject: [PATCH] feat: Centralize pricing calculations into a new `calculateTotals` utility function and `Totals` interface. --- scripts/generate-estimate.ts | 33 +--------- src/components/ContactForm.tsx | 40 ++---------- .../components/PriceCalculation.tsx | 18 ++---- src/components/ContactForm/types.ts | 10 +++ src/logic/pricing/calculator.ts | 63 ++++++++++++++++++- src/logic/pricing/types.ts | 9 +++ 6 files changed, 95 insertions(+), 78 deletions(-) diff --git a/scripts/generate-estimate.ts b/scripts/generate-estimate.ts index 6ed0937..8f08366 100644 --- a/scripts/generate-estimate.ts +++ b/scripts/generate-estimate.ts @@ -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(); diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index c07c5bc..379e4e9 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -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) => { setState((s: FormState) => ({ ...s, ...updates })); }; @@ -598,9 +572,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC 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} />; diff --git a/src/components/ContactForm/types.ts b/src/components/ContactForm/types.ts index 9590aba..c212420 100644 --- a/src/components/ContactForm/types.ts +++ b/src/components/ContactForm/types.ts @@ -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; diff --git a/src/logic/pricing/calculator.ts b/src/logic/pricing/calculator.ts index ad0fe94..046aec1 100644 --- a/src/logic/pricing/calculator.ts +++ b/src/logic/pricing/calculator.ts @@ -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; diff --git a/src/logic/pricing/types.ts b/src/logic/pricing/types.ts index babe4c5..86d3297 100644 --- a/src/logic/pricing/types.ts +++ b/src/logic/pricing/types.ts @@ -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; +}