SkateSpots/src/components/FormWizard.tsx
SkateSpots bbe4ba2e14 Lab forms: multi-step wizard (shared form + coach) and bespoke scroll-to
- 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.
2026-10-04 12:29:42 +07:00

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>;
}