'use client';
import { useState } from 'react';
import Link from 'next/link';
import { api } from '@/lib/trpc';
import { SignedOut } from '@/components/chrome/signed-out';
import { buttonClasses, EmptyState, Segmented } from '@/components/ui';
import { JobRow, JobRowSkeleton, type JobRowData, type Perspective } from '@/components/jobs/job-row';
import { JobDetail } from '@/components/jobs/job-detail';
import { ChatThread } from '@/components/jobs/chat-thread';
import { ReviewSheet } from '@/components/jobs/review-sheet';
import { ReviewPrompt } from '@/components/jobs/review-prompt';
/**
* Where you are inside the tab.
*
* `jobId` on a thread is where Back goes, not where the thread came from: a
* client reaches a conversation through their job and should land back on it,
* while a pro's list row IS the conversation and has no middle screen to return
* to. Storing the destination rather than the history keeps Back from ever
* needing a stack.
*/
export type JobsView =
| { kind: 'list' }
| { kind: 'job'; jobId: string }
| { kind: 'thread'; matchId: string; jobId: string | null };
export type JobsSegment = 'current' | 'past';
export interface JobsState {
view: JobsView;
segment: JobsSegment;
}
export const INITIAL_JOBS_STATE: JobsState = { view: { kind: 'list' }, segment: 'current' };
/**
* The Jobs tab.
*
* Current and past are one list under a segmented control rather than two tabs,
* because they are the same objects at different points in their life — and
* because a job moves between them on its own, without the user doing anything.
* `isActive` comes from the server (ACTIVE_JOB_STATUSES), so the split cannot
* drift from the state machine.
*
* State is owned by the parent for the reason SearchPanel's is: the tab bar
* unmounts panels on switch, and losing your place in a half-read conversation
* because you glanced at Settings is worse than losing a search query.
*/
export function JobsPanel({
state,
onChange,
}: {
state: JobsState;
onChange: (next: JobsState) => void;
}) {
const me = api.user.me.useQuery(undefined, { retry: false });
const go = (view: JobsView) => onChange({ ...state, view });
if (me.isLoading) {
return (
);
}
if (me.error || !me.data) {
return (
);
}
if (state.view.kind === 'thread') {
const { matchId, jobId } = state.view;
return (
go(jobId ? { kind: 'job', jobId } : { kind: 'list' })}
/>
);
}
if (state.view.kind === 'job') {
const { jobId } = state.view;
return (
go({ kind: 'list' })}
onOpenThread={(matchId) => go({ kind: 'thread', matchId, jobId })}
/>
);
}
return me.data.role === 'pro' ? (
) : (
);
}
/**
* Work that is finished and still owed a review, above the Past list.
*
* The reason the Past segment is worth opening. A rating is the only thing a new
* pro has to trade on, and nobody navigates to a finished job to leave one
* unprompted — so it is asked for where the finished job already is.
*/
function PendingReviews() {
const pending = api.review.pending.useQuery(undefined, { retry: false });
const [open, setOpen] = useState<{
bookingId: string;
subjectName: string;
jobTitle: string;
} | null>(null);
const rows = pending.data ?? [];
if (rows.length === 0) return null;
return (
<>
setOpen(null)}
/>
>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return
{children}
;
}
/* ─────────────────────────────── client ─────────────────────────────── */
function ClientJobs({
state,
onChange,
}: {
state: JobsState;
onChange: (next: JobsState) => void;
}) {
// Polled, not pushed: a job list that goes stale hides the one thing this tab
// exists to surface — that somebody replied. Thirty seconds is cheap here and
// the thread itself refreshes far faster while it is open.
const jobs = api.job.mine.useQuery(undefined, { refetchInterval: 30_000 });
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
id: j.id,
title: j.title,
status: j.status,
subtitle: j.categoryName,
createdAt: j.createdAt,
matchCount: j.matchCount,
pendingCount: j.pendingCount,
unreadCount: j.unreadCount,
lastMessageAt: j.lastMessageAt,
nextBookingAt: j.nextBookingAt,
}));
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
return (
onChange({ ...state, view: { kind: 'job', jobId } })}
empty={{
current: {
title: 'No jobs on the go',
body: 'Post a job and we will build you a deck of verified pros who cover your area.',
action: (
Post a job
),
},
past: {
title: 'Nothing finished yet',
body: 'Jobs you complete or cancel move here, with the conversation kept as a record.',
},
}}
action={
Post a job
}
/>
);
}
/* ───────────────────────────────── pro ──────────────────────────────── */
function ProJobs({ state, onChange }: { state: JobsState; onChange: (next: JobsState) => void }) {
const jobs = api.job.mineForPro.useQuery(undefined, { refetchInterval: 30_000 });
const byId = new Map((jobs.data ?? []).map((j) => [j.id, j]));
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
id: j.id,
title: j.title,
status: j.status,
// The customer, not the trade — a plumber's list of plumbing jobs does not
// need to say "Plumber" eleven times.
subtitle: j.clientName ?? 'Customer',
createdAt: j.createdAt,
unreadCount: j.unreadCount,
lastMessageAt: j.lastMessageAt,
nextBookingAt: j.nextBookingAt,
}));
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
return (
{
const match = byId.get(jobId);
if (!match) return;
onChange({ ...state, view: { kind: 'thread', matchId: match.matchId, jobId: null } });
}}
empty={{
current: {
title: 'No live jobs',
body: 'Jobs you accept from your inbox appear here, with the customer chat attached.',
},
past: {
title: 'No finished jobs yet',
body: 'Work you complete moves here, and the customer can leave you a review.',
},
}}
/>
);
}
/* ─────────────────────────────── shared ─────────────────────────────── */
interface EmptyCopy {
title: string;
body: string;
action?: React.ReactNode;
}
function JobList({
perspective,
loading,
rows,
activeIds,
state,
onChange,
onOpen,
empty,
action,
}: {
perspective: Perspective;
loading: boolean;
rows: JobRowData[];
activeIds: Set;
state: JobsState;
onChange: (next: JobsState) => void;
onOpen: (jobId: string) => void;
empty: Record;
action?: React.ReactNode;
}) {
const current = rows.filter((r) => activeIds.has(r.id));
const past = rows.filter((r) => !activeIds.has(r.id));
const shown = state.segment === 'current' ? current : past;
const copy = empty[state.segment];
return (
Jobs
{/* Counts are rendered even at zero — a missing number reads as loading. */}
onChange({ ...state, segment })}
segments={[
{ id: 'current', label: 'Current', count: current.length },
{ id: 'past', label: 'Past', count: past.length },
]}
/>
{/* Past only: a review belongs beside finished work, not beside a job
somebody is still waiting on. */}
{state.segment === 'past' && (