From 3d52f071440f26e37cefa094e778b36fd985ab86 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 4 Oct 2026 11:39:31 +0700 Subject: [PATCH] Lab forms: profile-completeness progress + scroll-to/inline validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/CraftProfileForm.tsx | 51 ++++++++++++++++++------- src/components/LabProfileProgress.tsx | 55 +++++++++++++++++++++++++++ src/i18n/en.ts | 10 +++++ src/i18n/es.ts | 10 +++++ src/pages/BecomeBuilderPage.tsx | 9 +++++ src/pages/BecomeCoachPage.tsx | 34 ++++++++++++----- src/pages/BecomeEditorPage.tsx | 9 +++++ src/pages/BecomeFilmerPage.tsx | 9 +++++ src/pages/BecomePhotographerPage.tsx | 9 +++++ 9 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 src/components/LabProfileProgress.tsx diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index a2ef469..81e812d 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -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 && } + 20} + hasSkills={selected.length > 0} + hasRate={!!rate.trim()} + hasLocation={!!location.trim()} + hasContact={!!(email || website || phone || social)} + />
{/* Image + Bio */} @@ -471,8 +485,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { {/* Skill sections */} - {skillSections.map((section) => ( - + {skillSections.map((section, sectionIndex) => ( + ))} @@ -570,12 +587,20 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
setPhone(e.target.value)} placeholder={t('labUi.craftForm.phoneOptional')} className="bg-white/5 border-white/10" /> - setEmail(e.target.value)} placeholder={t('labUi.craftForm.emailOptional')} type="email" className="bg-white/5 border-white/10" /> - setWebsite(e.target.value)} placeholder={t('labUi.become.website')} inputMode="url" className="bg-white/5 border-white/10" /> +
+ setEmail(e.target.value)} placeholder={t('labUi.craftForm.emailOptional')} type="email" className={cn('bg-white/5 border-white/10', emailError && 'border-red-500')} /> + {emailError &&

{emailError}

} +
+
+ setWebsite(e.target.value)} placeholder={t('labUi.become.website')} inputMode="url" className={cn('bg-white/5 border-white/10', websiteError && 'border-red-500')} /> + {websiteError &&

{websiteError}

} +
setSocial(e.target.value)} placeholder={t('labUi.craftForm.socialPlaceholder')} className="bg-white/5 border-white/10" /> - setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" /> +
+ setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} /> + {portfolioError &&

{portfolioError}

} +
- {contactError &&

{contactError}

}
diff --git a/src/components/LabProfileProgress.tsx b/src/components/LabProfileProgress.tsx new file mode 100644 index 0000000..a60fe27 --- /dev/null +++ b/src/components/LabProfileProgress.tsx @@ -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 ( +
+
+ {t('labUi.progress.title')} + {t('labUi.progress.count', { done, total: steps.length })} +
+
+
+
+
+ {steps.map((s) => ( + + {s.done ? '✓ ' : ''}{s.label} + + ))} +
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ec7f0ce..7ae3aec 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 3078fb3..62ea146 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index 4075ced..a604adf 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -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() {
{draft.resumable && } + 20} + hasSkills={selectedServices.length + selectedSkills.length + selectedScale.length > 0} + hasRate={!!rate.trim()} + hasContact={!!(email || website || phone || social)} + hasLocation={!!location.trim()} + /> diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index d0da735..4be2e1d 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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() { {draft.resumable && } + 20} + hasSkills={selectedSkills.length > 0} + hasRate={!!(rate.trim() || rateAmount.trim())} + hasContact={!!(email || website || phone || instagram)} + hasLocation={!!location.trim()} + /> @@ -633,7 +645,7 @@ export default function BecomeCoachPage() {
-
+
{SKILL_ENTRIES.map(([skill, label]) => (
+ {attempted && selectedSkills.length === 0 && ( +

{t('labUi.coachForm.selectSkill')}

+ )}
- setEmail(e.target.value)} className="bg-gray-800 border-gray-700" /> + setEmail(e.target.value)} className={cn('bg-gray-800 border-gray-700', emailError && 'border-red-500')} /> + {emailError &&

{emailError}

}
- setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" /> + setWebsite(e.target.value)} className={cn('bg-gray-800 border-gray-700', websiteError && 'border-red-500')} /> + {websiteError &&

{websiteError}

}
setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
- {contactError &&

{contactError}

} diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index 4b301f3..3b9ee66 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -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() { {draft.resumable && } + 20} + hasSkills={selectedServices.length + selectedSkills.length > 0} + hasRate={!!rate.trim()} + hasContact={!!(email || website || phone || social)} + hasLocation={!!location.trim()} + /> diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 6b18039..d43fd61 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -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() { {draft.resumable && } + 20} + hasSkills={selectedServices.length + selectedSkills.length > 0} + hasRate={!!rate.trim()} + hasContact={!!(email || website || phone || social)} + hasLocation={!!location.trim()} + /> diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index 27339bb..53139b7 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -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() { {draft.resumable && } + 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()} + />