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:
parent
3e84310384
commit
3d52f07144
9 changed files with 174 additions and 22 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
55
src/components/LabProfileProgress.tsx
Normal file
55
src/components/LabProfileProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue