Files
linkder/apps/web/src/components/jobs/job-row.tsx
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

155 lines
5.3 KiB
TypeScript

'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<Perspective, Record<JobStatus, { label: string; className: string }>> = {
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 (
<button
type="button"
onClick={() => onOpen(job.id)}
className={cn(
'flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left',
'transition-[border-color] duration-[120ms] ease-standard',
'hover:border-brand-500 active:border-brand-500',
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-h4 text-strong">{job.title}</span>
<span className="shrink-0 text-meta text-faint tabular-nums">
{formatRelativeTime(timestamp)}
</span>
</span>
<span className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
status.className,
)}
>
{status.label}
</span>
<span className="truncate text-body-sm text-muted">{job.subtitle}</span>
</span>
{detail && (
<span
className={cn(
'mt-1.5 flex items-center gap-1.5 text-meta',
detail.urgent ? 'font-semibold text-accent' : 'text-faint',
)}
>
<detail.icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
{detail.text}
</span>
)}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
</button>
);
}
/** Placeholder at the row's own height, so the list does not jump when it lands. */
export function JobRowSkeleton() {
return <div className="h-[6.5rem] animate-pulse rounded-card bg-inset" aria-hidden />;
}