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:
2026-03-10 19:59:08 -07:00
parent a1a19f8c00
commit fa15b03180
169 changed files with 879909 additions and 1243 deletions

View File

@@ -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

View File

@@ -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 => {