/opt/canhelp/apps/web/src/app/specialist-setup
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 (
{Array.from({ length: total }, (_, i) => i + 1).map((n) => (
{n < current ? (
) : (
n
)}
{n < total && (
)}
))}
)
}
// ─── Step 1: Welcome ───────────────────────────────────────────────────────
function Step1Welcome({ t, onNext }: { t: (k: string, fb?: string) => string; onNext: () => void }) {
return (
{t('specialist_setup.step1.title')}
{t('specialist_setup.step1.subtitle')}
{t('specialist_setup.step1.desc')}
{t('specialist_setup.step1.what_next')}
{[
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) => (
-
{i + 1}
{item}
))}
)
}
// ─── 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
(null)
const [uploading, setUploading] = useState(false)
const fileRef = useRef(null)
useEffect(() => {
api.request('/users/me', {}).then((u) => {
setBio(u.bio ?? '')
setPhone(u.phone ?? '')
setAvatarUrl(u.image ?? null)
}).catch(() => {})
}, [])
async function handleAvatarChange(e: React.ChangeEvent) {
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 (
{t('specialist_setup.step2.title')}
{t('specialist_setup.step2.subtitle')}
{/* Avatar */}
{t('specialist_setup.step2.photo_label')}
{avatarUrl ? (

) : (
)}
{t('specialist_setup.step2.photo_hint')}
{/* Bio */}
{/* Phone */}
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"
/>
)
}
// ─── 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 (
{t('specialist_setup.step3.title')}
{t('specialist_setup.step3.subtitle')}
{ 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 &&
{error}
}
)
}
// ─── 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(card?.skills ?? [])
const [skillInput, setSkillInput] = useState('')
const [categories, setCategories] = useState(card?.categories ?? [])
const [allCategories, setAllCategories] = useState([])
const [suggestions, setSuggestions] = useState([])
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 (
{t('specialist_setup.step4.title')}
{t('specialist_setup.step4.subtitle')}
{/* Skills */}
{skills.length > 0 && (
{skills.map((s) => (
{s}
))}
)}
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"
/>
{suggestions.length > 0 && (
{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) => (
))}
)}
{/* Categories */}
{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 (
)
})}
)
}
// ─── 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(card?.locations ?? [])
const [locationTree, setLocationTree] = useState([])
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 (
{t('specialist_setup.step5.title')}
{t('specialist_setup.step5.subtitle')}
{t('specialist_setup.step5.hint')}
{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 (
)
})}
)
}
// ─── Done screen ───────────────────────────────────────────────────────────
function StepDone({ t }: { t: (k: string, fb?: string) => string }) {
return (
{t('specialist_setup.done.title')}
{t('specialist_setup.done.subtitle')}
{t('specialist_setup.done.desc')}
)
}
// ─── Navigation buttons ────────────────────────────────────────────────────
function NavButtons({
t,
onBack,
onNext,
loading,
isLast,
}: {
t: (k: string, fb?: string) => string
onBack: () => void
onNext: () => void
loading?: boolean
isLast?: boolean
}) {
return (
)
}
// ─── 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(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 {t('common.loading')}
}
const isDone = step > TOTAL_STEPS
return (
{/* Header */}
{t('specialist_setup.page_title')}
{!isDone && (
{t('specialist_setup.step_of')
.replace('{current}', String(step))
.replace('{total}', String(TOTAL_STEPS))}
)}
{/* Progress */}
{!isDone &&
}
{/* Card */}
{isDone && }
{step === 1 && (
setStep(2)} />
)}
{step === 2 && (
setStep(3)} onBack={() => setStep(1)} />
)}
{step === 3 && (
setStep(4)}
onBack={() => setStep(2)}
onCardCreated={setCard}
/>
)}
{step === 4 && (
setStep(5)}
onBack={() => setStep(3)}
card={card}
catLocale={catLocale}
/>
)}
{step === 5 && (
setStep(6)}
onBack={() => setStep(4)}
card={card}
catLocale={catLocale}
/>
)}
{/* Skip link (steps 2-5) */}
{step >= 2 && step <= TOTAL_STEPS && (
)}
)
}