/opt/canhelp/apps/web/src/app/tasks/[id]
Edit: /opt/canhelp/apps/web/src/app/tasks/[id]/TaskDetailClient.tsx (49677B)
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth'
import { useLocale } from '@/context/locale'
import { getOffers, acceptOffer, declineOffer, completeTask, createReview, getMyOffers, getTaskReviews, getTaskInvite, dismissNotification, getOrCreateDirectRoom, updateTask, getTaskStats, getAutoMatchResults, runAutoMatch, getMyDashboard } from '@/lib/api'
import type { Notification } from '@canhelp/shared'
import { taskTitle, taskDescription, formatPrice } from '@/lib/taskLocale'
import { shortName } from '@/lib/formatName'
import { PlanBadge } from '@/components/PlanBadge'
interface Props {
taskId: string
initialTask: any
initialCustomer: any
catMap?: Record
locMap?: Record
}
const statusColors: Record = {
open: 'bg-green-100 text-green-700',
in_progress: 'bg-green-100 text-green-700',
completed: 'bg-gray-100 text-gray-700',
cancelled: 'bg-red-100 text-red-700',
draft: 'bg-yellow-100 text-yellow-700',
}
export default function TaskDetailClient({ taskId, initialTask, initialCustomer, catMap = {}, locMap = {} }: Props) {
const { data: session } = useSession()
const { t, locale } = useLocale()
const router = useRouter()
const [task, setTask] = useState(initialTask)
const [customer] = useState(initialCustomer)
const [offers, setOffers] = useState([])
const [offersLoaded, setOffersLoaded] = useState(false)
const [myOffer, setMyOffer] = useState(null)
const [myOfferLoaded, setMyOfferLoaded] = useState(false)
const [myReview, setMyReview] = useState(null)
const [myReviewLoaded, setMyReviewLoaded] = useState(false)
const [reviewTargetId, setReviewTargetId] = useState(null)
const [reviewRating, setReviewRating] = useState(5)
const [reviewComment, setReviewComment] = useState('')
const [reviewSubmitting, setReviewSubmitting] = useState(false)
const [reviewDone, setReviewDone] = useState(false)
const [invite, setInvite] = useState(null)
const [inviteLoaded, setInviteLoaded] = useState(false)
const [inviteDeclining, setInviteDeclining] = useState(false)
const [msgLoading, setMsgLoading] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(null)
// FOMO stats + auto-match
const [taskStats, setTaskStats] = useState(null)
const [autoMatchResults, setAutoMatchResults] = useState(null)
const [autoMatchLoading, setAutoMatchLoading] = useState(false)
const [hasAutoMatch, setHasAutoMatch] = useState(false)
// Edit task state
const [editing, setEditing] = useState(false)
const [editSaving, setEditSaving] = useState(false)
const [editForm, setEditForm] = useState(null)
// Keyboard navigation for lightbox
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLightboxIndex(null)
if (e.key === 'ArrowLeft') setLightboxIndex(prev => prev !== null && prev > 0 ? prev - 1 : prev)
if (e.key === 'ArrowRight') setLightboxIndex(prev => {
if (prev === null) return prev
const imgs = task.images
return imgs && prev < imgs.length - 1 ? prev + 1 : prev
})
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [task.images])
const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'uk' ? 'uk-UA' : locale === 'en' ? 'en-US' : 'el-GR'
const currentUserId = (session?.user as any)?.id
const isOwner = currentUserId === task.customerId
const loginRedirectHref = `/login?redirect=${encodeURIComponent(`/tasks/${taskId}`)}`
// Load offers if owner
useEffect(() => {
if (!isOwner || offersLoaded) return
getOffers(taskId)
.then((data) => { setOffers(data); setOffersLoaded(true) })
.catch(() => setOffersLoaded(true))
}, [isOwner, taskId, offersLoaded])
// Load task stats + dashboard features for owner
useEffect(() => {
if (!isOwner) return
getTaskStats(taskId).then(setTaskStats).catch(() => {})
getMyDashboard().then((d) => setHasAutoMatch(!!(d.features?.hasAutoMatch))).catch(() => {})
}, [isOwner, taskId])
// Load specialist's own offer
useEffect(() => {
if (!currentUserId || isOwner || myOfferLoaded) return
getMyOffers()
.then((rows) => {
const found = rows.find((r) => r.task?.id === taskId)
setMyOffer(found?.offer ?? null)
setMyOfferLoaded(true)
})
.catch(() => setMyOfferLoaded(true))
}, [currentUserId, isOwner, taskId, myOfferLoaded])
// Load task invite for specialist
useEffect(() => {
if (!currentUserId || isOwner || inviteLoaded) return
getTaskInvite(taskId)
.then((rows) => { setInvite(rows[0] ?? null); setInviteLoaded(true) })
.catch(() => setInviteLoaded(true))
}, [currentUserId, isOwner, taskId, inviteLoaded])
// Load review for specialist when task completed
useEffect(() => {
if (!currentUserId || isOwner || myReviewLoaded || task.status !== 'completed') return
getTaskReviews(taskId)
.then((rows) => {
const found = rows.find((r) => r.targetId === currentUserId)
setMyReview(found ?? null)
setMyReviewLoaded(true)
})
.catch(() => setMyReviewLoaded(true))
}, [currentUserId, isOwner, taskId, task.status, myReviewLoaded])
async function handleAccept(offerId: string) {
const updated = await acceptOffer(offerId).catch(() => null)
if (updated) {
setOffers((prev) => prev.map((r) => r.offer.id === offerId ? { ...r, offer: updated } : r))
setTask((t: any) => ({ ...t, status: 'in_progress' }))
}
}
async function handleDecline(offerId: string) {
const updated = await declineOffer(offerId).catch(() => null)
if (updated) {
setOffers((prev) => prev.map((r) => r.offer.id === offerId ? { ...r, offer: updated } : r))
}
}
async function handleComplete() {
if (!window.confirm(t('task.complete.confirm'))) return
const updated = await completeTask(taskId).catch(() => null)
if (updated) setTask((t: any) => ({ ...t, status: 'completed' }))
}
async function handleReview(e: React.FormEvent) {
e.preventDefault()
if (!reviewTargetId) return
setReviewSubmitting(true)
const ok = await createReview({ taskId, targetId: reviewTargetId, rating: reviewRating, comment: reviewComment || undefined }).catch(() => null)
setReviewSubmitting(false)
if (ok) { setReviewDone(true); setReviewTargetId(null) }
}
async function handleMessage(otherUserId: string) {
setMsgLoading(true)
try {
const { room } = await getOrCreateDirectRoom(otherUserId)
router.push(`/chat?room=${room.id}`)
} catch {}
setMsgLoading(false)
}
function handleEditOpen() {
setEditForm({
title: task.title,
description: task.description,
budget: task.budget ? Number(task.budget) : '',
budgetNegotiable: task.budgetNegotiable ?? false,
category: task.category ?? '',
location: task.location ?? '',
district: task.district ?? '',
street: task.street ?? '',
houseNumber: task.houseNumber ?? '',
timeSlot: task.timeSlot ?? '',
confidentialNote: task.confidentialNote ?? '',
deadline: task.deadline ? new Date(task.deadline).toISOString().slice(0, 10) : '',
expiresAt: task.expiresAt ? new Date(task.expiresAt).toISOString().slice(0, 10) : '',
status: task.status === 'draft' ? 'draft' : 'open',
})
setEditing(true)
}
async function handleEditSave(e: React.FormEvent) {
e.preventDefault()
if (!editForm) return
setEditSaving(true)
try {
const payload: any = {
title: editForm.title,
description: editForm.description,
budgetNegotiable: editForm.budgetNegotiable,
status: editForm.status,
}
if (editForm.budget !== '' && editForm.budget > 0) payload.budget = Number(editForm.budget)
if (editForm.category) payload.category = editForm.category
if (editForm.location) payload.location = editForm.location
if (editForm.district) payload.district = editForm.district
if (editForm.street) payload.street = editForm.street
if (editForm.houseNumber) payload.houseNumber = editForm.houseNumber
if (editForm.timeSlot) payload.timeSlot = editForm.timeSlot
if (editForm.confidentialNote !== undefined) payload.confidentialNote = editForm.confidentialNote
if (editForm.deadline) payload.deadline = new Date(editForm.deadline).toISOString()
if (editForm.expiresAt) payload.expiresAt = new Date(editForm.expiresAt).toISOString()
const updated = await updateTask(taskId, payload)
setTask((prev: any) => ({ ...prev, ...updated }))
setEditing(false)
} catch (err: any) {
alert(err?.message || t('common.error'))
} finally {
setEditSaving(false)
}
}
const acceptedOffer = offers.find((r) => r.offer.status === 'accepted')
return (
{/* Header */}
{t(`task.status.${task.status}`, task.status)}
{task.category && (
{catMap[task.category] ?? task.category}
)}
{isOwner && (
{t('task.yours')}
)}
{isOwner && (task.status === 'draft' || task.status === 'open') && (
)}
{new Date(task.createdAt).toLocaleDateString(dateLocale)}
{taskTitle(task, locale)}
{taskDescription(task, locale)}
{/* Meta */}
{task.budget && (
{t('task.budget')}
{task.budget}€
{task.budgetNegotiable && (
{t('task.budget_negotiable')}
)}
)}
{!task.budget && task.budgetNegotiable && (
{t('task.budget')}
{t('task.budget_negotiable')}
)}
{task.location && (
{t('task.location')}
📍 {locMap[task.location] ?? task.location}
)}
{task.deadline && (
{t('task.deadline')}
{new Date(task.deadline).toLocaleDateString(dateLocale)}
{task.timeSlot && task.timeSlot !== '' && (
{t(`create.field.time_slot.${task.timeSlot}`, task.timeSlot)}
)}
)}
{!task.deadline && task.timeSlot && task.timeSlot !== '' && (
{t('task.time_slot')}
{t(`create.field.time_slot.${task.timeSlot}`, task.timeSlot)}
)}
{task.expiresAt && (
{t('task.expires_at')}
{new Date(task.expiresAt).toLocaleDateString(dateLocale)}
)}
{customer && (
{t('task.customer')}
{customer.image ? (

) : (
{shortName(customer.name).charAt(0).toUpperCase()}
)}
{shortName(customer.name)}
{customer.rating && (
★ {Number(customer.rating).toFixed(1)}
)}
)}
{/* Photos */}
{task.images && task.images.length > 0 && (
{task.images.map((url: string, i: number) => (
))}
)}
{/* Lightbox */}
{lightboxIndex !== null && task.images && (
setLightboxIndex(null)}
>
e.stopPropagation()}>

{/* Close */}
{/* Prev */}
{lightboxIndex > 0 && (
)}
{/* Next */}
{lightboxIndex < task.images.length - 1 && (
)}
{/* Counter */}
{task.images.length > 1 && (
{lightboxIndex + 1} / {task.images.length}
)}
)}
{/* Confidential: address + note (owner + accepted specialist only) */}
{(isOwner || myOffer?.status === 'accepted') && (task.district || task.street || task.confidentialNote) && (
{t('task.confidential')}
{(task.district || task.street) && (
📍 {[task.district, task.street, task.houseNumber].filter(Boolean).join(', ')}
)}
{task.confidentialNote && (
{task.confidentialNote}
)}
)}
{/* CTAs */}
{/* Guest: prompt login before response/contact actions */}
{!session && !isOwner && task.status === 'open' && (
<>
{t('task.make_offer', 'Send offer')}
{t('chat.write_customer', 'Write to customer')}
>
)}
{/* Owner: complete */}
{isOwner && task.status === 'in_progress' && (
)}
{/* Owner: write to specialist when offer accepted */}
{isOwner && acceptedOffer?.specialist?.id && (
)}
{/* Owner: leave review after completion */}
{isOwner && task.status === 'completed' && acceptedOffer && !reviewDone && (
)}
{reviewDone && (
{t('review.done')}
)}
{/* Review form */}
{reviewTargetId && (
)}
{/* Offers — only for owner */}
{isOwner && (
{t('task.offers')} ({offers.length})
{/* FOMO stats */}
{taskStats && (
{taskStats.totalViews > 0 && (
{taskStats.totalViews}
)}
{taskStats.offerCount > 0 && (
⚡ {taskStats.offerCount} {t('task.offers').toLowerCase()}
)}
)}
{/* Auto-match button */}
{hasAutoMatch && task.status === 'open' && (
)}
{/* Auto-match results */}
{autoMatchResults && autoMatchResults.length > 0 && (
{t('task.auto_match_results', 'Top specialists')}
{autoMatchResults.slice(0, 5).map((s: any) => (
{s.image ?

: s.name?.[0]?.toUpperCase()}
{s.firstName ?? s.name}
{s.rating &&
★{Number(s.rating).toFixed(1)}}
))}
)}
{!offersLoaded ? (
{t('common.loading')}
) : offers.length === 0 ? (
{t('task.offers.none')}
) : (
{offers.map((row: any) => {
const offer = row.offer
const specialist = row.specialist
return (
{specialist?.image ? (

) : (
specialist?.name?.[0]?.toUpperCase() ?? '?'
)}
{specialist && (
{shortName(specialist.name)}
{specialist.planTier && specialist.planTier !== 'free' && (
)}
{specialist.hasVerifiedBadge && (
)}
)}
{offer.message && (
{offer.message}
)}
{formatPrice(offer.price)}€
{t(`offer.status.${offer.status}`, offer.status)}
{/* Accept / Decline buttons */}
{offer.status === 'pending' && task.status === 'open' && (
)}
)
})}
)}
)}
{/* Invite banner */}
{!isOwner && session && invite && inviteLoaded && task.status === 'open' && !myOffer && (
{t('task.invite.banner_title')}
{t('task.invite.banner_hint')}
{t('task.invite.accept')}
)}
{/* Specialist's own offer */}
{!isOwner && session && myOfferLoaded && (
{/* Task completed banner */}
{task.status === 'completed' && myOffer?.status === 'accepted' && (
🎉
Задание завершено!
Заказчик отметил задание как выполненное.
)}
{(myOffer || (task.status === 'open' && !invite) || task.status !== 'open') && (
Моё предложение
{myOffer ? (
<>
{myOffer.message && (
{myOffer.message}
)}
{new Date(myOffer.createdAt).toLocaleString(dateLocale)}
{formatPrice(myOffer.price)}€
{myOffer.status === 'accepted' ? '✓ Принято' :
myOffer.status === 'declined' ? '✗ Отклонено' : '⏳ На рассмотрении'}
{myOffer.status === 'accepted' && task.status === 'in_progress' && (
)}
>
) : task.status === 'open' && !invite ? (
{/* FOMO hint for specialist */}
{taskStats && taskStats.offerCount > 0 && (
{t('fomo.already_offered', '\u0423\u0436\u0435 {n} \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u0438\u0441\u0442\u043e\u0432 \u043e\u0442\u043a\u043b\u0438\u043a\u043d\u0443\u043b\u0438\u0441\u044c').replace('{n}', String(taskStats.offerCount))}
)}
{t('task.no_offer_yet', '\u0412\u044b \u0435\u0449\u0451 \u043d\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435')}
{t('task.make_offer', '\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435')}
) : null}
)}
{/* Review received */}
{task.status === 'completed' && myOffer?.status === 'accepted' && (
Оценка заказчика
{!myReviewLoaded ? (
Загрузка...
) : myReview ? (
{[1, 2, 3, 4, 5].map((star) => (
★
))}
{myReview.rating}/5
{myReview.comment && (
{myReview.comment}
)}
{new Date(myReview.createdAt).toLocaleDateString(dateLocale)}
) : (
Заказчик ещё не оставил оценку.
)}
)}
)}
{/* Edit task modal */}
{editing && editForm && (
)}
)
}