'use client'; import { CalendarClock, ChevronRight, MessageSquare, Users } from 'lucide-react'; import type { JobStatus } from '@linkdr/shared'; import { cn, formatRelativeTime, formatWhen } from '@/lib/utils'; export type Perspective = 'client' | 'pro'; /** * The status of a job, as a word and a tint. §8 — never colour alone. * * Two maps, because the same status means different things to the two sides. * `matched` is "somebody said yes" to a customer and "you said yes" to a pro; * one shared wording would fit neither, and a pro reading "Pros interested" * about their own accepted job would reasonably think it was somebody else's. */ const STATUS: Record> = { client: { open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' }, matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' }, booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' }, completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' }, cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' }, }, pro: { open: { label: 'Still open', className: 'border-brand-200 bg-brand-100 text-ink-950' }, matched: { label: 'You accepted', className: 'border-go-100 bg-go-50 text-go-700' }, booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' }, completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' }, cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' }, }, }; export interface JobRowData { id: string; title: string; status: JobStatus; /** The second line. The trade for a client, the customer's name for a pro. */ subtitle: string; createdAt: Date; matchCount?: number; pendingCount?: number; unreadCount: number; lastMessageAt: Date | null; nextBookingAt: Date | null; } /** * One line of live detail per row, chosen by priority rather than stacked. * * A row that shows unread messages AND a booking AND three pending requests is a * row nobody reads. The order is what the person has to act on soonest: someone * is waiting for a reply, then something is about to happen, then someone is * waiting for a decision. */ function liveDetail(job: JobRowData): { icon: typeof Users; text: string; urgent: boolean } | null { if (job.unreadCount > 0) { return { icon: MessageSquare, text: job.unreadCount === 1 ? '1 new message' : `${job.unreadCount} new messages`, urgent: true, }; } if (job.nextBookingAt) { return { icon: CalendarClock, text: formatWhen(job.nextBookingAt), urgent: false }; } if (job.matchCount) { return { icon: Users, text: job.matchCount === 1 ? '1 pro accepted' : `${job.matchCount} pros accepted`, urgent: false, }; } if (job.pendingCount) { return { icon: Users, text: job.pendingCount === 1 ? '1 pro deciding' : `${job.pendingCount} pros deciding`, urgent: false, }; } return null; } /** * DESIGN.md §6.10, applied to a job rather than a pro. * * No thumbnail: a job's photos are of a broken boiler, and a 56px crop of one * says nothing at a glance. The trade and the status carry the row instead. */ export function JobRow({ job, perspective, onOpen, }: { job: JobRowData; perspective: Perspective; onOpen: (jobId: string) => void; }) { const status = STATUS[perspective][job.status]; const detail = liveDetail(job); const timestamp = job.lastMessageAt ?? job.createdAt; return ( ); } /** Placeholder at the row's own height, so the list does not jump when it lands. */ export function JobRowSkeleton() { return
; }