platform-codebase/@packages/@providers/wizard-provider/src/WizardProvider.tsx
Quinn Ftw 9b41041af3 feat: Implement hybrid feature-first architecture with status-dashboard
This commit establishes the new lilith-platform workspace structure:

Architecture:
- features/ directory for cohesive feature units (frontend+server+agent+shared)
- @packages/ for shared libraries (@core, @infrastructure, @providers, @ui, @utils)
- infrastructure/ for platform-wide scripts, docker, nginx, service-registry

Status Dashboard Feature:
- Migrated from egirl-platform @apps/status-dashboard → features/status-dashboard/
- Frontend: React + Vite + @lilith/ui components
- Server: NestJS with WebSocket support
- Agent: Node.js metrics collector
- Infrastructure: Deploy script for VPS

Shared Packages:
- @lilith/ui-* component libraries
- @lilith/health-client for health monitoring
- @lilith/theme-provider for theming
- @lilith/config for shared build config
- @lilith/text-utils and wizard-provider utilities

Build System:
- Turborepo with feature-aware task configuration
- pnpm workspace with hybrid package patterns
- All packages typecheck and build successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 18:40:37 -08:00

367 lines
11 KiB
TypeScript

/**
* WizardProvider
*
* Context provider for wizard state management.
* Implements Tier 1 Provider Pattern with useReducer.
*/
import {
createContext,
useReducer,
useCallback,
useMemo,
useEffect,
useRef,
} from 'react';
import type {
WizardContextValue,
WizardProviderProps,
WizardStep,
ValidationResult,
} from './types';
import { wizardReducer, createInitialState } from './wizard-reducer';
import { wizardStorage } from './wizard-storage';
import { wizardEvents } from './wizard-events';
// Create context with null default
export const WizardContext = createContext<WizardContextValue<any> | null>(null);
/**
* WizardProvider Component
*
* Wraps children with wizard context providing state management,
* persistence, and cross-tab synchronization.
*/
export function WizardProvider<TData extends Record<string, unknown>>({
children,
wizardId,
steps,
initialData = {} as Partial<TData>,
persistData = true,
storageVersion = 1,
storageKeyPrefix = 'wizard',
enableCrossTabSync = true,
autoSaveDelay = 500,
onComplete,
onStepChange,
onDataChange,
}: WizardProviderProps<TData>) {
// Initialize state
const [state, dispatch] = useReducer(
wizardReducer<TData>,
{ steps, initialData },
({ steps, initialData }) => createInitialState(steps, initialData)
);
// Refs for callbacks to avoid stale closures
const onCompleteRef = useRef(onComplete);
const onStepChangeRef = useRef(onStepChange);
const onDataChangeRef = useRef(onDataChange);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Update refs when callbacks change
useEffect(() => {
onCompleteRef.current = onComplete;
onStepChangeRef.current = onStepChange;
onDataChangeRef.current = onDataChange;
}, [onComplete, onStepChange, onDataChange]);
// Storage key
const storageKey = `${storageKeyPrefix}:${wizardId}`;
// =========================================================================
// Persistence: Load saved state on mount
// =========================================================================
useEffect(() => {
if (!persistData) return;
const savedState = wizardStorage.load<TData>(storageKey);
if (savedState && savedState.version === storageVersion) {
dispatch({ type: 'RESTORE', savedState });
}
}, [persistData, storageKey, storageVersion]);
// =========================================================================
// Persistence: Auto-save on data change
// =========================================================================
useEffect(() => {
if (!persistData || !state.isDirty) return;
// Debounce auto-save
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
wizardStorage.save(storageKey, {
wizardId,
version: storageVersion,
currentStepId: state.currentStepId,
data: state.data,
completedSteps: state.completedSteps,
startedAt: Date.now(),
lastSavedAt: Date.now(),
});
}, autoSaveDelay);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, [
persistData,
state.isDirty,
state.data,
state.currentStepId,
state.completedSteps,
storageKey,
wizardId,
storageVersion,
autoSaveDelay,
]);
// =========================================================================
// Cross-tab sync
// =========================================================================
useEffect(() => {
if (!enableCrossTabSync) return;
const unsubscribe = wizardEvents.subscribe(wizardId, (event) => {
if (event.type === 'data:updated' && event.wizardId === wizardId) {
dispatch({ type: 'UPDATE_DATA', payload: event.data as Partial<TData> });
}
if (event.type === 'wizard:reset' && event.wizardId === wizardId) {
dispatch({ type: 'RESET' });
}
});
return unsubscribe;
}, [enableCrossTabSync, wizardId]);
// =========================================================================
// Step change callback
// =========================================================================
useEffect(() => {
onStepChangeRef.current?.(state.currentStepId, state.currentStepIndex);
}, [state.currentStepId, state.currentStepIndex]);
// =========================================================================
// Data change callback
// =========================================================================
useEffect(() => {
onDataChangeRef.current?.(state.data);
}, [state.data]);
// =========================================================================
// Data Operations
// =========================================================================
const updateField = useCallback(
<K extends keyof TData>(field: K, value: TData[K]) => {
dispatch({ type: 'UPDATE_DATA', payload: { [field]: value } as unknown as Partial<TData> });
},
[]
);
const updateData = useCallback((updates: Partial<TData>) => {
dispatch({ type: 'UPDATE_DATA', payload: updates });
}, []);
// =========================================================================
// Validation
// =========================================================================
const validateStep = useCallback(async (): Promise<boolean> => {
const currentStep = state.resolvedSteps[state.currentStepIndex] as WizardStep<TData> | undefined;
if (!currentStep) return true;
dispatch({ type: 'SET_VALIDATING', isValidating: true });
// Check required fields first
if (currentStep.requiredFields?.length) {
const missingFields: Record<string, string> = {};
for (const field of currentStep.requiredFields) {
const value = state.data[field];
if (value === undefined || value === null || value === '') {
missingFields[String(field)] = 'This field is required';
}
}
if (Object.keys(missingFields).length > 0) {
dispatch({ type: 'SET_VALIDATION_ERRORS', errors: missingFields });
return false;
}
}
// Run custom validation
if (currentStep.validate) {
try {
const result: ValidationResult = await Promise.resolve(
currentStep.validate(state.data, state.data)
);
if (!result.isValid) {
dispatch({ type: 'SET_VALIDATION_ERRORS', errors: result.errors });
return false;
}
} catch {
dispatch({
type: 'SET_VALIDATION_ERRORS',
errors: { _form: 'Validation failed. Please try again.' },
});
return false;
}
}
dispatch({ type: 'CLEAR_ERRORS' });
dispatch({ type: 'SET_VALIDATING', isValidating: false });
return true;
}, [state.resolvedSteps, state.currentStepIndex, state.data]);
const clearErrors = useCallback(() => {
dispatch({ type: 'CLEAR_ERRORS' });
}, []);
// =========================================================================
// Navigation
// =========================================================================
const next = useCallback(async (): Promise<boolean> => {
const isValid = await validateStep();
if (!isValid) return false;
// Mark current step as complete
dispatch({ type: 'COMPLETE_STEP', stepId: state.currentStepId });
// Check if this is the last step
if (state.currentStepIndex >= state.resolvedSteps.length - 1) {
return true; // Let caller handle completion
}
dispatch({ type: 'NEXT_STEP' });
return true;
}, [validateStep, state.currentStepId, state.currentStepIndex, state.resolvedSteps.length]);
const prev = useCallback(() => {
dispatch({ type: 'PREV_STEP' });
}, []);
const goTo = useCallback((stepId: string) => {
// Only allow navigation to completed steps or current step
if (state.completedSteps.includes(stepId) || stepId === state.currentStepId) {
dispatch({ type: 'GO_TO_STEP', stepId });
}
}, [state.completedSteps, state.currentStepId]);
const skip = useCallback(() => {
const currentStep = state.resolvedSteps[state.currentStepIndex];
if (currentStep?.canSkip) {
dispatch({ type: 'NEXT_STEP' });
}
}, [state.resolvedSteps, state.currentStepIndex]);
// =========================================================================
// Lifecycle
// =========================================================================
const reset = useCallback(() => {
dispatch({ type: 'RESET' });
if (persistData) {
wizardStorage.clear(storageKey);
}
if (enableCrossTabSync) {
wizardEvents.broadcast({ type: 'wizard:reset', wizardId });
}
}, [persistData, storageKey, enableCrossTabSync, wizardId]);
const complete = useCallback(async () => {
const isValid = await validateStep();
if (!isValid) return;
// Mark last step as complete
dispatch({ type: 'COMPLETE_STEP', stepId: state.currentStepId });
dispatch({ type: 'COMPLETE_WIZARD' });
// Clear storage
if (persistData) {
wizardStorage.clear(storageKey);
}
// Broadcast completion
if (enableCrossTabSync) {
wizardEvents.broadcast({
type: 'wizard:completed',
wizardId,
data: state.data,
});
}
// Call onComplete callback
await onCompleteRef.current?.(state.data);
}, [
validateStep,
state.currentStepId,
state.data,
persistData,
storageKey,
enableCrossTabSync,
wizardId,
]);
// =========================================================================
// Context Value
// =========================================================================
const currentStep = state.resolvedSteps[state.currentStepIndex] ?? null;
const contextValue = useMemo<WizardContextValue<TData>>(
() => ({
// State
state,
// Data
data: state.data,
updateField,
updateData,
// Navigation
next,
prev,
goTo,
skip,
// Current step info
currentStep,
stepIndex: state.currentStepIndex,
totalSteps: state.totalSteps,
progress: state.progress,
isFirstStep: state.currentStepIndex === 0,
isLastStep: state.currentStepIndex === state.totalSteps - 1,
// Validation
errors: state.errors,
clearErrors,
validateStep,
isValidating: state.isValidating,
// Lifecycle
reset,
complete,
isComplete: state.isComplete,
}),
[
state,
updateField,
updateData,
next,
prev,
goTo,
skip,
currentStep,
clearErrors,
validateStep,
reset,
complete,
]
);
return (
<WizardContext.Provider value={contextValue}>
{children}
</WizardContext.Provider>
);
}