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>
209 lines
5.8 KiB
TypeScript
209 lines
5.8 KiB
TypeScript
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
import type { WizardStep } from '@/types/quote';
|
|
|
|
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;
|
|
steps: WizardStep[];
|
|
totalSteps: number;
|
|
isFirstStep: boolean;
|
|
isLastStep: boolean;
|
|
goToStep: (step: number) => void;
|
|
nextStep: () => void;
|
|
prevStep: () => void;
|
|
markStepComplete: (stepIndex: number) => void;
|
|
markStepIncomplete: (stepIndex: number) => void;
|
|
resetWizard: () => void;
|
|
progress: number;
|
|
canProceed: boolean;
|
|
setCanProceed: (canProceed: boolean) => void;
|
|
currentStepId: string;
|
|
getStepByIndex: (index: number) => WizardStep | undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 = 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 stepDefs.map((step, index) => ({
|
|
...step,
|
|
isComplete: completedSteps.has(index),
|
|
isActive: index === currentStep,
|
|
}));
|
|
}, [stepDefs, currentStep, completedSteps]);
|
|
|
|
const currentStepId = useMemo(() => {
|
|
return stepDefs[currentStep]?.id || '';
|
|
}, [currentStep, stepDefs]);
|
|
|
|
const progress = useMemo(() => {
|
|
if (totalSteps <= 1) return 100;
|
|
return Math.round((currentStep / (totalSteps - 1)) * 100);
|
|
}, [currentStep, totalSteps]);
|
|
|
|
const goToStep = useCallback(
|
|
(step: number) => {
|
|
if (step >= 0 && step < totalSteps) {
|
|
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
|
|
setCurrentStep(step);
|
|
}
|
|
}
|
|
},
|
|
[totalSteps, currentStep, completedSteps]
|
|
);
|
|
|
|
const nextStep = useCallback(() => {
|
|
if (!isLastStep && canProceed) {
|
|
setCompletedSteps((prev) => new Set(prev).add(currentStep));
|
|
setCurrentStep((prev) => prev + 1);
|
|
}
|
|
}, [currentStep, isLastStep, canProceed]);
|
|
|
|
const prevStep = useCallback(() => {
|
|
if (!isFirstStep) {
|
|
setCurrentStep((prev) => prev - 1);
|
|
}
|
|
}, [isFirstStep]);
|
|
|
|
const markStepComplete = useCallback((stepIndex: number) => {
|
|
setCompletedSteps((prev) => new Set(prev).add(stepIndex));
|
|
}, []);
|
|
|
|
const markStepIncomplete = useCallback((stepIndex: number) => {
|
|
setCompletedSteps((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(stepIndex);
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
const resetWizard = useCallback(() => {
|
|
setCurrentStep(0);
|
|
setCompletedSteps(new Set());
|
|
setCanProceed(true);
|
|
window.history.replaceState(null, '', `#${stepDefs[0]?.id}`);
|
|
}, [stepDefs]);
|
|
|
|
const getStepByIndex = useCallback(
|
|
(index: number): WizardStep | undefined => {
|
|
return steps[index];
|
|
},
|
|
[steps]
|
|
);
|
|
|
|
return {
|
|
currentStep,
|
|
steps,
|
|
totalSteps,
|
|
isFirstStep,
|
|
isLastStep,
|
|
goToStep,
|
|
nextStep,
|
|
prevStep,
|
|
markStepComplete,
|
|
markStepIncomplete,
|
|
resetWizard,
|
|
progress,
|
|
canProceed,
|
|
setCanProceed,
|
|
currentStepId,
|
|
getStepByIndex,
|
|
};
|
|
}
|