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.
This commit is contained in:
SkateSpots 2026-10-04 12:29:42 +07:00
parent 6a0b0695ee
commit bbe4ba2e14
9 changed files with 192 additions and 53 deletions

View file

@ -26,6 +26,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { useLabDraft } from '@/hooks/useLabDraft';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { FormWizard, WizardStep } from '@/components/FormWizard';
import { isValidNoffer, type EntityType } from '@/lib/types';
import type { LabOption } from '@/lib/labExtras';
import { labProfilePathFromKind } from '@/lib/labRoutes';
@ -139,6 +140,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [step, setStep] = useState(0);
const draft = useLabDraft(
`craft:${kind}:${identifier}`,
@ -255,7 +257,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
setAttempted(true);
if (contactError) {
scrollTo(emailError ? 'craft-email' : websiteError ? 'craft-website' : 'craft-portfolio');
setStep(3);
window.setTimeout(() => scrollTo(emailError ? 'craft-email' : websiteError ? 'craft-website' : 'craft-portfolio'), 60);
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
@ -264,7 +267,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
for (const section of requiredSections) {
const keys = section.entries.map(([k]) => k);
if (!selected.some((s) => keys.includes(s))) {
scrollTo('craft-skills');
setStep(1);
window.setTimeout(() => scrollTo('craft-skills'), 60);
toast({ title: t('labUi.craftForm.sectionRequired', { section: section.title }), description: t('labUi.craftForm.sectionRequiredDesc', { section: section.title }), variant: 'destructive' });
return;
}
@ -426,6 +430,27 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
/>
<form onSubmit={handleSubmit} className="space-y-6">
<FormWizard
steps={[
{ id: 'identity', title: t('labUi.wizard.identity') },
{ id: 'offer', title: t('labUi.wizard.offer') },
{ id: 'rates', title: t('labUi.wizard.rates') },
{ id: 'contact', title: t('labUi.wizard.contact') },
{ id: 'media', title: t('labUi.wizard.media') },
]}
step={step}
onStepChange={setStep}
accentChip={accentChip}
submitting={isPending}
submitLabel={existingProfile ? t('labUi.craftForm.updateProfile') : t('labUi.craftForm.publishProfile')}
submittingLabel={t('labUi.become.publishing')}
footerExtra={
<Button type="button" variant="outline" onClick={() => setPreviewOpen(true)} className="h-12 flex-shrink-0 gap-2 rounded-xl border-white/15 font-bold">
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
}
>
<WizardStep index={0}>
{/* Image + Bio */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
@ -484,6 +509,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</CardContent>
</Card>
</WizardStep>
<WizardStep index={1}>
{/* Skill sections */}
{skillSections.map((section, sectionIndex) => (
<Card key={section.title} id={sectionIndex === 0 ? 'craft-skills' : undefined} className="bg-black/60 border-white/10">
@ -553,6 +580,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</Card>
)}
</WizardStep>
<WizardStep index={2}>
{/* Location + experience + rate */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
@ -581,6 +610,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</CardContent>
</Card>
</WizardStep>
<WizardStep index={3}>
{/* Contact + links */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
@ -615,6 +646,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</CardContent>
</Card>
</WizardStep>
<WizardStep index={4}>
{/* Shared enrichment: languages, availability, rate, payments, portfolio */}
<LabExtrasFields
existingTags={existingProfile?.tags}
@ -647,24 +680,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{/* Pro upgrade (optional) */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText={accentText} entityCoord={entityCoord} />
{/* Preview + Submit */}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="h-12 flex-shrink-0 gap-2 rounded-xl border-white/15 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
disabled={isPending}
className={cn('flex-1 h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
>
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : existingProfile ? t('labUi.craftForm.updateProfile') : t('labUi.craftForm.publishProfile')}
</Button>
</div>
</WizardStep>
</FormWizard>
</form>
</div>

View file

@ -0,0 +1,92 @@
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>;
}

View file

@ -2021,6 +2021,13 @@ labUi: {
location: 'Location',
contact: 'Contact',
},
wizard: {
identity: 'Identity',
offer: 'What you offer',
rates: 'Rates',
contact: 'Contact',
media: 'Media',
},
offerTattoo: 'Offer Tattoo',
offerTraining: 'Offer Training / Physio',
organizeEvents: 'Organize Events',

View file

@ -2021,6 +2021,13 @@ labUi: {
location: 'Ubicación',
contact: 'Contacto',
},
wizard: {
identity: 'Identidad',
offer: 'Qué ofreces',
rates: 'Tarifas',
contact: 'Contacto',
media: 'Multimedia',
},
offerTattoo: 'Ofrecer tatuajes',
offerTraining: 'Ofrecer entrenamiento / fisio',
organizeEvents: 'Organizar eventos',

View file

@ -288,11 +288,13 @@ export default function BecomeBuilderPage() {
e.preventDefault();
if (contactError) {
document.getElementById('contact-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
toast({ title: t('labUi.builderForm.buildTypesRequired'), description: t('labUi.builderForm.selectBuild'), variant: 'destructive' });
return;
}
@ -633,7 +635,7 @@ export default function BecomeBuilderPage() {
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
{contactError && <p id="contact-error" className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">

View file

@ -37,6 +37,7 @@ import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { useLabDraft } from '@/hooks/useLabDraft';
import { labProfilePath } from '@/lib/labRoutes';
import { FormWizard, WizardStep } from '@/components/FormWizard';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
@ -160,6 +161,7 @@ export default function BecomeCoachPage() {
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [step, setStep] = useState(0);
const draft = useLabDraft(
`craft:coach`,
@ -370,13 +372,15 @@ export default function BecomeCoachPage() {
setAttempted(true);
if (contactError) {
scrollTo(emailError ? 'email' : 'website');
setStep(4);
window.setTimeout(() => scrollTo(emailError ? 'email' : 'website'), 60);
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
if (selectedSkills.length === 0) {
scrollTo('coach-skills');
setStep(1);
window.setTimeout(() => scrollTo('coach-skills'), 60);
toast({ title: t('labUi.coachForm.skillsRequired'), description: t('labUi.coachForm.selectSkill'), variant: 'destructive' });
return;
}
@ -590,6 +594,27 @@ export default function BecomeCoachPage() {
<CardDescription className="text-gray-400">{t('labUi.coachForm.profileDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<FormWizard
steps={[
{ id: 'identity', title: t('labUi.wizard.identity') },
{ id: 'coach', title: t('labUi.coachForm.sectionWhatYouCoach') },
{ id: 'creds', title: t('labUi.coachForm.sectionCredentials') },
{ id: 'pay', title: t('labUi.coachForm.sectionPayments') },
{ id: 'where', title: t('labUi.coachForm.sectionWhereYouCoach') },
]}
step={step}
onStepChange={setStep}
accentChip="bg-red-500 text-white border-red-500"
submitting={isPublishing || isUploading}
submitLabel={existingProfile ? t('labUi.coachForm.update') : isPro ? t('labUi.coachForm.createPro') : t('labUi.coachForm.create')}
submittingLabel={t('labUi.become.publishing')}
footerExtra={
<Button type="button" variant="outline" onClick={() => setPreviewOpen(true)} className="h-12 flex-shrink-0 gap-2 border-gray-700 font-bold">
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
}
>
<WizardStep index={0}>
<div className="space-y-2">
<Label>{t('labUi.coachForm.identity')}</Label>
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-700 bg-gray-800/50">
@ -641,6 +666,8 @@ export default function BecomeCoachPage() {
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90" accentText="text-[#ff0040]" />
)}
</WizardStep>
<WizardStep index={1}>
<FormSection title={t('labUi.coachForm.sectionWhatYouCoach')} />
<div className="space-y-3">
@ -695,6 +722,8 @@ export default function BecomeCoachPage() {
idPrefix="age"
/>
</WizardStep>
<WizardStep index={2}>
<FormSection title={t('labUi.coachForm.sectionCredentials')} />
<OptionChips
@ -812,6 +841,8 @@ export default function BecomeCoachPage() {
</div>
</div>
</WizardStep>
<WizardStep index={3}>
<FormSection title={t('labUi.coachForm.sectionPayments')} />
<OptionChips
@ -870,6 +901,8 @@ export default function BecomeCoachPage() {
<Input id="payment-note" placeholder={t('labUi.coachForm.paymentNotePlaceholder')} value={paymentNote} onChange={(e) => setPaymentNote(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</WizardStep>
<WizardStep index={4}>
<FormSection title={t('labUi.coachForm.sectionWhereYouCoach')} />
<div className="space-y-2">
@ -998,33 +1031,8 @@ export default function BecomeCoachPage() {
{/* Optional Pro upgrade — free to list without it */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreviewOpen(true)}
className="h-10 flex-shrink-0 gap-2 border-gray-700 font-bold"
>
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
<Button
type="submit"
className="flex-1 bg-red-500 hover:bg-red-600 uppercase font-black"
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('labUi.become.publishing')}
</>
) : (
<>
<GraduationCap className="h-4 w-4 mr-2" />
{existingProfile ? t('labUi.coachForm.update') : isPro ? t('labUi.coachForm.createPro') : t('labUi.coachForm.create')}
</>
)}
</Button>
</div>
</WizardStep>
</FormWizard>
</CardContent>
</Card>
</form>

View file

@ -227,11 +227,13 @@ export default function BecomeEditorPage() {
e.preventDefault();
if (contactError) {
document.getElementById('contact-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
toast({ title: t('labUi.editorForm.servicesRequired'), description: t('labUi.editorForm.selectService'), variant: 'destructive' });
return;
}
@ -561,7 +563,7 @@ export default function BecomeEditorPage() {
<Input placeholder={t('labUi.become.website')} value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder={t('labUi.editorForm.socialPlaceholder')} value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
{contactError && <p id="contact-error" className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
</div>

View file

@ -240,11 +240,13 @@ export default function BecomeFilmerPage() {
e.preventDefault();
if (contactError) {
document.getElementById('contact-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
toast({ title: t('labUi.filmerForm.servicesRequired'), description: t('labUi.filmerForm.selectService'), variant: 'destructive' });
return;
}
@ -593,7 +595,7 @@ export default function BecomeFilmerPage() {
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
{contactError && <p id="contact-error" className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />

View file

@ -302,11 +302,13 @@ export default function BecomePhotographerPage() {
e.preventDefault();
if (contactError) {
document.getElementById('contact-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
toast({ title: t('labUi.photoForm.servicesRequired'), description: t('labUi.photoForm.selectService'), variant: 'destructive' });
return;
}
@ -662,7 +664,7 @@ export default function BecomePhotographerPage() {
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
{contactError && <p id="contact-error" className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">