Lab forms: profile-completeness progress + scroll-to/inline validation

Progress (interpreted as a completeness indicator, not a wizard — the form
stays one scroll):
- LabProfileProgress shows photo/bio/skills/rate/location/contact as done/todo
  with a % bar; wired into CraftProfileForm, coach, and the 4 bespoke pages.

Validation (was toast-only):
- CraftProfileForm splits email/website/portfolio into per-field inline errors
  with a red field border, gives the first skill section + contact fields ids,
  and scrolls to the first problem on submit; required sections show an inline
  message after a submit attempt.
- Coach page likewise splits email/website, shows inline errors + red borders,
  and scrolls to the contact field or the skills grid.
This commit is contained in:
SkateSpots 2026-10-04 11:39:31 +07:00
parent 3e84310384
commit 3d52f07144
9 changed files with 174 additions and 22 deletions

View file

@ -25,6 +25,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { useLabDraft } from '@/hooks/useLabDraft';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { isValidNoffer, type EntityType } from '@/lib/types';
import type { LabOption } from '@/lib/labExtras';
import { labProfilePathFromKind } from '@/lib/labRoutes';
@ -239,18 +240,22 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
// Contact details are optional, but if provided they must be usable — an
// invalid email/URL would otherwise be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? t('labUi.become.invalidWebsite')
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? t('labUi.become.invalidPortfolio')
: '';
const emailError = email.trim() && !isValidEmail(email) ? t('labUi.become.invalidEmail') : '';
const websiteError = website.trim() && !sanitizeUrl(normalizeUrlInput(website)) ? t('labUi.become.invalidWebsite') : '';
const portfolioError = portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) ? t('labUi.become.invalidPortfolio') : '';
const contactError = emailError || websiteError || portfolioError;
const [attempted, setAttempted] = useState(false);
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setAttempted(true);
if (contactError) {
scrollTo(emailError ? 'craft-email' : websiteError ? 'craft-website' : 'craft-portfolio');
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
@ -259,6 +264,7 @@ 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');
toast({ title: t('labUi.craftForm.sectionRequired', { section: section.title }), description: t('labUi.craftForm.sectionRequiredDesc', { section: section.title }), variant: 'destructive' });
return;
}
@ -410,6 +416,14 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
)}
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selected.length > 0}
hasRate={!!rate.trim()}
hasLocation={!!location.trim()}
hasContact={!!(email || website || phone || social)}
/>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Image + Bio */}
@ -471,8 +485,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</Card>
{/* Skill sections */}
{skillSections.map((section) => (
<Card key={section.title} className="bg-black/60 border-white/10">
{skillSections.map((section, sectionIndex) => (
<Card key={section.title} id={sectionIndex === 0 ? 'craft-skills' : undefined} className="bg-black/60 border-white/10">
<CardContent className="p-5">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
{section.title}
@ -497,6 +511,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
);
})}
</div>
{attempted && section.required && !section.entries.some(([k]) => selected.includes(k)) && (
<p className="mt-2 text-xs font-bold text-red-400">{t('labUi.craftForm.sectionRequiredDesc', { section: section.title })}</p>
)}
</CardContent>
</Card>
))}
@ -570,12 +587,20 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.contactLinks')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder={t('labUi.craftForm.phoneOptional')} className="bg-white/5 border-white/10" />
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('labUi.craftForm.emailOptional')} type="email" className="bg-white/5 border-white/10" />
<Input value={website} onChange={(e) => setWebsite(e.target.value)} placeholder={t('labUi.become.website')} inputMode="url" className="bg-white/5 border-white/10" />
<div>
<Input id="craft-email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('labUi.craftForm.emailOptional')} type="email" className={cn('bg-white/5 border-white/10', emailError && 'border-red-500')} />
{emailError && <p className="mt-1 text-xs text-red-400">{emailError}</p>}
</div>
<div>
<Input id="craft-website" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder={t('labUi.become.website')} inputMode="url" className={cn('bg-white/5 border-white/10', websiteError && 'border-red-500')} />
{websiteError && <p className="mt-1 text-xs text-red-400">{websiteError}</p>}
</div>
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder={t('labUi.craftForm.socialPlaceholder')} className="bg-white/5 border-white/10" />
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
<div className="sm:col-span-2">
<Input id="craft-portfolio" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} />
{portfolioError && <p className="mt-1 text-xs text-red-400">{portfolioError}</p>}
</div>
</div>
{contactError && <p className="text-xs text-red-400">{contactError}</p>}
</CardContent>
</Card>

View file

@ -0,0 +1,55 @@
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
interface LabProfileProgressProps {
hasPhoto: boolean;
hasBio: boolean;
hasSkills: boolean;
hasRate: boolean;
hasLocation: boolean;
hasContact: boolean;
}
/**
* Compact "profile completeness" bar for the long Lab offer forms. Not a wizard
* — the form stays in one scroll — but it shows what's still worth adding so an
* incomplete listing isn't published blind.
*/
export function LabProfileProgress(props: LabProfileProgressProps) {
const { t } = useTranslation();
const steps = [
{ label: t('labUi.progress.photo'), done: props.hasPhoto },
{ label: t('labUi.progress.bio'), done: props.hasBio },
{ label: t('labUi.progress.skills'), done: props.hasSkills },
{ label: t('labUi.progress.rate'), done: props.hasRate },
{ label: t('labUi.progress.location'), done: props.hasLocation },
{ label: t('labUi.progress.contact'), done: props.hasContact },
];
const done = steps.filter((s) => s.done).length;
const pct = Math.round((done / steps.length) * 100);
return (
<div className="mb-4 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">{t('labUi.progress.title')}</span>
<span className="text-[11px] font-bold text-gray-400">{t('labUi.progress.count', { done, total: steps.length })}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-[#ccff00] transition-all" style={{ width: `${pct}%` }} />
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{steps.map((s) => (
<span
key={s.label}
className={cn(
'rounded-full border px-2 py-0.5 text-[11px] font-semibold',
s.done ? 'border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/10 text-gray-500',
)}
>
{s.done ? '✓ ' : ''}{s.label}
</span>
))}
</div>
</div>
);
}

View file

@ -2011,6 +2011,16 @@ labUi: {
resume: 'Resume draft',
discard: 'Discard draft',
},
progress: {
title: 'Profile completeness',
count: '{{done}} of {{total}} done',
photo: 'Photo',
bio: 'Bio',
skills: 'Skills',
rate: 'Rate',
location: 'Location',
contact: 'Contact',
},
offerTattoo: 'Offer Tattoo',
offerTraining: 'Offer Training / Physio',
organizeEvents: 'Organize Events',

View file

@ -2011,6 +2011,16 @@ labUi: {
resume: 'Reanudar borrador',
discard: 'Descartar borrador',
},
progress: {
title: 'Perfil completo',
count: '{{done}} de {{total}}',
photo: 'Foto',
bio: 'Descripción',
skills: 'Aptitudes',
rate: 'Tarifa',
location: 'Ubicación',
contact: 'Contacto',
},
offerTattoo: 'Ofrecer tatuajes',
offerTraining: 'Ofrecer entrenamiento / fisio',
organizeEvents: 'Organizar eventos',

View file

@ -49,6 +49,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { useLabDraft } from '@/hooks/useLabDraft';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
@ -465,6 +466,14 @@ export default function BecomeBuilderPage() {
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selectedServices.length + selectedSkills.length + selectedScale.length > 0}
hasRate={!!rate.trim()}
hasContact={!!(email || website || phone || social)}
hasLocation={!!location.trim()}
/>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">

View file

@ -34,6 +34,7 @@ import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalL
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { useLabDraft } from '@/hooks/useLabDraft';
import { labProfilePath } from '@/lib/labRoutes';
import { usePlatformFee } from '@/hooks/usePlatformFee';
@ -340,11 +341,11 @@ export default function BecomeCoachPage() {
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? t('labUi.become.invalidWebsite')
: '';
const emailError = email.trim() && !isValidEmail(email) ? t('labUi.become.invalidEmail') : '';
const websiteError = website.trim() && !sanitizeUrl(normalizeUrlInput(website)) ? t('labUi.become.invalidWebsite') : '';
const contactError = emailError || websiteError;
const [attempted, setAttempted] = useState(false);
const scrollTo = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Draft enrichment tags for the live preview (same tags the form publishes).
const previewExtraTags: string[][] = [];
@ -366,13 +367,16 @@ export default function BecomeCoachPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setAttempted(true);
if (contactError) {
scrollTo(emailError ? 'email' : 'website');
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
if (selectedSkills.length === 0) {
scrollTo('coach-skills');
toast({ title: t('labUi.coachForm.skillsRequired'), description: t('labUi.coachForm.selectSkill'), variant: 'destructive' });
return;
}
@ -570,6 +574,14 @@ export default function BecomeCoachPage() {
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selectedSkills.length > 0}
hasRate={!!(rate.trim() || rateAmount.trim())}
hasContact={!!(email || website || phone || instagram)}
hasLocation={!!location.trim()}
/>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
@ -633,7 +645,7 @@ export default function BecomeCoachPage() {
<div className="space-y-3">
<Label>{t('labUi.coachForm.skillsLabel')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div id="coach-skills" className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => (
<button
key={skill}
@ -653,6 +665,9 @@ export default function BecomeCoachPage() {
</button>
))}
</div>
{attempted && selectedSkills.length === 0 && (
<p className="text-xs font-bold text-red-400">{t('labUi.coachForm.selectSkill')}</p>
)}
</div>
<OptionChips
@ -873,18 +888,19 @@ export default function BecomeCoachPage() {
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('labUi.become.email')}</Label>
<Input id="email" type="email" placeholder="coach@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input id="email" type="email" placeholder="coach@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className={cn('bg-gray-800 border-gray-700', emailError && 'border-red-500')} />
{emailError && <p className="text-xs text-red-400">{emailError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="website">{t('labUi.become.website')}</Label>
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className={cn('bg-gray-800 border-gray-700', websiteError && 'border-red-500')} />
{websiteError && <p className="text-xs text-red-400">{websiteError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="instagram">{t('labUi.become.instagram')}</Label>
<Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />

View file

@ -40,6 +40,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { useLabDraft } from '@/hooks/useLabDraft';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
@ -380,6 +381,14 @@ export default function BecomeEditorPage() {
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selectedServices.length + selectedSkills.length > 0}
hasRate={!!rate.trim()}
hasContact={!!(email || website || phone || social)}
hasLocation={!!location.trim()}
/>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">

View file

@ -34,6 +34,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { useLabDraft } from '@/hooks/useLabDraft';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
@ -430,6 +431,14 @@ export default function BecomeFilmerPage() {
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selectedServices.length + selectedSkills.length > 0}
hasRate={!!rate.trim()}
hasContact={!!(email || website || phone || social)}
hasLocation={!!location.trim()}
/>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">

View file

@ -54,6 +54,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { useLabDraft } from '@/hooks/useLabDraft';
import { labProfilePath } from '@/lib/labRoutes';
import { pickRateUnits } from '@/lib/labExtras';
@ -489,6 +490,14 @@ export default function BecomePhotographerPage() {
</div>
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selectedServices.length + selectedCameras.length + selectedLenses.length + selectedLighting.length + selectedSupport.length + selectedStyles.length > 0}
hasRate={!!rate.trim()}
hasContact={!!(email || website || phone || social)}
hasLocation={!!location.trim()}
/>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">