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>
155 lines
5.3 KiB
TypeScript
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 />;
|
|
}
|