sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import type {
|
||||
QuoteData,
|
||||
QuoteResult,
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
EmailProvider,
|
||||
Industry,
|
||||
ContactPreference,
|
||||
ClientType,
|
||||
ServiceInterests,
|
||||
} from '@/types/quote';
|
||||
import {
|
||||
gpsTiers,
|
||||
@@ -31,9 +33,46 @@ import {
|
||||
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,
|
||||
@@ -90,6 +129,10 @@ 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;
|
||||
@@ -140,6 +183,7 @@ export interface UseQuoteReturn {
|
||||
getVoIPMonthly: () => number;
|
||||
getWebHostingMonthly: () => number;
|
||||
getEmailMonthly: () => number;
|
||||
getSupportBlockTimeOneTime: () => number;
|
||||
getVoIPOneTime: () => number;
|
||||
|
||||
// Reset
|
||||
@@ -150,18 +194,67 @@ export interface UseQuoteReturn {
|
||||
* Quote calculation and state management hook
|
||||
*/
|
||||
export function useQuote(): UseQuoteReturn {
|
||||
const [company, setCompany] = useState<CompanyInfo>(initialCompanyInfo);
|
||||
const [gps, setGPS] = useState<GPSSelection>(initialGPSSelection);
|
||||
const [support, setSupport] = useState<SupportSelection>(initialSupportSelection);
|
||||
const [voip, setVoIP] = useState<VoIPSelection>(initialVoIPSelection);
|
||||
const [webHosting, setWebHosting] = useState<WebHostingSelection>(initialWebHostingSelection);
|
||||
const [email, setEmail] = useState<EmailSelection>(initialEmailSelection);
|
||||
const [contact, setContact] = useState<ContactInfo>(initialContactInfo);
|
||||
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,
|
||||
@@ -170,9 +263,31 @@ export function useQuote(): UseQuoteReturn {
|
||||
email,
|
||||
contact,
|
||||
}),
|
||||
[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
|
||||
// ============================================================================
|
||||
@@ -387,19 +502,16 @@ export function useQuote(): UseQuoteReturn {
|
||||
}, [gps]);
|
||||
|
||||
const getSupportMonthly = useCallback((): number => {
|
||||
if (support.planId === 'none') return 0;
|
||||
|
||||
const plan = supportPlans.find((p) => p.id === support.planId);
|
||||
if (!plan) return 0;
|
||||
return plan ? plan.monthlyPrice : 0;
|
||||
}, [support]);
|
||||
|
||||
let total = plan.monthlyPrice;
|
||||
|
||||
if (support.useBlockTime && support.blockTimeId) {
|
||||
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
|
||||
if (blockTime) {
|
||||
total += blockTime.price;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
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 => {
|
||||
@@ -460,6 +572,7 @@ export function useQuote(): UseQuoteReturn {
|
||||
const supportMonthly = getSupportMonthly();
|
||||
const voipMonthly = getVoIPMonthly();
|
||||
const voipOneTime = getVoIPOneTime();
|
||||
const supportBlockTimeOneTime = getSupportBlockTimeOneTime();
|
||||
const webHostingMonthly = getWebHostingMonthly();
|
||||
const emailMonthly = getEmailMonthly();
|
||||
|
||||
@@ -473,15 +586,8 @@ export function useQuote(): UseQuoteReturn {
|
||||
}
|
||||
|
||||
// Calculate support breakdown
|
||||
const supportPlan = supportPlans.find((p) => p.id === support.planId);
|
||||
const supportPlan = support.planId !== 'none' ? supportPlans.find((p) => p.id === support.planId) : null;
|
||||
const supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
|
||||
let supportBlockTime = 0;
|
||||
if (support.useBlockTime && support.blockTimeId) {
|
||||
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
|
||||
if (blockTime) {
|
||||
supportBlockTime = blockTime.price;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate VoIP breakdown
|
||||
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
|
||||
@@ -506,7 +612,7 @@ export function useQuote(): UseQuoteReturn {
|
||||
},
|
||||
support: {
|
||||
plan: supportPlanCost,
|
||||
blockTime: supportBlockTime,
|
||||
blockTime: supportBlockTimeOneTime,
|
||||
total: supportMonthly,
|
||||
},
|
||||
voip: {
|
||||
@@ -522,7 +628,7 @@ export function useQuote(): UseQuoteReturn {
|
||||
|
||||
const result: QuoteResult = {
|
||||
monthlyTotal,
|
||||
oneTimeTotal: voipOneTime,
|
||||
oneTimeTotal: voipOneTime + supportBlockTimeOneTime,
|
||||
breakdown,
|
||||
gpsMonthly,
|
||||
supportMonthly,
|
||||
@@ -533,13 +639,15 @@ export function useQuote(): UseQuoteReturn {
|
||||
|
||||
setQuoteResult(result);
|
||||
return result;
|
||||
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
|
||||
}, [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);
|
||||
@@ -548,12 +656,17 @@ export function useQuote(): UseQuoteReturn {
|
||||
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,
|
||||
@@ -604,6 +717,7 @@ export function useQuote(): UseQuoteReturn {
|
||||
getVoIPMonthly,
|
||||
getWebHostingMonthly,
|
||||
getEmailMonthly,
|
||||
getSupportBlockTimeOneTime,
|
||||
getVoIPOneTime,
|
||||
|
||||
// Reset
|
||||
|
||||
@@ -1,46 +1,28 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import type { WizardStep } from '@/types/quote';
|
||||
|
||||
/**
|
||||
* Wizard steps configuration for the 7-step MSP Quote Wizard
|
||||
*/
|
||||
const WIZARD_STEPS: Omit<WizardStep, 'isComplete' | 'isActive'>[] = [
|
||||
{
|
||||
id: 'company',
|
||||
title: 'Company Profile',
|
||||
description: 'Tell us about your business',
|
||||
},
|
||||
{
|
||||
id: 'gps',
|
||||
title: 'GPS Monitoring',
|
||||
description: 'Select your monitoring tier',
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Support Plan',
|
||||
description: 'Choose your support level',
|
||||
},
|
||||
{
|
||||
id: 'voip',
|
||||
title: 'VoIP Phone System',
|
||||
description: 'Business phone options',
|
||||
},
|
||||
{
|
||||
id: 'web-email',
|
||||
title: 'Web & Email',
|
||||
description: 'Hosting and email services',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Review Quote',
|
||||
description: 'Review your selections',
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: 'Get Your Quote',
|
||||
description: 'Submit your information',
|
||||
},
|
||||
];
|
||||
export interface WizardStepDef {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Map step id from URL hash to step index */
|
||||
function stepIndexFromHash(steps: WizardStepDef[]): number {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (!hash) return 0;
|
||||
const idx = steps.findIndex((s) => s.id === hash);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
/** Determine which steps should be marked complete based on a restored index */
|
||||
function restoredCompletedSteps(upToIndex: number): Set<number> {
|
||||
const set = new Set<number>();
|
||||
for (let i = 0; i < upToIndex; i++) {
|
||||
set.add(i);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export interface UseWizardReturn {
|
||||
currentStep: number;
|
||||
@@ -61,37 +43,103 @@ export interface UseWizardReturn {
|
||||
getStepByIndex: (index: number) => WizardStep | undefined;
|
||||
}
|
||||
|
||||
export function useWizard(): UseWizardReturn {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||
/**
|
||||
* Dynamic wizard hook — accepts a step definition array that can change
|
||||
* as the user enables/disables services in the discovery step.
|
||||
*/
|
||||
export function useWizard(stepDefs: WizardStepDef[]): UseWizardReturn {
|
||||
const initialStep = stepIndexFromHash(stepDefs);
|
||||
const [currentStep, setCurrentStep] = useState(initialStep);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(
|
||||
() => restoredCompletedSteps(initialStep)
|
||||
);
|
||||
const [canProceed, setCanProceed] = useState(true);
|
||||
const isPopstateRef = useRef(false);
|
||||
const prevStepDefsRef = useRef(stepDefs);
|
||||
|
||||
const totalSteps = WIZARD_STEPS.length;
|
||||
const totalSteps = stepDefs.length;
|
||||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === totalSteps - 1;
|
||||
|
||||
// When stepDefs change (services toggled), keep current position valid
|
||||
useEffect(() => {
|
||||
const prevDefs = prevStepDefsRef.current;
|
||||
prevStepDefsRef.current = stepDefs;
|
||||
|
||||
if (prevDefs.length === stepDefs.length) return;
|
||||
|
||||
// If current step is beyond new length, clamp it
|
||||
if (currentStep >= stepDefs.length) {
|
||||
setCurrentStep(Math.max(0, stepDefs.length - 1));
|
||||
}
|
||||
|
||||
// If a step was removed, try to stay on the same step id
|
||||
const currentId = prevDefs[currentStep]?.id;
|
||||
if (currentId) {
|
||||
const newIndex = stepDefs.findIndex((s) => s.id === currentId);
|
||||
if (newIndex >= 0 && newIndex !== currentStep) {
|
||||
setCurrentStep(newIndex);
|
||||
}
|
||||
}
|
||||
}, [stepDefs, currentStep]);
|
||||
|
||||
// Sync URL hash when currentStep changes
|
||||
useEffect(() => {
|
||||
if (isPopstateRef.current) {
|
||||
isPopstateRef.current = false;
|
||||
return;
|
||||
}
|
||||
const stepId = stepDefs[currentStep]?.id;
|
||||
if (stepId) {
|
||||
const newHash = `#${stepId}`;
|
||||
if (window.location.hash !== newHash) {
|
||||
window.history.pushState(null, '', newHash);
|
||||
}
|
||||
}
|
||||
}, [currentStep, stepDefs]);
|
||||
|
||||
// Listen for browser back/forward
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const idx = stepIndexFromHash(stepDefs);
|
||||
isPopstateRef.current = true;
|
||||
setCurrentStep(idx);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [stepDefs]);
|
||||
|
||||
// Set initial hash if none present
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) {
|
||||
const stepId = stepDefs[0]?.id;
|
||||
if (stepId) {
|
||||
window.history.replaceState(null, '', `#${stepId}`);
|
||||
}
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const steps: WizardStep[] = useMemo(() => {
|
||||
return WIZARD_STEPS.map((step, index) => ({
|
||||
return stepDefs.map((step, index) => ({
|
||||
...step,
|
||||
isComplete: completedSteps.has(index),
|
||||
isActive: index === currentStep,
|
||||
}));
|
||||
}, [currentStep, completedSteps]);
|
||||
}, [stepDefs, currentStep, completedSteps]);
|
||||
|
||||
const currentStepId = useMemo(() => {
|
||||
return WIZARD_STEPS[currentStep]?.id || '';
|
||||
}, [currentStep]);
|
||||
return stepDefs[currentStep]?.id || '';
|
||||
}, [currentStep, stepDefs]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
// Progress based on current step position (0 to 100)
|
||||
if (totalSteps <= 1) return 100;
|
||||
return Math.round((currentStep / (totalSteps - 1)) * 100);
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: number) => {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
// Allow going back to any previous step
|
||||
// Only allow going forward to completed steps or the next step
|
||||
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
|
||||
setCurrentStep(step);
|
||||
}
|
||||
@@ -102,7 +150,6 @@ export function useWizard(): UseWizardReturn {
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (!isLastStep && canProceed) {
|
||||
// Mark current step as complete when moving forward
|
||||
setCompletedSteps((prev) => new Set(prev).add(currentStep));
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
@@ -130,7 +177,8 @@ export function useWizard(): UseWizardReturn {
|
||||
setCurrentStep(0);
|
||||
setCompletedSteps(new Set());
|
||||
setCanProceed(true);
|
||||
}, []);
|
||||
window.history.replaceState(null, '', `#${stepDefs[0]?.id}`);
|
||||
}, [stepDefs]);
|
||||
|
||||
const getStepByIndex = useCallback(
|
||||
(index: number): WizardStep | undefined => {
|
||||
|
||||
Reference in New Issue
Block a user