/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
tasks
/
new
/
/opt/canhelp/apps/web/src/app/tasks/new
mkdir
upload
Name
Size
Mode
Actions
page.tsx
25076
0644
edit
dl
rm
Edit:
/opt/canhelp/apps/web/src/app/tasks/new/page.tsx
(25076B)
'use client' import { useState, useEffect, useRef } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Link from 'next/link' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import { useLocation } from '@/context/location' import { createTask, getCategories, getLocations, getSpecialists } from '@/lib/api' import { formatShortName } from '@/lib/formatName' import { formatLastSeen } from '@/lib/formatLastSeen' import type { Category } from '@canhelp/shared' import { TaskLocationPicker } from '@/components/TaskLocationPicker' import { TaskCategoryPicker } from '@/components/TaskCategoryPicker' import { TurnstileWidget } from '@/components/Turnstile' import { CAPTCHA_ENABLED } from '@/components/Turnstile' const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' const localISO = (d: Date) => { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${day}` } export default function CreateTaskPage() { const { data: session, isPending } = useSession() const router = useRouter() const searchParams = useSearchParams() const preselectedCategory = searchParams.get('category') ?? '' const { t, locale } = useLocale() const [categories, setCategories] = useState<Category[]>([]) const [locations, setLocations] = useState<any[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [topSpecialists, setTopSpecialists] = useState<any[]>([]) const [deadline, setDeadline] = useState(() => { const d = new Date() d.setDate(d.getDate() + 1) return localISO(d) }) const [expiresAtDays, setExpiresAtDays] = useState(30) const [calendarMonth, setCalendarMonth] = useState(() => { const d = new Date() d.setDate(d.getDate() + 1) return localISO(d).slice(0, 7) }) const calendarInputRef = useRef<HTMLInputElement>(null) const [specialistAvailMap, setSpecialistAvailMap] = useState<Record<string, 'available' | 'busy' | null>>({}) const [invitedIds, setInvitedIds] = useState<Set<string>>(new Set()) const [captchaToken, setCaptchaToken] = useState(CAPTCHA_ENABLED ? '' : 'dev') const { locationSlug: ctxLocation } = useLocation() const [selectedLocation, setSelectedLocation] = useState('') const [selectedCategory, setSelectedCategory] = useState(preselectedCategory) useEffect(() => { if (ctxLocation) setSelectedLocation(ctxLocation) }, [ctxLocation]) const [images, setImages] = useState<string[]>([]) const [photoUploading, setPhotoUploading] = useState(false) const fileInputRef = useRef<HTMLInputElement>(null) useEffect(() => { if (!isPending && !session) router.push('/login') getCategories().then(setCategories).catch(() => {}) getLocations().then(setLocations).catch(() => {}) }, [session, isPending]) useEffect(() => { if (!selectedCategory) { setTopSpecialists([]) return } getSpecialists({ limit: '5', category: selectedCategory, locale }).then((r) => setTopSpecialists(r.data)).catch(() => {}) }, [selectedCategory, locale]) useEffect(() => { if (!deadline || topSpecialists.length === 0) return const month = deadline.slice(0, 7) const ids = topSpecialists.map((u: any) => u.id) Promise.all( ids.map((id: string) => fetch(`${API}/api/availability/user/${id}?month=${month}`, { credentials: 'include' }) .then((r) => r.ok ? r.json() : []) .then((rows: { date: string; status: string }[]) => { const row = rows.find((r) => r.date === deadline) return { id, status: (row?.status ?? null) as 'available' | 'busy' | null } }) .catch(() => ({ id, status: null as null })) ) ).then((results) => { const map: Record<string, 'available' | 'busy' | null> = {} results.forEach(({ id, status }) => { map[id] = status }) setSpecialistAvailMap(map) }) }, [deadline, topSpecialists]) async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0] if (!file) return setPhotoUploading(true) try { const form = new FormData() form.append('file', file) const res = await fetch(`${API}/api/uploads`, { method: 'POST', credentials: 'include', body: form }) if (!res.ok) throw new Error('Upload failed') const { url } = await res.json() setImages((prev) => [...prev, `${API}${url}`]) } catch { setError(t('profile.upload.error')) } finally { setPhotoUploading(false) if (fileInputRef.current) fileInputRef.current.value = '' } } async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() setError('') setLoading(true) const form = new FormData(e.currentTarget) const data: any = { title: form.get('title') as string, description: form.get('description') as string, category: form.get('category') as string || undefined, location: form.get('location') as string || undefined, budget: form.get('budget') ? Number(form.get('budget')) : undefined, budgetNegotiable: (form.get('budgetNegotiable') === 'on'), deadline: form.get('deadline') ? new Date(form.get('deadline') as string).toISOString() : undefined, expiresAt: (() => { const d = new Date(); d.setDate(d.getDate() + Number(form.get('expiresAtDays') || 30)); return d.toISOString() })(), timeSlot: form.get('timeSlot') as string || undefined, district: form.get('district') as string || undefined, street: form.get('street') as string || undefined, houseNumber: form.get('houseNumber') as string || undefined, confidentialNote: form.get('confidentialNote') as string || undefined, images: images.length > 0 ? images : undefined, status: 'open' as const, locale: locale as 'el' | 'en' | 'uk' | 'ru', inviteSpecialistIds: invitedIds.size > 0 ? [...invitedIds] : undefined, } try { const task = await createTask({ ...data, captchaToken } as any) router.push(`/tasks/${task.id}`) } catch (err: any) { setError(err.message || t('create.error')) } finally { setLoading(false) } } if (isPending) return <div className="text-center py-20 text-white">{t('common.loading')}</div> const catLocale = locale === 'en' ? 'en' : locale === 'ru' ? 'ru' : locale === 'uk' ? 'uk' : 'el' const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'uk' ? 'uk-UA' : locale === 'en' ? 'en-US' : 'el-GR' const todayMidnight = new Date() todayMidnight.setHours(0, 0, 0, 0) const [cmYear, cmMonth] = calendarMonth.split('-').map(Number) const daysInMonth = new Date(cmYear, cmMonth, 0).getDate() const calendarDates: Date[] = [] for (let day = 1; day <= daysInMonth; day++) { const d = new Date(cmYear, cmMonth - 1, day) if (d >= todayMidnight) calendarDates.push(d) } const monthOptions = Array.from({ length: 12 }, (_, i) => { const d = new Date() d.setDate(1) d.setMonth(d.getMonth() + i) return { value: localISO(d).slice(0, 7), label: d.toLocaleDateString(dateLocale, { month: 'long', year: 'numeric' }), } }) const sidebarTitle = t('tasks.new.top_specialists') return ( <div className="max-w-5xl mx-auto px-3 sm:px-4 py-6 sm:py-8"> <h1 className="text-xl sm:text-2xl font-bold text-white mb-4 sm:mb-6">{t('create.title')}</h1> <div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start"> {/* Main form */} <div className="flex-1 min-w-0 w-full"> <form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6 space-y-4 sm:space-y-5"> {error && ( <div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">{error}</div> )} <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.category')}</label> <TaskCategoryPicker categories={categories} locale={catLocale} value={selectedCategory} onChange={setSelectedCategory} placeholder={t('create.field.category.select')} /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.location')}</label> <TaskLocationPicker locations={locations} value={selectedLocation} onChange={setSelectedLocation} anyLabel={t('create.field.location.any')} allLabel={t('create.field.location.all')} /> </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> {t('create.field.title')} <span className="text-red-500">*</span> </label> <input name="title" required minLength={5} maxLength={200} placeholder={t('create.field.title.placeholder')} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> {t('create.field.description')} <span className="text-red-500">*</span> </label> <textarea name="description" required minLength={20} rows={4} placeholder={t('create.field.description.placeholder')} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none" /> </div> {/* Budget */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.budget')}</label> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"> <input name="budget" type="number" min={1} step={1} placeholder={t('create.field.budget.placeholder')} className="w-full sm:flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" /> <label className="flex items-center gap-1.5 cursor-pointer select-none"> <input type="checkbox" name="budgetNegotiable" className="w-4 h-4 rounded border-gray-300 text-green-600" /> <span className="text-sm text-gray-700">{t('create.field.budget_negotiable')}</span> </label> </div> </div> {/* Дата начала */} <div> <div className="flex items-center gap-2 mb-2"> <label className="text-sm font-medium text-gray-700 shrink-0">{t('create.field.deadline')}</label> <select value={calendarMonth} onChange={(e) => setCalendarMonth(e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" > {monthOptions.map((mo) => ( <option key={mo.value} value={mo.value}>{mo.label}</option> ))} </select> <button type="button" title="Открыть календарь" onClick={() => { try { calendarInputRef.current?.showPicker() } catch { calendarInputRef.current?.click() } }} className="shrink-0 w-8 h-8 flex items-center justify-center rounded-lg border border-gray-300 text-gray-500 hover:border-green-400 hover:text-green-600 transition" > <svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> <line x1="16" y1="2" x2="16" y2="6" /> <line x1="8" y1="2" x2="8" y2="6" /> <line x1="3" y1="10" x2="21" y2="10" /> </svg> </button> <input ref={calendarInputRef} type="date" min={localISO(todayMidnight)} value={deadline} onChange={(e) => { if (!e.target.value) return setDeadline(e.target.value) setCalendarMonth(e.target.value.slice(0, 7)) }} className="absolute w-0 h-0 opacity-0 pointer-events-none" /> </div> <div className="flex gap-2 overflow-x-auto pb-1 snap-x snap-mandatory"> {calendarDates.map((d) => { const iso = localISO(d) const isSelected = deadline === iso const dayName = d.toLocaleDateString(dateLocale, { weekday: 'short' }) const dayNum = d.getDate() return ( <button key={iso} type="button" onClick={() => setDeadline(iso)} className={`flex-shrink-0 snap-start flex flex-col items-center justify-center w-14 h-16 rounded-xl border-2 transition select-none ${ isSelected ? 'border-green-500 bg-green-50 text-green-700' : 'border-gray-200 bg-white text-gray-700 hover:border-green-300 hover:bg-gray-50' }`} > <span className="text-xs font-medium uppercase leading-none mb-0.5">{dayName}</span> <span className="text-xl font-bold leading-none">{dayNum}</span> </button> ) })} </div> <input type="hidden" name="deadline" value={deadline} /> </div> {/* Срок действия объявления */} <div> <label className="block text-sm font-medium text-gray-700 mb-2">{t('create.field.expires_at')}</label> <div className="flex gap-2"> {[7, 14, 30, 60].map((days) => ( <button key={days} type="button" onClick={() => setExpiresAtDays(days)} className={`flex-1 py-2 rounded-lg border-2 text-sm font-medium transition select-none ${ expiresAtDays === days ? 'border-green-500 bg-green-50 text-green-700' : 'border-gray-200 bg-white text-gray-700 hover:border-green-300' }`} > {days} {t('create.field.expires_at.days').replace('{n}', '')} </button> ))} </div> <input type="hidden" name="expiresAtDays" value={expiresAtDays} /> </div> {/* Time slot */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.time_slot')}</label> <select name="timeSlot" className="w-full border border-gray-300 rounded-lg px-3 py-2"> <option value="">{t('create.field.time_slot.any')}</option> <option value="morning">{t('create.field.time_slot.morning')}</option> <option value="afternoon">{t('create.field.time_slot.afternoon')}</option> <option value="evening">{t('create.field.time_slot.evening')}</option> <option value="weekend">{t('create.field.time_slot.weekend')}</option> </select> </div> {/* Photos */} <div> <label className="block text-sm font-medium text-gray-700 mb-2">{t('create.field.photos')}</label> <div className="flex flex-wrap gap-2 mb-2"> {images.map((url, i) => ( <div key={i} className="relative w-20 h-20 rounded-lg overflow-hidden border border-gray-200"> <img src={url} alt="" className="w-full h-full object-cover" /> <button type="button" onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))} className="absolute top-0.5 right-0.5 bg-black/50 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-black/70" > × </button> </div> ))} {images.length < 5 && ( <button type="button" onClick={() => fileInputRef.current?.click()} disabled={photoUploading} className="w-20 h-20 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-400 hover:border-green-400 hover:text-gray-400 text-xs disabled:opacity-50" > {photoUploading ? '...' : ( <> <span className="text-2xl leading-none">+</span> <span>{t('create.field.photos.add')}</span> </> )} </button> )} </div> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePhotoChange} /> </div> {/* Confidential: address + note */} <div className="space-y-3 p-3 bg-yellow-50 border border-yellow-200 rounded-xl"> <p className="text-xs font-medium text-yellow-700 flex items-center gap-1"> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> 🔒 {t('create.field.confidential.hint')} </p> <div> <label className="block text-sm font-medium text-gray-700 mb-2">{t('create.field.address')}</label> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3"> <input name="district" placeholder={t('create.field.address.district')} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" /> <input name="street" placeholder={t('create.field.address.street')} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" /> <input name="houseNumber" placeholder={t('create.field.address.house')} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" /> </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.confidential')}</label> <textarea name="confidentialNote" rows={2} placeholder={t('create.field.confidential.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 bg-white" /> </div> </div> <TurnstileWidget onSuccess={setCaptchaToken} onError={() => setCaptchaToken('')} onExpire={() => setCaptchaToken('')} /> <button type="submit" disabled={loading || !captchaToken} className="w-full bg-green-600 text-white py-3 rounded-xl font-semibold hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed" > {loading ? t('create.submitting') : t('create.submit')} </button> </form> </div> {/* Sidebar — Top specialists */} <aside className="hidden lg:block lg:w-64 shrink-0"> <div className="bg-white rounded-xl border border-gray-200 p-5 sticky top-24"> <h3 className="font-semibold text-gray-900 mb-4 text-sm">{sidebarTitle}</h3> {!selectedCategory ? ( <p className="text-xs text-gray-400 text-center py-4">{t('tasks.new.select_category')}</p> ) : topSpecialists.length === 0 ? ( <p className="text-xs text-gray-400 text-center py-4">{t('specialists.empty')}</p> ) : ( <div className="space-y-3"> {topSpecialists.map((u) => { const name = formatShortName(u.firstName, u.lastName, u.name) const initials = `${u.firstName?.[0] ?? ''}${u.lastName?.[0] ?? ''}`.toUpperCase() || u.name?.[0]?.toUpperCase() || '?' const seen = formatLastSeen(u.lastSeenAt, t) const avail = specialistAvailMap[u.id] const isInvited = invitedIds.has(u.id) return ( <div key={u.id} className="rounded-lg border border-gray-100 hover:bg-gray-50 transition"> <Link href={`/users/${u.id}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-2" > <div className="w-12 h-12 rounded-full bg-green-100 flex-shrink-0 flex items-center justify-center text-green-700 font-bold text-sm overflow-hidden border border-gray-100"> {u.image ? ( <img src={u.image} alt={name} className="w-full h-full object-cover" /> ) : initials} </div> <div className="min-w-0 flex-1"> <p className="text-sm font-medium text-gray-900 truncate">{name}</p> <div className="flex items-center gap-1.5 flex-wrap"> {u.rating ? ( <p className="text-xs text-gray-500">★ {Number(u.rating).toFixed(1)}</p> ) : seen ? ( <p className="text-xs text-gray-400">{seen}</p> ) : null} {typeof u.reviewCount === 'number' && u.reviewCount > 0 && ( <p className="text-xs text-gray-400">{u.reviewCount} {t('reviews.count')}</p> )} {typeof u.positivePercent === 'number' && u.reviewCount > 0 && ( <p className="text-xs text-green-600">{u.positivePercent}% 👍</p> )} {avail === 'available' && ( <span className="inline-flex items-center gap-0.5 text-xs font-medium text-green-700 bg-green-100 px-1.5 py-0.5 rounded-full"> <span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /> {t('schedule.available')} </span> )} {avail === 'busy' && ( <span className="inline-flex items-center gap-0.5 text-xs font-medium text-red-700 bg-red-100 px-1.5 py-0.5 rounded-full"> <span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" /> {t('schedule.busy')} </span> )} </div> </div> </Link> <div className="px-2 pb-2"> <button type="button" onClick={() => setInvitedIds((prev) => { const next = new Set(prev) if (next.has(u.id)) next.delete(u.id); else next.add(u.id) return next })} className={`w-full text-xs font-medium py-1 rounded-lg transition ${ isInvited ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }`} > {isInvited ? t('tasks.new.invited') : t('tasks.new.invite')} </button> </div> </div> ) })} </div> )} </div> </aside> </div> </div> ) }
Save
cmd:
run