/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
specialist-setup
/
/opt/canhelp/apps/web/src/app/specialist-setup
mkdir
upload
Name
Size
Mode
Actions
page.tsx
26508
0644
edit
dl
rm
Edit:
/opt/canhelp/apps/web/src/app/specialist-setup/page.tsx
(26508B)
'use client' import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import * as api from '@/lib/api' import type { SpecialistCard } from '@/lib/api' import { CategoryIcon, isCategoryIconUrl } from '@/components/CategoryIcon' const TOTAL_STEPS = 5 // ─── Step indicator ──────────────────────────────────────────────────────── function StepIndicator({ current, total }: { current: number; total: number }) { return ( <div className="flex items-center justify-center gap-2 mb-8"> {Array.from({ length: total }, (_, i) => i + 1).map((n) => ( <div key={n} className="flex items-center gap-2"> <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors ${ n < current ? 'bg-green-500 text-white' : n === current ? 'bg-green-600 text-white ring-4 ring-blue-200' : 'bg-gray-200 text-gray-400' }`} > {n < current ? ( <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <path d="M2 7l4 4 6-6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> ) : ( n )} </div> {n < total && ( <div className={`w-8 h-0.5 ${n < current ? 'bg-green-500' : 'bg-gray-200'}`} /> )} </div> ))} </div> ) } // ─── Step 1: Welcome ─────────────────────────────────────────────────────── function Step1Welcome({ t, onNext }: { t: (k: string, fb?: string) => string; onNext: () => void }) { return ( <div className="text-center"> <div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> <circle cx="12" cy="7" r="4" /> </svg> </div> <h2 className="text-2xl font-bold text-gray-900 mb-2">{t('specialist_setup.step1.title')}</h2> <p className="text-green-600 font-medium mb-4">{t('specialist_setup.step1.subtitle')}</p> <p className="text-gray-500 text-sm leading-relaxed mb-8 max-w-sm mx-auto"> {t('specialist_setup.step1.desc')} </p> <div className="bg-gray-50 rounded-xl p-5 text-left mb-8 max-w-sm mx-auto"> <p className="text-sm font-semibold text-gray-700 mb-3">{t('specialist_setup.step1.what_next')}</p> <ul className="space-y-2"> {[ t('specialist_setup.step1.item1'), t('specialist_setup.step1.item2'), t('specialist_setup.step1.item3'), t('specialist_setup.step1.item4'), t('specialist_setup.step1.item5'), ].map((item, i) => ( <li key={i} className="flex items-center gap-2 text-sm text-gray-600"> <span className="w-5 h-5 bg-green-100 text-green-600 rounded-full flex items-center justify-center text-xs font-bold shrink-0"> {i + 1} </span> {item} </li> ))} </ul> </div> <button onClick={onNext} className="w-full max-w-xs bg-green-600 text-white py-3 rounded-xl font-semibold hover:bg-green-700 transition" > {t('specialist_setup.next')} → </button> </div> ) } // ─── Step 2: Basic profile ───────────────────────────────────────────────── function Step2Profile({ t, onNext, onBack, }: { t: (k: string, fb?: string) => string onNext: () => void onBack: () => void }) { const [bio, setBio] = useState('') const [phone, setPhone] = useState('') const [saving, setSaving] = useState(false) const [avatarUrl, setAvatarUrl] = useState<string | null>(null) const [uploading, setUploading] = useState(false) const fileRef = useRef<HTMLInputElement>(null) useEffect(() => { api.request<any>('/users/me', {}).then((u) => { setBio(u.bio ?? '') setPhone(u.phone ?? '') setAvatarUrl(u.image ?? null) }).catch(() => {}) }, []) async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0] if (!file) return setUploading(true) try { const form = new FormData() form.append('file', file) const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/uploads`, { method: 'POST', credentials: 'include', body: form }, ) if (!res.ok) throw new Error('Upload failed') const { url } = await res.json() const fullUrl = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}${url}` await api.request('/users/me', { method: 'PATCH', body: JSON.stringify({ image: fullUrl }) }) setAvatarUrl(fullUrl) } catch {} finally { setUploading(false) if (fileRef.current) fileRef.current.value = '' } } async function handleSave() { setSaving(true) try { await api.request('/users/me', { method: 'PATCH', body: JSON.stringify({ bio: bio || undefined, phone: phone || undefined }), }) onNext() } catch {} finally { setSaving(false) } } return ( <div> <h2 className="text-xl font-bold text-gray-900 mb-1">{t('specialist_setup.step2.title')}</h2> <p className="text-gray-500 text-sm mb-6">{t('specialist_setup.step2.subtitle')}</p> {/* Avatar */} <div className="mb-5"> <p className="text-sm font-medium text-gray-700 mb-2">{t('specialist_setup.step2.photo_label')}</p> <div className="flex items-center gap-4"> <div className="w-16 h-16 rounded-full bg-gray-100 border-2 border-gray-200 overflow-hidden shrink-0 flex items-center justify-center"> {avatarUrl ? ( <img src={avatarUrl} alt="" className="w-full h-full object-cover" /> ) : ( <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2"> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> <circle cx="12" cy="7" r="4" /> </svg> )} </div> <div> <button type="button" onClick={() => fileRef.current?.click()} disabled={uploading} className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium text-gray-700 disabled:opacity-50 transition" > {uploading ? t('specialist_setup.step2.photo_uploading') : t('specialist_setup.step2.photo_btn')} </button> <p className="text-xs text-gray-400 mt-1">{t('specialist_setup.step2.photo_hint')}</p> </div> <input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={handleAvatarChange} /> </div> </div> {/* Bio */} <div className="mb-4"> <label className="block text-sm font-medium text-gray-700 mb-1">{t('specialist_setup.step2.bio_label')}</label> <textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={4} maxLength={1000} placeholder={t('specialist_setup.step2.bio_placeholder')} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none" /> </div> {/* Phone */} <div className="mb-6"> <label className="block text-sm font-medium text-gray-700 mb-1">{t('specialist_setup.step2.phone_label')}</label> <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder={t('specialist_setup.step2.phone_placeholder')} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> </div> <NavButtons t={t} onBack={onBack} onNext={handleSave} loading={saving} /> </div> ) } // ─── Step 3: Service card ────────────────────────────────────────────────── function Step3Card({ t, onNext, onBack, onCardCreated, }: { t: (k: string, fb?: string) => string onNext: () => void onBack: () => void onCardCreated: (card: SpecialistCard) => void }) { const [title, setTitle] = useState('') const [desc, setDesc] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') async function handleSave() { if (!title.trim()) { setError(t('specialist_setup.step3.title_required')); return } setError('') setSaving(true) try { const card = await api.createSpecialistCard({ title: title.trim(), description: desc || undefined }) onCardCreated(card) onNext() } catch {} finally { setSaving(false) } } return ( <div> <h2 className="text-xl font-bold text-gray-900 mb-1">{t('specialist_setup.step3.title')}</h2> <p className="text-gray-500 text-sm mb-6">{t('specialist_setup.step3.subtitle')}</p> <div className="mb-4"> <label className="block text-sm font-medium text-gray-700 mb-1">{t('specialist_setup.step3.title_label')}</label> <input value={title} onChange={(e) => { setTitle(e.target.value); if (error) setError('') }} maxLength={200} placeholder={t('specialist_setup.step3.title_placeholder')} className={`w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 ${error ? 'border-red-400' : 'border-gray-300'}`} /> {error && <p className="text-red-500 text-xs mt-1">{error}</p>} </div> <div className="mb-6"> <label className="block text-sm font-medium text-gray-700 mb-1">{t('specialist_setup.step3.desc_label')}</label> <textarea value={desc} onChange={(e) => setDesc(e.target.value)} rows={5} maxLength={1000} placeholder={t('specialist_setup.step3.desc_placeholder')} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none" /> </div> <NavButtons t={t} onBack={onBack} onNext={handleSave} loading={saving} /> </div> ) } // ─── Step 4: Skills & Categories ────────────────────────────────────────── function Step4Skills({ t, onNext, onBack, card, catLocale, }: { t: (k: string, fb?: string) => string onNext: () => void onBack: () => void card: SpecialistCard | null catLocale: string }) { const [skills, setSkills] = useState<string[]>(card?.skills ?? []) const [skillInput, setSkillInput] = useState('') const [categories, setCategories] = useState<string[]>(card?.categories ?? []) const [allCategories, setAllCategories] = useState<any[]>([]) const [suggestions, setSuggestions] = useState<api.SkillSuggestion[]>([]) const [saving, setSaving] = useState(false) useEffect(() => { api.getCategories().then(setAllCategories).catch(() => {}) }, []) useEffect(() => { api.getSkillSuggestions(categories.length > 0 ? categories : undefined) .then(setSuggestions).catch(() => {}) }, [categories]) function addSkill(s: string) { const trimmed = s.trim() if (trimmed && !skills.includes(trimmed)) setSkills([...skills, trimmed]) setSkillInput('') } function toggleCategory(slug: string, childSlugs: string[] = []) { if (categories.includes(slug)) { setCategories(categories.filter((c) => c !== slug && !childSlugs.includes(c))) } else { setCategories([...new Set([...categories, slug, ...childSlugs])]) } } async function handleSave() { if (!card) { onNext(); return } setSaving(true) try { await api.updateSpecialistCard(card.id, { skills, categories }) onNext() } catch {} finally { setSaving(false) } } return ( <div> <h2 className="text-xl font-bold text-gray-900 mb-1">{t('specialist_setup.step4.title')}</h2> <p className="text-gray-500 text-sm mb-6">{t('specialist_setup.step4.subtitle')}</p> {/* Skills */} <div className="mb-6"> <label className="block text-sm font-medium text-gray-700 mb-2">{t('specialist_setup.step4.skills_label')}</label> {skills.length > 0 && ( <div className="flex flex-wrap gap-2 mb-2"> {skills.map((s) => ( <span key={s} className="flex items-center gap-1.5 bg-green-50 text-green-700 text-sm px-3 py-1 rounded-full"> {s} <button type="button" onClick={() => setSkills(skills.filter((x) => x !== s))} className="text-gray-400 hover:text-green-700 leading-none text-base">×</button> </span> ))} </div> )} <div className="flex gap-2"> <input value={skillInput} onChange={(e) => setSkillInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSkill(skillInput) } }} placeholder={t('specialist_setup.step4.skills_placeholder')} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> <button type="button" onClick={() => addSkill(skillInput)} className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"> {t('specialist_setup.step4.skills_add')} </button> </div> {suggestions.length > 0 && ( <div className="flex flex-wrap gap-1.5 mt-2"> {suggestions .map((s) => s.names[catLocale as 'el' | 'en' | 'uk' | 'ru'] ?? s.names.el) .filter((name, i, arr) => arr.indexOf(name) === i && !skills.includes(name)) .slice(0, 12) .map((name) => ( <button key={name} type="button" onClick={() => addSkill(name)} className="text-xs px-2.5 py-1 border border-gray-200 rounded-full text-gray-500 hover:border-green-400 hover:text-green-600 transition"> + {name} </button> ))} </div> )} </div> {/* Categories */} <div className="mb-6"> <label className="block text-sm font-medium text-gray-700 mb-2">{t('specialist_setup.step4.categories_label')}</label> <div className="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-xl p-3"> {allCategories.map((cat: any) => { const catName = cat.names?.[catLocale] ?? cat.names?.el ?? cat.slug const children: any[] = cat.children ?? [] const childSlugs = children.map((c: any) => c.slug) return ( <div key={cat.id}> <label className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={categories.includes(cat.slug)} onChange={() => toggleCategory(cat.slug, childSlugs)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400" /> <span className="text-sm font-medium text-gray-800 inline-flex items-center gap-1"> {cat.icon && ( <CategoryIcon icon={cat.icon} alt={catName} className={isCategoryIconUrl(cat.icon) ? 'h-4 w-4 object-contain' : 'text-sm leading-none'} fallback="📦" /> )} {catName} </span> </label> {children.length > 0 && ( <div className="ml-6 mt-1.5 space-y-1.5"> {children.map((sub: any) => { const subName = sub.names?.[catLocale] ?? sub.names?.el ?? sub.slug return ( <label key={sub.id} className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={categories.includes(sub.slug)} onChange={() => toggleCategory(sub.slug)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400" /> <span className="text-sm text-gray-600">{subName}</span> </label> ) })} </div> )} </div> ) })} </div> </div> <NavButtons t={t} onBack={onBack} onNext={handleSave} loading={saving} /> </div> ) } // ─── Step 5: Geography ───────────────────────────────────────────────────── function Step5Geography({ t, onNext, onBack, card, catLocale, }: { t: (k: string, fb?: string) => string onNext: () => void onBack: () => void card: SpecialistCard | null catLocale: string }) { const [locations, setLocations] = useState<string[]>(card?.locations ?? []) const [locationTree, setLocationTree] = useState<any[]>([]) const [saving, setSaving] = useState(false) useEffect(() => { api.getLocations().then(setLocationTree).catch(() => {}) }, []) function toggleLocation(slug: string, childSlugs: string[] = []) { if (locations.includes(slug)) { setLocations(locations.filter((c) => c !== slug && !childSlugs.includes(c))) } else { setLocations([...new Set([...locations, slug, ...childSlugs])]) } } async function handleSave() { if (!card) { onNext(); return } setSaving(true) try { await api.updateSpecialistCard(card.id, { locations }) onNext() } catch {} finally { setSaving(false) } } return ( <div> <h2 className="text-xl font-bold text-gray-900 mb-1">{t('specialist_setup.step5.title')}</h2> <p className="text-gray-500 text-sm mb-2">{t('specialist_setup.step5.subtitle')}</p> <p className="text-xs text-gray-400 mb-5">{t('specialist_setup.step5.hint')}</p> <div className="space-y-3 max-h-72 overflow-y-auto border border-gray-200 rounded-xl p-3 mb-6"> {locationTree.map((city: any) => { const cityName = city.names?.[catLocale] ?? city.names?.el ?? city.slug const districts: any[] = city.children ?? [] const districtSlugs = districts.map((d: any) => d.slug) return ( <div key={city.id}> <label className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={locations.includes(city.slug)} onChange={() => toggleLocation(city.slug, districtSlugs)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400" /> <span className="text-sm font-medium text-gray-800">{cityName}</span> </label> {districts.length > 0 && ( <div className="ml-6 mt-1.5 space-y-1.5"> {districts.map((d: any) => { const dName = d.names?.[catLocale] ?? d.names?.el ?? d.slug return ( <label key={d.id} className="flex items-center gap-2.5 cursor-pointer"> <input type="checkbox" checked={locations.includes(d.slug)} onChange={() => toggleLocation(d.slug)} className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-blue-400" /> <span className="text-sm text-gray-600">{dName}</span> </label> ) })} </div> )} </div> ) })} </div> <NavButtons t={t} onBack={onBack} onNext={handleSave} loading={saving} isLast /> </div> ) } // ─── Done screen ─────────────────────────────────────────────────────────── function StepDone({ t }: { t: (k: string, fb?: string) => string }) { return ( <div className="text-center py-4"> <div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <path d="M20 6L9 17l-5-5" /> </svg> </div> <h2 className="text-2xl font-bold text-gray-900 mb-2">{t('specialist_setup.done.title')}</h2> <p className="text-green-600 font-medium mb-4">{t('specialist_setup.done.subtitle')}</p> <p className="text-gray-500 text-sm leading-relaxed mb-8 max-w-sm mx-auto"> {t('specialist_setup.done.desc')} </p> <div className="flex flex-col sm:flex-row gap-3 justify-center"> <a href="/profile" className="px-6 py-3 bg-green-600 text-white rounded-xl font-semibold hover:bg-green-700 transition text-center" > {t('specialist_setup.go_to_profile')} </a> <a href="/tasks" className="px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition text-center" > {t('specialist_setup.browse_tasks')} </a> </div> </div> ) } // ─── Navigation buttons ──────────────────────────────────────────────────── function NavButtons({ t, onBack, onNext, loading, isLast, }: { t: (k: string, fb?: string) => string onBack: () => void onNext: () => void loading?: boolean isLast?: boolean }) { return ( <div className="flex gap-3 pt-2"> <button type="button" onClick={onBack} className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition" > ← {t('specialist_setup.back')} </button> <button type="button" onClick={onNext} disabled={loading} className="flex-[2] px-4 py-3 bg-green-600 text-white rounded-xl font-semibold hover:bg-green-700 disabled:opacity-50 transition" > {loading ? '...' : isLast ? t('specialist_setup.finish') : `${t('specialist_setup.next')} →`} </button> </div> ) } // ─── Main page ───────────────────────────────────────────────────────────── export default function SpecialistSetupPage() { const { data: session, isPending } = useSession() const router = useRouter() const { t, locale } = useLocale() const [step, setStep] = useState(1) const [card, setCard] = useState<SpecialistCard | null>(null) const catLocale = locale === 'ru' ? 'ru' : locale === 'uk' ? 'uk' : locale === 'en' ? 'en' : 'el' useEffect(() => { if (!isPending && !session) router.push('/login') }, [session, isPending]) if (isPending || !session) { return <div className="text-center py-20 text-white">{t('common.loading')}</div> } const isDone = step > TOTAL_STEPS return ( <div className="min-h-screen py-10 px-4"> <div className="max-w-lg mx-auto"> {/* Header */} <div className="text-center mb-6"> <h1 className="text-lg font-semibold text-white/90">{t('specialist_setup.page_title')}</h1> {!isDone && ( <p className="text-sm text-white/50 mt-0.5"> {t('specialist_setup.step_of') .replace('{current}', String(step)) .replace('{total}', String(TOTAL_STEPS))} </p> )} </div> {/* Progress */} {!isDone && <StepIndicator current={step} total={TOTAL_STEPS} />} {/* Card */} <div className="bg-white rounded-2xl border border-gray-200 p-6 shadow-sm"> {isDone && <StepDone t={t} />} {step === 1 && ( <Step1Welcome t={t} onNext={() => setStep(2)} /> )} {step === 2 && ( <Step2Profile t={t} onNext={() => setStep(3)} onBack={() => setStep(1)} /> )} {step === 3 && ( <Step3Card t={t} onNext={() => setStep(4)} onBack={() => setStep(2)} onCardCreated={setCard} /> )} {step === 4 && ( <Step4Skills t={t} onNext={() => setStep(5)} onBack={() => setStep(3)} card={card} catLocale={catLocale} /> )} {step === 5 && ( <Step5Geography t={t} onNext={() => setStep(6)} onBack={() => setStep(4)} card={card} catLocale={catLocale} /> )} </div> {/* Skip link (steps 2-5) */} {step >= 2 && step <= TOTAL_STEPS && ( <div className="text-center mt-4"> <button type="button" onClick={() => setStep(step + 1)} className="text-sm text-white/50 hover:text-white/80 transition" > {t('specialist_setup.skip')} </button> </div> )} </div> </div> ) }
Save
cmd:
run