/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
tasks
/
[id]
/
/opt/canhelp/apps/web/src/app/tasks/[id]
mkdir
upload
Name
Size
Mode
Actions
offer/
-
0755
rm
page.tsx
1753
0644
edit
dl
rm
TaskDetailClient.tsx
49677
0644
edit
dl
rm
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<string, string> locMap?: Record<string, string> } const statusColors: Record<string, string> = { 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<any[]>([]) const [offersLoaded, setOffersLoaded] = useState(false) const [myOffer, setMyOffer] = useState<any>(null) const [myOfferLoaded, setMyOfferLoaded] = useState(false) const [myReview, setMyReview] = useState<any>(null) const [myReviewLoaded, setMyReviewLoaded] = useState(false) const [reviewTargetId, setReviewTargetId] = useState<string | null>(null) const [reviewRating, setReviewRating] = useState(5) const [reviewComment, setReviewComment] = useState('') const [reviewSubmitting, setReviewSubmitting] = useState(false) const [reviewDone, setReviewDone] = useState(false) const [invite, setInvite] = useState<Notification | null>(null) const [inviteLoaded, setInviteLoaded] = useState(false) const [inviteDeclining, setInviteDeclining] = useState(false) const [msgLoading, setMsgLoading] = useState(false) const [lightboxIndex, setLightboxIndex] = useState<number | null>(null) // FOMO stats + auto-match const [taskStats, setTaskStats] = useState<any>(null) const [autoMatchResults, setAutoMatchResults] = useState<any[] | null>(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<any>(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 ( <div className="max-w-4xl mx-auto px-4 py-8"> <div className="bg-white rounded-xl border border-gray-200 p-6 mb-6"> {/* Header */} <div className="flex justify-between items-start gap-4 mb-4"> <div className="flex flex-wrap items-center gap-2"> <span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[task.status]}`}> {t(`task.status.${task.status}`, task.status)} </span> {task.category && ( <span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-1 rounded-full"> {catMap[task.category] ?? task.category} </span> )} {isOwner && ( <span className="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded-full"> {t('task.yours')} </span> )} </div> <div className="flex items-center gap-2 shrink-0"> {isOwner && (task.status === 'draft' || task.status === 'open') && ( <button onClick={handleEditOpen} className="flex items-center gap-1.5 text-xs font-medium text-green-600 bg-green-50 hover:bg-green-100 px-3 py-1.5 rounded-lg transition" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" /> </svg> {t('task.edit')} </button> )} <p className="text-sm text-gray-400">{new Date(task.createdAt).toLocaleDateString(dateLocale)}</p> </div> </div> <h1 className="text-2xl font-bold text-gray-900 mb-4">{taskTitle(task, locale)}</h1> <p className="text-gray-700 whitespace-pre-wrap mb-6">{taskDescription(task, locale)}</p> {/* Meta */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 rounded-lg"> {task.budget && ( <div> <p className="text-xs text-gray-500">{t('task.budget')}</p> <p className="font-bold text-green-600 text-lg">{task.budget}€</p> {task.budgetNegotiable && ( <p className="text-xs text-green-600 mt-0.5">{t('task.budget_negotiable')}</p> )} </div> )} {!task.budget && task.budgetNegotiable && ( <div> <p className="text-xs text-gray-500">{t('task.budget')}</p> <p className="font-medium text-green-600">{t('task.budget_negotiable')}</p> </div> )} {task.location && ( <div> <p className="text-xs text-gray-500">{t('task.location')}</p> <p className="font-medium">📍 {locMap[task.location] ?? task.location}</p> </div> )} {task.deadline && ( <div> <p className="text-xs text-gray-500">{t('task.deadline')}</p> <p className="font-medium">{new Date(task.deadline).toLocaleDateString(dateLocale)}</p> {task.timeSlot && task.timeSlot !== '' && ( <p className="text-xs text-gray-500 mt-0.5">{t(`create.field.time_slot.${task.timeSlot}`, task.timeSlot)}</p> )} </div> )} {!task.deadline && task.timeSlot && task.timeSlot !== '' && ( <div> <p className="text-xs text-gray-500">{t('task.time_slot')}</p> <p className="font-medium">{t(`create.field.time_slot.${task.timeSlot}`, task.timeSlot)}</p> </div> )} {task.expiresAt && ( <div> <p className="text-xs text-gray-500">{t('task.expires_at')}</p> <p className="font-medium">{new Date(task.expiresAt).toLocaleDateString(dateLocale)}</p> </div> )} {customer && ( <div> <p className="text-xs text-gray-500">{t('task.customer')}</p> <Link href={`/users/${customer.id}`} className="flex items-center gap-2 mt-1 group"> <div className="w-8 h-8 rounded-full overflow-hidden bg-green-100 shrink-0 flex items-center justify-center"> {customer.image ? ( <img src={customer.image} alt={shortName(customer.name)} className="w-full h-full object-cover" /> ) : ( <span className="text-green-600 font-semibold text-xs"> {shortName(customer.name).charAt(0).toUpperCase()} </span> )} </div> <div> <p className="font-medium text-gray-900 group-hover:text-green-600 leading-none">{shortName(customer.name)}</p> {customer.rating && ( <p className="text-xs text-yellow-500 leading-none mt-0.5">★ {Number(customer.rating).toFixed(1)}</p> )} </div> </Link> </div> )} </div> {/* Photos */} {task.images && task.images.length > 0 && ( <div className="mt-4"> <div className="flex flex-wrap gap-2"> {task.images.map((url: string, i: number) => ( <button key={i} onClick={() => setLightboxIndex(i)} className="block w-24 h-24 rounded-lg overflow-hidden border border-gray-200 hover:opacity-90 hover:border-green-300 transition" > <img src={url} alt="" className="w-full h-full object-cover" /> </button> ))} </div> </div> )} {/* Lightbox */} {lightboxIndex !== null && task.images && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85" onClick={() => setLightboxIndex(null)} > <div className="relative max-w-4xl w-full mx-4" onClick={(e) => e.stopPropagation()}> <img src={task.images[lightboxIndex]} alt="" className="w-full max-h-[80vh] object-contain rounded-lg" /> {/* Close */} <button onClick={() => setLightboxIndex(null)} className="absolute -top-10 right-0 text-white/80 hover:text-white p-2" > <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> {/* Prev */} {lightboxIndex > 0 && ( <button onClick={() => setLightboxIndex(lightboxIndex - 1)} className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-12 text-white/80 hover:text-white p-2" > <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> </svg> </button> )} {/* Next */} {lightboxIndex < task.images.length - 1 && ( <button onClick={() => setLightboxIndex(lightboxIndex + 1)} className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-12 text-white/80 hover:text-white p-2" > <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> </svg> </button> )} {/* Counter */} {task.images.length > 1 && ( <p className="text-white/60 text-sm text-center mt-3">{lightboxIndex + 1} / {task.images.length}</p> )} </div> </div> )} {/* Confidential: address + note (owner + accepted specialist only) */} {(isOwner || myOffer?.status === 'accepted') && (task.district || task.street || task.confidentialNote) && ( <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg space-y-2"> <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('task.confidential')} </p> {(task.district || task.street) && ( <p className="text-sm text-yellow-800"> 📍 {[task.district, task.street, task.houseNumber].filter(Boolean).join(', ')} </p> )} {task.confidentialNote && ( <p className="text-sm text-yellow-800">{task.confidentialNote}</p> )} </div> )} {/* CTAs */} <div className="mt-6 flex flex-wrap gap-3"> {/* Guest: prompt login before response/contact actions */} {!session && !isOwner && task.status === 'open' && ( <> <Link href={loginRedirectHref} className="bg-green-600 text-white px-6 py-3 rounded-xl font-semibold hover:bg-green-700" > {t('task.make_offer', 'Send offer')} </Link> <Link href={loginRedirectHref} className="flex items-center gap-2 bg-green-50 text-green-600 border border-green-200 px-4 py-2.5 rounded-xl font-medium hover:bg-green-100" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> {t('chat.write_customer', 'Write to customer')} </Link> </> )} {/* Owner: complete */} {isOwner && task.status === 'in_progress' && ( <button onClick={handleComplete} className="bg-green-600 text-white px-6 py-3 rounded-xl font-semibold hover:bg-green-700" > {t('task.complete')} </button> )} {/* Owner: write to specialist when offer accepted */} {isOwner && acceptedOffer?.specialist?.id && ( <button onClick={() => handleMessage(acceptedOffer.specialist.id)} disabled={msgLoading} className="flex items-center gap-2 bg-green-50 text-green-600 border border-green-200 px-4 py-2.5 rounded-xl font-medium hover:bg-green-100 disabled:opacity-50" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> {t('chat.write_specialist')} </button> )} {/* Owner: leave review after completion */} {isOwner && task.status === 'completed' && acceptedOffer && !reviewDone && ( <button onClick={() => setReviewTargetId(acceptedOffer.specialist?.id ?? null)} className="bg-yellow-500 text-white px-6 py-3 rounded-xl font-semibold hover:bg-yellow-600" > {t('review.leave')} </button> )} {reviewDone && ( <span className="text-green-600 font-medium py-3">{t('review.done')}</span> )} </div> {/* Review form */} {reviewTargetId && ( <form onSubmit={handleReview} className="mt-6 border-t border-gray-100 pt-6 space-y-4"> <h3 className="font-semibold text-gray-800">{t('review.leave')}</h3> <div> <p className="text-sm font-medium text-gray-700 mb-2">{t('review.rating')}</p> <div className="flex gap-1"> {[1, 2, 3, 4, 5].map((star) => ( <button key={star} type="button" onClick={() => setReviewRating(star)} className={`text-2xl transition ${star <= reviewRating ? 'text-yellow-400' : 'text-gray-300'}`} > ★ </button> ))} </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> {t('review.comment')} </label> <textarea value={reviewComment} onChange={(e) => setReviewComment(e.target.value)} rows={3} maxLength={2000} placeholder={t('review.comment.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> <div className="flex gap-3"> <button type="submit" disabled={reviewSubmitting} className="bg-green-600 text-white px-5 py-2 rounded-xl font-semibold hover:bg-green-700 disabled:opacity-50" > {reviewSubmitting ? t('review.submitting') : t('review.submit')} </button> <button type="button" onClick={() => setReviewTargetId(null)} className="px-5 py-2 rounded-xl border border-gray-200 text-gray-600 hover:bg-gray-50" > {t('common.cancel')} </button> </div> </form> )} </div> {/* Offers — only for owner */} {isOwner && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-4 flex-wrap gap-2"> <h2 className="text-lg font-bold text-gray-900"> {t('task.offers')} ({offers.length}) </h2> {/* FOMO stats */} {taskStats && ( <div className="flex items-center gap-3 text-xs text-gray-500"> {taskStats.totalViews > 0 && ( <span className="flex items-center gap-1"> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> </svg> {taskStats.totalViews} </span> )} {taskStats.offerCount > 0 && ( <span className="font-medium text-green-600"> ⚡ {taskStats.offerCount} {t('task.offers').toLowerCase()} </span> )} </div> )} {/* Auto-match button */} {hasAutoMatch && task.status === 'open' && ( <button onClick={async () => { setAutoMatchLoading(true) try { const res = await runAutoMatch(taskId) setAutoMatchResults(res.matches ?? []) } catch {} finally { setAutoMatchLoading(false) } }} disabled={autoMatchLoading} className="flex items-center gap-1.5 text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200 px-3 py-1.5 rounded-lg hover:bg-purple-100 transition disabled:opacity-50" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> </svg> {autoMatchLoading ? t('common.loading') : t('task.auto_match', 'Auto match')} </button> )} </div> {/* Auto-match results */} {autoMatchResults && autoMatchResults.length > 0 && ( <div className="mb-4 p-3 bg-purple-50 rounded-xl border border-purple-100"> <p className="text-xs font-medium text-purple-700 mb-2">{t('task.auto_match_results', 'Top specialists')}</p> <div className="space-y-2"> {autoMatchResults.slice(0, 5).map((s: any) => ( <div key={s.id} className="flex items-center justify-between gap-2"> <div className="flex items-center gap-2"> <div className="w-7 h-7 rounded-full bg-purple-100 flex items-center justify-center text-purple-700 text-xs font-bold overflow-hidden"> {s.image ? <img src={s.image} alt="" className="w-full h-full object-cover" /> : s.name?.[0]?.toUpperCase()} </div> <span className="text-sm text-gray-800">{s.firstName ?? s.name}</span> {s.rating && <span className="text-xs text-yellow-500">★{Number(s.rating).toFixed(1)}</span>} </div> <button onClick={() => handleMessage(s.id)} className="text-xs text-green-600 hover:underline" > {t('chat.write_btn')} </button> </div> ))} </div> </div> )} {!offersLoaded ? ( <p className="text-gray-400 text-sm">{t('common.loading')}</p> ) : offers.length === 0 ? ( <p className="text-gray-500 text-sm">{t('task.offers.none')}</p> ) : ( <div className="space-y-4"> {offers.map((row: any) => { const offer = row.offer const specialist = row.specialist return ( <div key={offer.id} className="border border-gray-100 rounded-xl p-4"> <div className="flex justify-between items-start gap-4"> <div className="flex items-start gap-3"> <div className="w-10 h-10 rounded-full bg-green-100 flex-shrink-0 flex items-center justify-center text-green-700 font-bold overflow-hidden"> {specialist?.image ? ( <img src={specialist.image} alt={specialist.name} className="w-full h-full object-cover" /> ) : ( specialist?.name?.[0]?.toUpperCase() ?? '?' )} </div> <div> {specialist && ( <div className="flex items-center gap-1.5"> <Link href={`/users/${specialist.id}`} className="font-medium text-gray-900 hover:text-green-600"> {shortName(specialist.name)} </Link> {specialist.planTier && specialist.planTier !== 'free' && ( <PlanBadge tier={specialist.planTier} /> )} {specialist.hasVerifiedBadge && ( <svg className="w-3.5 h-3.5 text-gray-500" viewBox="0 0 24 24" fill="currentColor"> <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> )} </div> )} {offer.message && ( <p className="text-sm text-gray-600 mt-1">{offer.message}</p> )} </div> </div> <div className="text-right shrink-0"> <p className="text-lg font-bold text-green-600">{formatPrice(offer.price)}€</p> <span className={`text-xs px-2 py-0.5 rounded-full ${ offer.status === 'accepted' ? 'bg-green-100 text-green-700' : offer.status === 'declined' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700' }`}> {t(`offer.status.${offer.status}`, offer.status)} </span> </div> </div> {/* Accept / Decline buttons */} {offer.status === 'pending' && task.status === 'open' && ( <div className="flex gap-2 mt-3 pt-3 border-t border-gray-50"> <button onClick={() => handleAccept(offer.id)} className="flex-1 bg-green-600 text-white py-2 rounded-lg text-sm font-semibold hover:bg-green-700" > {t('offer.accept')} </button> <button onClick={() => handleDecline(offer.id)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg text-sm font-semibold hover:bg-gray-200" > {t('offer.decline')} </button> </div> )} </div> ) })} </div> )} </div> )} {/* Invite banner */} {!isOwner && session && invite && inviteLoaded && task.status === 'open' && !myOffer && ( <div className="rounded-xl border border-green-200 bg-green-50 p-5"> <div className="flex items-center justify-between gap-4 flex-wrap gap-y-3"> <div className="flex items-center gap-3"> <div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center shrink-0"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 text-green-600"> <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> </svg> </div> <div> <p className="font-semibold text-gray-900 text-sm">{t('task.invite.banner_title')}</p> <p className="text-gray-500 text-sm mt-0.5">{t('task.invite.banner_hint')}</p> </div> </div> <div className="flex gap-2 shrink-0"> <Link href={`/tasks/${taskId}/offer`} className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-green-700 transition" > {t('task.invite.accept')} </Link> <button onClick={async () => { if (!invite) return setInviteDeclining(true) await dismissNotification(invite.id).catch(() => {}) setInvite(null) setInviteDeclining(false) }} disabled={inviteDeclining} className="border border-gray-300 bg-white text-gray-600 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition" > {t('task.invite.decline')} </button> </div> </div> </div> )} {/* Specialist's own offer */} {!isOwner && session && myOfferLoaded && ( <div className="space-y-4"> {/* Task completed banner */} {task.status === 'completed' && myOffer?.status === 'accepted' && ( <div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-center gap-4"> <span className="text-3xl">🎉</span> <div> <p className="font-bold text-green-800 text-lg">Задание завершено!</p> <p className="text-green-700 text-sm">Заказчик отметил задание как выполненное.</p> </div> <button onClick={() => handleMessage(task.customerId)} disabled={msgLoading} className="ml-auto flex items-center gap-2 bg-white text-green-600 border border-green-200 px-4 py-2 rounded-xl text-sm font-medium hover:bg-green-50 disabled:opacity-50" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> {t('chat.write_customer')} </button> </div> )} {(myOffer || (task.status === 'open' && !invite) || task.status !== 'open') && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="text-lg font-bold text-gray-900 mb-4">Моё предложение</h2> {myOffer ? ( <> <div className="flex items-start justify-between gap-4"> <div className="space-y-1"> {myOffer.message && ( <p className="text-sm text-gray-600">{myOffer.message}</p> )} <p className="text-xs text-gray-400"> {new Date(myOffer.createdAt).toLocaleString(dateLocale)} </p> </div> <div className="text-right shrink-0"> <p className="text-xl font-bold text-green-600">{formatPrice(myOffer.price)}€</p> <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${ myOffer.status === 'accepted' ? 'bg-green-100 text-green-700' : myOffer.status === 'declined' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700' }`}> {myOffer.status === 'accepted' ? '✓ Принято' : myOffer.status === 'declined' ? '✗ Отклонено' : '⏳ На рассмотрении'} </span> </div> </div> {myOffer.status === 'accepted' && task.status === 'in_progress' && ( <div className="mt-3 pt-3 border-t border-gray-100"> <button onClick={() => handleMessage(task.customerId)} disabled={msgLoading} className="flex items-center gap-2 bg-green-50 text-green-600 border border-green-200 px-4 py-2 rounded-xl text-sm font-medium hover:bg-green-100 disabled:opacity-50" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> <path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> {t('chat.write_customer')} </button> </div> )} </> ) : task.status === 'open' && !invite ? ( <div className="space-y-3"> {/* FOMO hint for specialist */} {taskStats && taskStats.offerCount > 0 && ( <div className="flex items-center gap-2 p-2.5 bg-amber-50 rounded-lg border border-amber-100"> <svg className="w-4 h-4 text-amber-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> </svg> <p className="text-xs text-amber-700"> {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))} </p> </div> )} <div className="flex items-center justify-between"> <p className="text-sm text-gray-500">{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')}</p> <Link href={`/tasks/${taskId}/offer`} className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-green-700" > {t('task.make_offer', '\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435')} </Link> </div> </div> ) : null} </div> )} {/* Review received */} {task.status === 'completed' && myOffer?.status === 'accepted' && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="text-lg font-bold text-gray-900 mb-4">Оценка заказчика</h2> {!myReviewLoaded ? ( <p className="text-sm text-gray-400">Загрузка...</p> ) : myReview ? ( <div className="space-y-3"> <div className="flex items-center gap-1"> {[1, 2, 3, 4, 5].map((star) => ( <span key={star} className={`text-2xl ${ star <= myReview.rating ? 'text-yellow-400' : 'text-gray-200' }`}>★</span> ))} <span className="ml-2 text-lg font-bold text-gray-800">{myReview.rating}/5</span> </div> {myReview.comment && ( <p className="text-gray-700 text-sm leading-relaxed">{myReview.comment}</p> )} <p className="text-xs text-gray-400"> {new Date(myReview.createdAt).toLocaleDateString(dateLocale)} </p> </div> ) : ( <p className="text-sm text-gray-500">Заказчик ещё не оставил оценку.</p> )} </div> )} </div> )} {/* Edit task modal */} {editing && editForm && ( <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 overflow-y-auto py-8 px-4"> <div className="bg-white rounded-2xl w-full max-w-2xl shadow-xl"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100"> <h2 className="text-lg font-bold text-gray-900">{t('task.edit')}</h2> <button onClick={() => setEditing(false)} className="text-gray-400 hover:text-gray-600 p-1"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> <form onSubmit={handleEditSave} className="p-6 space-y-4"> {/* Title */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.title')} *</label> <input type="text" required minLength={5} maxLength={200} value={editForm.title} onChange={(e) => setEditForm((p: any) => ({ ...p, title: e.target.value }))} 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> {/* Description */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.description')} *</label> <textarea required minLength={20} value={editForm.description} onChange={(e) => setEditForm((p: any) => ({ ...p, description: e.target.value }))} rows={4} 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> {/* Budget */} <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.budget')}</label> <input type="number" min={1} value={editForm.budget} onChange={(e) => setEditForm((p: any) => ({ ...p, budget: e.target.value }))} 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> <div className="flex items-end pb-2"> <label className="flex items-center gap-2 cursor-pointer"> <input type="checkbox" checked={editForm.budgetNegotiable} onChange={(e) => setEditForm((p: any) => ({ ...p, budgetNegotiable: e.target.checked }))} className="w-4 h-4 rounded text-green-600" /> <span className="text-sm text-gray-700">{t('create.field.budget_negotiable')}</span> </label> </div> </div> {/* Deadline */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.deadline')}</label> <input type="date" value={editForm.deadline} onChange={(e) => setEditForm((p: any) => ({ ...p, deadline: e.target.value }))} 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> {/* Expires at */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.expires_at')}</label> <input type="date" value={editForm.expiresAt} onChange={(e) => setEditForm((p: any) => ({ ...p, expiresAt: e.target.value }))} 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> {/* Address */} <div className="grid grid-cols-3 gap-3"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.address.district')}</label> <input type="text" value={editForm.district} onChange={(e) => setEditForm((p: any) => ({ ...p, district: e.target.value }))} 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> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.address.street')}</label> <input type="text" value={editForm.street} onChange={(e) => setEditForm((p: any) => ({ ...p, street: e.target.value }))} 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> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.address.house')}</label> <input type="text" value={editForm.houseNumber} onChange={(e) => setEditForm((p: any) => ({ ...p, houseNumber: e.target.value }))} 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> </div> {/* Confidential note */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('create.field.confidential')}</label> <textarea value={editForm.confidentialNote} onChange={(e) => setEditForm((p: any) => ({ ...p, confidentialNote: e.target.value }))} rows={2} 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> {/* Status */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('task.status_label', 'Status')}</label> <select value={editForm.status} onChange={(e) => setEditForm((p: any) => ({ ...p, status: e.target.value }))} 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" > <option value="open">{t('task.status.open')}</option> <option value="draft">{t('task.status.draft')}</option> </select> </div> {/* Actions */} <div className="flex gap-3 pt-2"> <button type="submit" disabled={editSaving} className="flex-1 bg-green-600 text-white py-2.5 rounded-xl font-semibold hover:bg-green-700 disabled:opacity-50 transition" > {editSaving ? t('common.saving', '...') : t('common.save')} </button> <button type="button" onClick={() => setEditing(false)} className="px-6 py-2.5 rounded-xl border border-gray-200 text-gray-600 hover:bg-gray-50 transition" > {t('common.cancel')} </button> </div> </form> </div> </div> )} </div> ) }
Save
cmd:
run