Implement Phase 2: Recipe & Supplier wizard modals (JTBD-driven UX)
Following Jobs-To-Be-Done analysis, break down complex forms into multi-step wizards to reduce cognitive load for non-technical bakery owners. **Core Infrastructure:** - Add reusable WizardModal component with progress tracking, validation, and navigation - Multi-step progress bar with clickable previous steps - Per-step validation with clear error messaging - Back/Next/Complete navigation with loading states - Optional step skipping support - Responsive modal design (sm/md/lg/xl/2xl sizes) **Recipe Wizard (4 steps):** - Step 1 (Product): Name, category, finished product, cuisine type, difficulty, description - Step 2 (Ingredients): Dynamic ingredient list with add/remove, quantities, units, optional flags - Step 3 (Production): Times (prep/cook/rest), yield, batch sizes, temperature, humidity, special flags - Step 4 (Review): Instructions, storage, nutritional info, allergens, final summary **Supplier Wizard (3 steps):** - Step 1 (Basic): Name, type, status, contact person, email, phone, tax ID, registration - Step 2 (Delivery): Payment terms, lead time, minimum order, delivery schedule, address - Step 3 (Review): Certifications, sustainability practices, notes, summary **Benefits:** - Reduces form overwhelm from 8 sections to 4 sequential steps (recipes) and 3 steps (suppliers) - Clear progress indication and next actions - Validation feedback per step instead of at end - Summary review before final submission - Matches mental model of "configure then review" workflow Files: - WizardModal: Reusable wizard infrastructure - RecipeWizard: 4-step recipe creation (Product → Ingredients → Production → Review) - SupplierWizard: 3-step supplier creation (Basic → Delivery → Review) Related to Phase 1 (ConfigurationProgressWidget) for post-onboarding guidance.
This commit is contained in:
271
frontend/src/components/ui/WizardModal/WizardModal.tsx
Normal file
271
frontend/src/components/ui/WizardModal/WizardModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface WizardStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
component: React.ComponentType<WizardStepProps>;
|
||||
isOptional?: boolean;
|
||||
validate?: () => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
export interface WizardStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onComplete: () => void;
|
||||
isFirstStep: boolean;
|
||||
isLastStep: boolean;
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
goToStep: (index: number) => void;
|
||||
}
|
||||
|
||||
interface WizardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: (data?: any) => void;
|
||||
title: string;
|
||||
steps: WizardStep[];
|
||||
icon?: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
title,
|
||||
steps,
|
||||
icon,
|
||||
size = 'xl'
|
||||
}) => {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
xl: 'max-w-5xl',
|
||||
'2xl': 'max-w-7xl'
|
||||
};
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
// Validate current step if validator exists
|
||||
if (currentStep.validate) {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const isValid = await currentStep.validate();
|
||||
if (!isValid) {
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
setIsValidating(false);
|
||||
}
|
||||
|
||||
if (isLastStep) {
|
||||
handleComplete();
|
||||
} else {
|
||||
setCurrentStepIndex(prev => Math.min(prev + 1, steps.length - 1));
|
||||
}
|
||||
}, [currentStep, isLastStep, steps.length]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentStepIndex(prev => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
onComplete();
|
||||
handleClose();
|
||||
}, [onComplete]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCurrentStepIndex(0);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const goToStep = useCallback((index: number) => {
|
||||
if (index >= 0 && index < steps.length) {
|
||||
setCurrentStepIndex(index);
|
||||
}
|
||||
}, [steps.length]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const StepComponent = currentStep.component;
|
||||
const progressPercentage = ((currentStepIndex + 1) / steps.length) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 animate-fadeIn"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
className={`bg-[var(--bg-primary)] rounded-xl shadow-2xl ${sizeClasses[size]} w-full max-h-[90vh] overflow-hidden pointer-events-auto animate-slideUp`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)]">
|
||||
{/* Title Bar */}
|
||||
<div className="flex items-center justify-between p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-[var(--color-primary)]">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{currentStep.description || `Step ${currentStepIndex + 1} of ${steps.length}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => index < currentStepIndex && goToStep(index)}
|
||||
disabled={index > currentStepIndex}
|
||||
className={`flex-1 h-2 rounded-full transition-all duration-300 ${
|
||||
index < currentStepIndex
|
||||
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80'
|
||||
: index === currentStepIndex
|
||||
? 'bg-[var(--color-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
|
||||
}`}
|
||||
title={step.title}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span className="font-medium">{currentStep.title}</span>
|
||||
<span>{currentStepIndex + 1} / {steps.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
|
||||
<StepComponent
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
onComplete={handleComplete}
|
||||
isFirstStep={isFirstStep}
|
||||
isLastStep={isLastStep}
|
||||
currentStepIndex={currentStepIndex}
|
||||
totalSteps={steps.length}
|
||||
goToStep={goToStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/50 backdrop-blur-sm px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Back Button */}
|
||||
{!isFirstStep && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={isValidating}
|
||||
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Skip Button (for optional steps) */}
|
||||
{currentStep.isOptional && !isLastStep && (
|
||||
<button
|
||||
onClick={() => setCurrentStepIndex(prev => prev + 1)}
|
||||
disabled={isValidating}
|
||||
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
Skip This Step
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next/Complete Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isValidating}
|
||||
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium inline-flex items-center gap-2 min-w-[140px] justify-center"
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Validating...
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
Complete
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animation Styles */}
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user