- FormWizard/WizardStep: a step shell for the long offer forms. Steps stay mounted (hidden, not unmounted) so nested components with their own state (LabExtrasFields) keep their values; a controlled step lets the form jump to the offending step on validation. - Applied to CraftProfileForm (the 8 data-driven crafts, 5 steps: Identity / What you offer / Rates / Contact / Media) and the coach page (5 steps). Submit + Preview live in the wizard footer; submit errors jump to the right step before scrolling. - The 4 bespoke pages now scroll to their inline contact error on a failed submit.
92 lines
3.5 KiB
TypeScript
92 lines
3.5 KiB
TypeScript
import { createContext, useContext, type ReactNode } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { cn } from '@/lib/utils';
|
|
import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
|
|
|
const WizardCtx = createContext(0);
|
|
|
|
export interface WizardStepDef {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
interface FormWizardProps {
|
|
steps: WizardStepDef[];
|
|
/** Controlled current step (owned by the form so it can jump on validation). */
|
|
step: number;
|
|
onStepChange: (step: number) => void;
|
|
accentChip: string;
|
|
submitting: boolean;
|
|
submitLabel: string;
|
|
submittingLabel: string;
|
|
/** Extra footer node (e.g. a Preview button) shown next to Back/Next. */
|
|
footerExtra?: ReactNode;
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* A lightweight multi-step shell for the long Lab offer forms. Steps are
|
|
* declared as {@link WizardStepDef} metadata, and each block of the form is
|
|
* wrapped in a {@link WizardStep}. Crucially, non-active steps are hidden with
|
|
* `display:none` rather than unmounted, so nested components that own state
|
|
* (e.g. `LabExtrasFields`) keep their values while you move around.
|
|
*/
|
|
export function FormWizard({
|
|
steps, step, onStepChange, accentChip, submitting, submitLabel, submittingLabel, footerExtra, children,
|
|
}: FormWizardProps) {
|
|
const { t } = useTranslation();
|
|
const last = step === steps.length - 1;
|
|
|
|
return (
|
|
<WizardCtx.Provider value={step}>
|
|
<div className="space-y-4">
|
|
{/* Step nav */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{steps.map((s, i) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() => onStepChange(i)}
|
|
className={cn(
|
|
'min-h-0 min-w-0 rounded-full border px-3 py-1.5 text-xs font-bold transition-colors',
|
|
i === step ? accentChip : i < step ? 'border-white/20 text-gray-300' : 'border-white/10 text-gray-500',
|
|
)}
|
|
>
|
|
{i < step && <Check className="mr-1 inline h-3 w-3" />}
|
|
{i + 1}. {s.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Current step content (others stay mounted but hidden) */}
|
|
{children}
|
|
|
|
{/* Nav */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{footerExtra}
|
|
{step > 0 && (
|
|
<Button type="button" variant="outline" onClick={() => onStepChange(step - 1)} className="h-12 gap-1 border-white/15 font-bold">
|
|
<ChevronLeft className="h-4 w-4" /> {t('common.back')}
|
|
</Button>
|
|
)}
|
|
{last ? (
|
|
<Button type="submit" disabled={submitting} className={cn('h-12 flex-1 rounded-xl font-black uppercase tracking-wide', accentChip)}>
|
|
{submitting ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{submittingLabel}</> : submitLabel}
|
|
</Button>
|
|
) : (
|
|
<Button type="button" onClick={() => onStepChange(step + 1)} className={cn('h-12 flex-1 rounded-xl font-black uppercase tracking-wide', accentChip)}>
|
|
{t('common.next')} <ChevronRight className="ml-1 h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</WizardCtx.Provider>
|
|
);
|
|
}
|
|
|
|
/** A block of the form shown only while its step is active (kept mounted). */
|
|
export function WizardStep({ index, children }: { index: number; children: ReactNode }) {
|
|
const current = useContext(WizardCtx);
|
|
return <div className={current === index ? 'space-y-4' : 'hidden'}>{children}</div>;
|
|
}
|