/opt/canhelp/apps/web/src/app/tasks/[id]
NameSizeModeActions
offer/-0755rm
page.tsx17530644editdlrm
TaskDetailClient.tsx496770644editdlrm
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)} ) : ( {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 && (

{t('review.leave')}

{t('review.rating')}

{[1, 2, 3, 4, 5].map((star) => ( ))}