feat: Centralize pricing calculations into a new calculateTotals utility function and Totals interface.
This commit is contained in:
@@ -4,7 +4,7 @@ import * as readline from 'node:readline/promises';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
import { renderToFile } from '@react-pdf/renderer';
|
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 { CombinedQuotePDF } from '../src/components/CombinedQuotePDF.js';
|
||||||
import { initialState, PRICING } from '../src/logic/pricing/constants.js';
|
import { initialState, PRICING } from '../src/logic/pricing/constants.js';
|
||||||
import { getTechDetails, getPrinciples } from '../src/logic/content-provider.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.');
|
console.warn('⚠️ Missing recipient name or email. Document might look incomplete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPrice = calculateTotal(state);
|
const totals = calculateTotals(state, PRICING);
|
||||||
const monthlyPrice = calculateMonthly(state);
|
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
||||||
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
|
|
||||||
|
|
||||||
const finalOutputPath = generateDefaultPath(state);
|
const finalOutputPath = generateDefaultPath(state);
|
||||||
const outputDir = path.dirname(finalOutputPath);
|
const outputDir = path.dirname(finalOutputPath);
|
||||||
@@ -115,32 +114,6 @@ async function runWizard(state: any) {
|
|||||||
return state;
|
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) {
|
function generateDefaultPath(state: any) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as confetti from 'canvas-confetti';
|
|||||||
|
|
||||||
import { FormState, Step } from './ContactForm/types';
|
import { FormState, Step } from './ContactForm/types';
|
||||||
import { PRICING, initialState } from './ContactForm/constants';
|
import { PRICING, initialState } from './ContactForm/constants';
|
||||||
|
import { calculateTotals } from '../logic/pricing/calculator';
|
||||||
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
||||||
import { ShareModal } from './ShareModal';
|
import { ShareModal } from './ShareModal';
|
||||||
|
|
||||||
@@ -182,39 +183,12 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
}
|
}
|
||||||
}, [currentUrl, router, isRemotion]);
|
}, [currentUrl, router, isRemotion]);
|
||||||
|
|
||||||
const totalPagesCount = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
return calculateTotals(state, PRICING);
|
||||||
}, [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);
|
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
|
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
||||||
|
|
||||||
const updateState = (updates: Partial<FormState>) => {
|
const updateState = (updates: Partial<FormState>) => {
|
||||||
setState((s: FormState) => ({ ...s, ...updates }));
|
setState((s: FormState) => ({ ...s, ...updates }));
|
||||||
};
|
};
|
||||||
@@ -598,9 +572,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
</div>
|
</div>
|
||||||
<PriceCalculation
|
<PriceCalculation
|
||||||
state={state}
|
state={state}
|
||||||
totalPrice={totalPrice}
|
totals={totals}
|
||||||
monthlyPrice={monthlyPrice}
|
|
||||||
totalPagesCount={totalPagesCount}
|
|
||||||
isClient={isClient}
|
isClient={isClient}
|
||||||
qrCodeData={qrCodeData}
|
qrCodeData={qrCodeData}
|
||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormState } from '../types';
|
import { FormState, Totals } from '../types';
|
||||||
import { PRICING } from '../constants';
|
import { PRICING } from '../constants';
|
||||||
import { AnimatedNumber } from './AnimatedNumber';
|
import { AnimatedNumber } from './AnimatedNumber';
|
||||||
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
|
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
|
||||||
@@ -20,9 +20,7 @@ const PDFDownloadLink = dynamic(
|
|||||||
|
|
||||||
interface PriceCalculationProps {
|
interface PriceCalculationProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
totalPrice: number;
|
totals: Totals;
|
||||||
monthlyPrice: number;
|
|
||||||
totalPagesCount: number;
|
|
||||||
isClient: boolean;
|
isClient: boolean;
|
||||||
qrCodeData: string;
|
qrCodeData: string;
|
||||||
onShare?: () => void;
|
onShare?: () => void;
|
||||||
@@ -30,20 +28,15 @@ interface PriceCalculationProps {
|
|||||||
|
|
||||||
export function PriceCalculation({
|
export function PriceCalculation({
|
||||||
state,
|
state,
|
||||||
totalPrice,
|
totals,
|
||||||
monthlyPrice,
|
|
||||||
totalPagesCount,
|
|
||||||
isClient,
|
isClient,
|
||||||
qrCodeData,
|
qrCodeData,
|
||||||
onShare
|
onShare
|
||||||
}: PriceCalculationProps) {
|
}: PriceCalculationProps) {
|
||||||
const totalPages = totalPagesCount + (state.otherPagesCount || 0);
|
const { totalPrice, monthlyPrice, totalPagesCount, totalFeatures, totalFunctions, totalApis, languagesCount } = totals;
|
||||||
const totalFeatures = state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0);
|
const totalPages = totalPagesCount;
|
||||||
const totalFunctions = state.functions.length + state.otherFunctions.length + (state.otherFunctionsCount || 0);
|
|
||||||
const totalApis = state.apiSystems.length + state.otherTech.length + (state.otherTechCount || 0);
|
|
||||||
|
|
||||||
const [pdfLoading, setPdfLoading] = React.useState(false);
|
const [pdfLoading, setPdfLoading] = React.useState(false);
|
||||||
const languagesCount = state.languagesList.length || 1;
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (pdfLoading) return;
|
if (pdfLoading) return;
|
||||||
@@ -59,7 +52,6 @@ export function PriceCalculation({
|
|||||||
monthlyPrice={monthlyPrice}
|
monthlyPrice={monthlyPrice}
|
||||||
totalPagesCount={totalPagesCount}
|
totalPagesCount={totalPagesCount}
|
||||||
pricing={PRICING}
|
pricing={PRICING}
|
||||||
qrCodeData={qrCodeData}
|
|
||||||
headerIcon={typeof IconWhite === 'string' ? IconWhite : (IconWhite as any).src}
|
headerIcon={typeof IconWhite === 'string' ? IconWhite : (IconWhite as any).src}
|
||||||
footerLogo={typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src}
|
footerLogo={typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src}
|
||||||
/>;
|
/>;
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ export interface FormState {
|
|||||||
complexInteractions: string;
|
complexInteractions: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Totals {
|
||||||
|
totalPrice: number;
|
||||||
|
monthlyPrice: number;
|
||||||
|
totalPagesCount: number;
|
||||||
|
totalFeatures: number;
|
||||||
|
totalFunctions: number;
|
||||||
|
totalApis: number;
|
||||||
|
languagesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -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';
|
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[] {
|
export function calculatePositions(state: FormState, pricing: any): Position[] {
|
||||||
const positions: Position[] = [];
|
const positions: Position[] = [];
|
||||||
let pos = 1;
|
let pos = 1;
|
||||||
|
|||||||
@@ -78,3 +78,12 @@ export interface Position {
|
|||||||
price: number;
|
price: number;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
}
|
}
|
||||||
|
export interface Totals {
|
||||||
|
totalPrice: number;
|
||||||
|
monthlyPrice: number;
|
||||||
|
totalPagesCount: number;
|
||||||
|
totalFeatures: number;
|
||||||
|
totalFunctions: number;
|
||||||
|
totalApis: number;
|
||||||
|
languagesCount: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user