Files
claudetools/projects/msp-tools/quote-wizard/frontend/src/hooks/useQuote.ts
azcomputerguru c629890e32 fix: Quote wizard - correct total calculation and email sender
- Fix calculateQuote() to respect serviceInterests flags
- Only include GPS/Support costs when user has enabled them
- Update Step6Summary to conditionally render service sections
- Add sender display name (Arizona Computer Guru) to emails
- Add reply-to address (admin@azcomputerguru.com)
- Fixes phantom $380 support charge appearing in totals

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-10 20:42:40 -07:00

735 lines
22 KiB
TypeScript

import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import type {
QuoteData,
QuoteResult,
QuoteBreakdown,
CompanyInfo,
GPSSelection,
SupportSelection,
VoIPSelection,
WebHostingSelection,
EmailSelection,
ContactInfo,
GPSTierId,
SupportPlanId,
BlockTimeId,
VoIPTierId,
WebHostingTierId,
EmailTierId,
EmailProvider,
Industry,
ContactPreference,
ClientType,
ServiceInterests,
} from '@/types/quote';
import {
gpsTiers,
equipmentMonitoring,
supportPlans,
blockTimeOptions,
voipTiers,
voipHardware,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
const DRAFT_STORAGE_KEY = 'quote-wizard-draft';
/**
* Load saved draft from localStorage if available.
* Returns partial state keyed by section, or null if nothing saved.
*/
function loadDraft(): {
clientType?: ClientType;
serviceInterests?: ServiceInterests;
company?: CompanyInfo;
gps?: GPSSelection;
support?: SupportSelection;
voip?: VoIPSelection;
webHosting?: WebHostingSelection;
email?: EmailSelection;
contact?: ContactInfo;
accessToken?: string;
} | null {
try {
const raw = localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Initial state values
*/
const initialClientType: ClientType = 'company';
const initialServiceInterests: ServiceInterests = {
gps: true,
support: true,
voip: false,
webHosting: false,
email: false,
};
const initialCompanyInfo: CompanyInfo = {
name: '',
endpointCount: 10,
industry: '',
notes: '',
};
const initialGPSSelection: GPSSelection = {
tierId: 'pro',
endpointCount: 10,
includeEquipment: false,
equipmentDeviceCount: 0,
};
const initialSupportSelection: SupportSelection = {
planId: 'standard',
useBlockTime: false,
blockTimeId: null,
};
const initialVoIPSelection: VoIPSelection = {
enabled: false,
tierId: 'voip-standard',
userCount: 0,
hardware: [],
};
const initialWebHostingSelection: WebHostingSelection = {
enabled: false,
tierId: 'hosting-business',
};
const initialEmailSelection: EmailSelection = {
enabled: false,
provider: 'm365',
tierId: 'm365-standard',
mailboxCount: 0,
};
const initialContactInfo: ContactInfo = {
name: '',
email: '',
phone: '',
companyName: '',
currentITSituation: '',
contactPreference: 'email',
agreedToTerms: false,
};
/**
* Hook return type
*/
export interface UseQuoteReturn {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
// Client type & service interests
setClientType: (type: ClientType) => void;
setServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
// Company updates
updateCompany: (data: Partial<CompanyInfo>) => void;
setEndpointCount: (count: number) => void;
setIndustry: (industry: Industry | '') => void;
// GPS updates
updateGPS: (data: Partial<GPSSelection>) => void;
setGPSTier: (tierId: GPSTierId) => void;
setEquipmentEnabled: (enabled: boolean) => void;
setEquipmentCount: (count: number) => void;
// Support updates
updateSupport: (data: Partial<SupportSelection>) => void;
setSupportPlan: (planId: SupportPlanId) => void;
setBlockTimeEnabled: (enabled: boolean) => void;
setBlockTime: (blockTimeId: BlockTimeId) => void;
// VoIP updates
updateVoIP: (data: Partial<VoIPSelection>) => void;
setVoIPEnabled: (enabled: boolean) => void;
setVoIPTier: (tierId: VoIPTierId) => void;
setVoIPUserCount: (count: number) => void;
addHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
removeHardware: (hardwareId: string) => void;
updateHardwareQuantity: (hardwareId: string, quantity: number) => void;
// Web Hosting updates
updateWebHosting: (data: Partial<WebHostingSelection>) => void;
setWebHostingEnabled: (enabled: boolean) => void;
setWebHostingTier: (tierId: WebHostingTierId) => void;
// Email updates
updateEmail: (data: Partial<EmailSelection>) => void;
setEmailEnabled: (enabled: boolean) => void;
setEmailProvider: (provider: EmailProvider) => void;
setEmailTier: (tierId: EmailTierId) => void;
setMailboxCount: (count: number) => void;
// Contact updates
updateContact: (data: Partial<ContactInfo>) => void;
setContactPreference: (preference: ContactPreference) => void;
setAgreedToTerms: (agreed: boolean) => void;
// Calculations
calculateQuote: () => QuoteResult;
getGPSMonthly: () => number;
getSupportMonthly: () => number;
getVoIPMonthly: () => number;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
getSupportBlockTimeOneTime: () => number;
getVoIPOneTime: () => number;
// Reset
resetQuote: () => void;
}
/**
* Quote calculation and state management hook
*/
export function useQuote(): UseQuoteReturn {
const draft = useRef(loadDraft());
const [clientType, setClientType] = useState<ClientType>(draft.current?.clientType ?? initialClientType);
const [serviceInterests, setServiceInterests] = useState<ServiceInterests>(draft.current?.serviceInterests ?? initialServiceInterests);
const [company, setCompany] = useState<CompanyInfo>(draft.current?.company ?? initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(draft.current?.gps ?? initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(draft.current?.support ?? initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(draft.current?.voip ?? initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(draft.current?.webHosting ?? initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(draft.current?.email ?? initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(draft.current?.contact ?? initialContactInfo);
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
// Persist draft to localStorage when any section changes (debounced)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
try {
// Preserve the accessToken that WizardContainer may have written
const existing = localStorage.getItem(DRAFT_STORAGE_KEY);
let accessToken: string | undefined;
if (existing) {
try {
accessToken = JSON.parse(existing).accessToken;
} catch {
// ignore
}
}
const payload = {
clientType,
serviceInterests,
company,
gps,
support,
voip,
webHosting,
email,
contact,
...(accessToken ? { accessToken } : {}),
};
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// localStorage write failures are non-critical
}
}, 500);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, [clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]);
// Combined quote data
const quoteData: QuoteData = useMemo(
() => ({
clientType,
serviceInterests,
company,
gps,
support,
voip,
webHosting,
email,
contact,
}),
[clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]
);
// ============================================================================
// Client Type & Service Interests
// ============================================================================
const setClientTypeValue = useCallback((type: ClientType) => {
setClientType(type);
}, []);
const setServiceInterest = useCallback((service: keyof ServiceInterests, enabled: boolean) => {
setServiceInterests((prev) => ({ ...prev, [service]: enabled }));
// Sync the enabled flags on the corresponding selections
if (service === 'voip') {
setVoIP((prev) => ({ ...prev, enabled, userCount: enabled ? Math.max(prev.userCount, 1) : 0 }));
}
if (service === 'webHosting') {
setWebHosting((prev) => ({ ...prev, enabled }));
}
if (service === 'email') {
setEmail((prev) => ({ ...prev, enabled, mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0 }));
}
}, []);
// ============================================================================
// Company Updates
// ============================================================================
const updateCompany = useCallback((data: Partial<CompanyInfo>) => {
setCompany((prev) => {
const updated = { ...prev, ...data };
// Sync endpoint count with GPS selection
if (data.endpointCount !== undefined) {
setGPS((gpsState) => ({ ...gpsState, endpointCount: data.endpointCount as number }));
}
return updated;
});
}, []);
const setEndpointCount = useCallback((count: number) => {
const validCount = Math.max(1, count);
setCompany((prev) => ({ ...prev, endpointCount: validCount }));
setGPS((prev) => ({ ...prev, endpointCount: validCount }));
}, []);
const setIndustry = useCallback((industry: Industry | '') => {
setCompany((prev) => ({ ...prev, industry }));
}, []);
// ============================================================================
// GPS Updates
// ============================================================================
const updateGPS = useCallback((data: Partial<GPSSelection>) => {
setGPS((prev) => ({ ...prev, ...data }));
}, []);
const setGPSTier = useCallback((tierId: GPSTierId) => {
setGPS((prev) => ({ ...prev, tierId }));
}, []);
const setEquipmentEnabled = useCallback((enabled: boolean) => {
setGPS((prev) => ({
...prev,
includeEquipment: enabled,
equipmentDeviceCount: enabled ? Math.max(prev.equipmentDeviceCount, 1) : 0,
}));
}, []);
const setEquipmentCount = useCallback((count: number) => {
setGPS((prev) => ({ ...prev, equipmentDeviceCount: Math.max(0, count) }));
}, []);
// ============================================================================
// Support Updates
// ============================================================================
const updateSupport = useCallback((data: Partial<SupportSelection>) => {
setSupport((prev) => ({ ...prev, ...data }));
}, []);
const setSupportPlan = useCallback((planId: SupportPlanId) => {
setSupport((prev) => ({ ...prev, planId }));
}, []);
const setBlockTimeEnabled = useCallback((enabled: boolean) => {
setSupport((prev) => ({
...prev,
useBlockTime: enabled,
blockTimeId: enabled ? (prev.blockTimeId || 'block-10') : null,
}));
}, []);
const setBlockTime = useCallback((blockTimeId: BlockTimeId) => {
setSupport((prev) => ({ ...prev, blockTimeId, useBlockTime: true }));
}, []);
// ============================================================================
// VoIP Updates
// ============================================================================
const updateVoIP = useCallback((data: Partial<VoIPSelection>) => {
setVoIP((prev) => ({ ...prev, ...data }));
}, []);
const setVoIPEnabled = useCallback((enabled: boolean) => {
setVoIP((prev) => ({
...prev,
enabled,
userCount: enabled ? Math.max(prev.userCount, 1) : 0,
}));
}, []);
const setVoIPTier = useCallback((tierId: VoIPTierId) => {
setVoIP((prev) => ({ ...prev, tierId }));
}, []);
const setVoIPUserCount = useCallback((count: number) => {
setVoIP((prev) => ({ ...prev, userCount: Math.max(0, count) }));
}, []);
const addHardware = useCallback((hardwareId: string, quantity: number, isRental: boolean) => {
setVoIP((prev) => {
const existing = prev.hardware.find((h) => h.hardwareId === hardwareId);
if (existing) {
return {
...prev,
hardware: prev.hardware.map((h) =>
h.hardwareId === hardwareId ? { ...h, quantity, isRental } : h
),
};
}
return {
...prev,
hardware: [...prev.hardware, { hardwareId, quantity, isRental }],
};
});
}, []);
const removeHardware = useCallback((hardwareId: string) => {
setVoIP((prev) => ({
...prev,
hardware: prev.hardware.filter((h) => h.hardwareId !== hardwareId),
}));
}, []);
const updateHardwareQuantity = useCallback((hardwareId: string, quantity: number) => {
setVoIP((prev) => ({
...prev,
hardware: prev.hardware.map((h) =>
h.hardwareId === hardwareId ? { ...h, quantity: Math.max(0, quantity) } : h
),
}));
}, []);
// ============================================================================
// Web Hosting Updates
// ============================================================================
const updateWebHosting = useCallback((data: Partial<WebHostingSelection>) => {
setWebHosting((prev) => ({ ...prev, ...data }));
}, []);
const setWebHostingEnabled = useCallback((enabled: boolean) => {
setWebHosting((prev) => ({ ...prev, enabled }));
}, []);
const setWebHostingTier = useCallback((tierId: WebHostingTierId) => {
setWebHosting((prev) => ({ ...prev, tierId }));
}, []);
// ============================================================================
// Email Updates
// ============================================================================
const updateEmail = useCallback((data: Partial<EmailSelection>) => {
setEmail((prev) => ({ ...prev, ...data }));
}, []);
const setEmailEnabled = useCallback((enabled: boolean) => {
setEmail((prev) => ({
...prev,
enabled,
mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0,
}));
}, []);
const setEmailProvider = useCallback((provider: EmailProvider) => {
setEmail((prev) => {
// Set default tier for provider
const defaultTier = provider === 'm365' ? 'm365-standard' : 'whm-standard';
return { ...prev, provider, tierId: defaultTier as EmailTierId };
});
}, []);
const setEmailTier = useCallback((tierId: EmailTierId) => {
setEmail((prev) => ({ ...prev, tierId }));
}, []);
const setMailboxCount = useCallback((count: number) => {
setEmail((prev) => ({ ...prev, mailboxCount: Math.max(0, count) }));
}, []);
// ============================================================================
// Contact Updates
// ============================================================================
const updateContact = useCallback((data: Partial<ContactInfo>) => {
setContact((prev) => ({ ...prev, ...data }));
}, []);
const setContactPreference = useCallback((preference: ContactPreference) => {
setContact((prev) => ({ ...prev, contactPreference: preference }));
}, []);
const setAgreedToTerms = useCallback((agreed: boolean) => {
setContact((prev) => ({ ...prev, agreedToTerms: agreed }));
}, []);
// ============================================================================
// Calculation Functions
// ============================================================================
const getGPSMonthly = useCallback((): number => {
const tier = gpsTiers.find((t) => t.id === gps.tierId);
if (!tier) return 0;
let total = tier.pricePerEndpoint * gps.endpointCount;
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
total += equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
}
return total;
}, [gps]);
const getSupportMonthly = useCallback((): number => {
if (support.planId === 'none') return 0;
const plan = supportPlans.find((p) => p.id === support.planId);
return plan ? plan.monthlyPrice : 0;
}, [support]);
const getSupportBlockTimeOneTime = useCallback((): number => {
if (!support.useBlockTime || !support.blockTimeId) return 0;
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
return blockTime ? blockTime.price : 0;
}, [support]);
const getVoIPMonthly = useCallback((): number => {
if (!voip.enabled) return 0;
const tier = voipTiers.find((t) => t.id === voip.tierId);
if (!tier) return 0;
let total = tier.pricePerUser * voip.userCount;
// Add rental hardware costs
voip.hardware.forEach((hw) => {
if (hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
total += hardware.monthlyRental * hw.quantity;
}
}
});
return total;
}, [voip]);
const getVoIPOneTime = useCallback((): number => {
if (!voip.enabled) return 0;
let total = 0;
// Add purchased hardware costs
voip.hardware.forEach((hw) => {
if (!hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
total += hardware.oneTimePrice * hw.quantity;
}
}
});
return total;
}, [voip]);
const getWebHostingMonthly = useCallback((): number => {
if (!webHosting.enabled) return 0;
const tier = webHostingTiers.find((t) => t.id === webHosting.tierId);
return tier ? tier.monthlyPrice : 0;
}, [webHosting]);
const getEmailMonthly = useCallback((): number => {
if (!email.enabled) return 0;
const tier = emailTiers.find((t) => t.id === email.tierId);
return tier ? tier.pricePerMailbox * email.mailboxCount : 0;
}, [email]);
const calculateQuote = useCallback((): QuoteResult => {
// Only include services that are enabled in serviceInterests
const gpsMonthly = serviceInterests.gps ? getGPSMonthly() : 0;
const supportMonthly = serviceInterests.support ? getSupportMonthly() : 0;
const supportBlockTimeOneTime = serviceInterests.support ? getSupportBlockTimeOneTime() : 0;
const voipMonthly = serviceInterests.voip ? getVoIPMonthly() : 0;
const voipOneTime = serviceInterests.voip ? getVoIPOneTime() : 0;
const webHostingMonthly = serviceInterests.webHosting ? getWebHostingMonthly() : 0;
const emailMonthly = serviceInterests.email ? getEmailMonthly() : 0;
// Calculate GPS breakdown (only if enabled)
let gpsMonitoring = 0;
let gpsEquipment = 0;
if (serviceInterests.gps) {
const gpsTier = gpsTiers.find((t) => t.id === gps.tierId);
gpsMonitoring = gpsTier ? gpsTier.pricePerEndpoint * gps.endpointCount : 0;
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
gpsEquipment = equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
}
}
// Calculate support breakdown (only if enabled)
let supportPlanCost = 0;
if (serviceInterests.support && support.planId !== 'none') {
const supportPlan = supportPlans.find((p) => p.id === support.planId);
supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
}
// Calculate VoIP breakdown (only if enabled)
let voipService = 0;
let voipHardwareMonthly = 0;
if (serviceInterests.voip && voip.enabled) {
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
voipService = voipTier ? voipTier.pricePerUser * voip.userCount : 0;
voip.hardware.forEach((hw) => {
if (hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
voipHardwareMonthly += hardware.monthlyRental * hw.quantity;
}
}
});
}
const breakdown: QuoteBreakdown = {
gps: {
monitoring: gpsMonitoring,
equipment: gpsEquipment,
total: gpsMonthly,
},
support: {
plan: supportPlanCost,
blockTime: supportBlockTimeOneTime,
total: supportMonthly,
},
voip: {
service: voipService,
hardware: voipHardwareMonthly,
total: voipMonthly,
},
webHosting: webHostingMonthly,
email: emailMonthly,
};
const monthlyTotal = gpsMonthly + supportMonthly + voipMonthly + webHostingMonthly + emailMonthly;
const result: QuoteResult = {
monthlyTotal,
oneTimeTotal: voipOneTime + supportBlockTimeOneTime,
breakdown,
gpsMonthly,
supportMonthly,
voipMonthly,
webHostingMonthly,
emailMonthly,
};
setQuoteResult(result);
return result;
}, [serviceInterests, gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getSupportBlockTimeOneTime, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
// ============================================================================
// Reset
// ============================================================================
const resetQuote = useCallback(() => {
setClientType(initialClientType);
setServiceInterests(initialServiceInterests);
setCompany(initialCompanyInfo);
setGPS(initialGPSSelection);
setSupport(initialSupportSelection);
setVoIP(initialVoIPSelection);
setWebHosting(initialWebHostingSelection);
setEmail(initialEmailSelection);
setContact(initialContactInfo);
setQuoteResult(null);
localStorage.removeItem(DRAFT_STORAGE_KEY);
}, []);
return {
quoteData,
quoteResult,
// Client type & service interests
setClientType: setClientTypeValue,
setServiceInterest,
// Company updates
updateCompany,
setEndpointCount,
setIndustry,
// GPS updates
updateGPS,
setGPSTier,
setEquipmentEnabled,
setEquipmentCount,
// Support updates
updateSupport,
setSupportPlan,
setBlockTimeEnabled,
setBlockTime,
// VoIP updates
updateVoIP,
setVoIPEnabled,
setVoIPTier,
setVoIPUserCount,
addHardware,
removeHardware,
updateHardwareQuantity,
// Web Hosting updates
updateWebHosting,
setWebHostingEnabled,
setWebHostingTier,
// Email updates
updateEmail,
setEmailEnabled,
setEmailProvider,
setEmailTier,
setMailboxCount,
// Contact updates
updateContact,
setContactPreference,
setAgreedToTerms,
// Calculations
calculateQuote,
getGPSMonthly,
getSupportMonthly,
getVoIPMonthly,
getWebHostingMonthly,
getEmailMonthly,
getSupportBlockTimeOneTime,
getVoIPOneTime,
// Reset
resetQuote,
};
}