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>
158 lines
4.6 KiB
TypeScript
158 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { ChevronRight } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* A titled group of rows. Settings is a list of lists.
|
|
*
|
|
* The gap below is 32px rather than 24px because these are the "blocks within a
|
|
* screen" of §4, not siblings in a list — at 24px the group titles stopped
|
|
* reading as titles and the page became one undifferentiated stack of boxes.
|
|
*/
|
|
export function SettingsGroup({
|
|
title,
|
|
note,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
note?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="mb-8">
|
|
<h2 className="mb-2 px-1 text-overline uppercase text-faint">{title}</h2>
|
|
<div className="divide-y divide-hairline overflow-hidden rounded-card border border-hairline bg-raised">
|
|
{children}
|
|
</div>
|
|
{note && <p className="mt-2 px-1 text-meta text-muted">{note}</p>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Shared by every row shape below, so a group never looks stitched together. */
|
|
const rowClasses = 'flex w-full items-center gap-3 px-4 py-3.5 text-left';
|
|
const interactiveClasses = 'transition-colors duration-[120ms] ease-standard hover:bg-sunken';
|
|
|
|
/**
|
|
* A read-only, navigational, or linking row.
|
|
*
|
|
* `href` and `onClick` are alternatives, and one of them must be present for the
|
|
* row to draw a chevron. A row that shows the chevron and does nothing is the
|
|
* worst state available here — it is indistinguishable from a broken link, so
|
|
* the affordance is tied to the destination rather than set by hand.
|
|
*/
|
|
export function SettingsRow({
|
|
label,
|
|
value,
|
|
hint,
|
|
href,
|
|
onClick,
|
|
danger,
|
|
disabled,
|
|
}: {
|
|
label: string;
|
|
value?: React.ReactNode;
|
|
hint?: string;
|
|
href?: string;
|
|
onClick?: () => void;
|
|
danger?: boolean;
|
|
disabled?: boolean;
|
|
}) {
|
|
const interactive = Boolean(href ?? onClick) && !disabled;
|
|
|
|
const inner = (
|
|
<>
|
|
<span className="min-w-0 flex-1">
|
|
<span className={cn('block text-body-sm', danger ? 'text-danger' : 'text-strong')}>
|
|
{label}
|
|
</span>
|
|
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
|
</span>
|
|
{value !== undefined && (
|
|
// Shrinkable and truncating, NOT shrink-0: an email long enough to need
|
|
// the room used to take it from the label, which collapsed to nothing
|
|
// while the value it was labelling ran on past the bezel.
|
|
<span className="min-w-0 truncate text-right text-body-sm text-muted">{value}</span>
|
|
)}
|
|
{interactive && <ChevronRight className="h-4 w-4 shrink-0 text-faint" aria-hidden />}
|
|
</>
|
|
);
|
|
|
|
if (href && !disabled) {
|
|
return (
|
|
<Link href={href} className={cn(rowClasses, interactiveClasses)}>
|
|
{inner}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
if (onClick && !disabled) {
|
|
return (
|
|
<button type="button" onClick={onClick} className={cn(rowClasses, interactiveClasses)}>
|
|
{inner}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return <div className={cn(rowClasses, disabled && 'opacity-45')}>{inner}</div>;
|
|
}
|
|
|
|
/**
|
|
* A row carrying a switch. The label is the control's accessible name, so the
|
|
* whole row is one tap target rather than a label and a separate 20px switch.
|
|
*/
|
|
export function SettingsToggle({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
onChange: (next: boolean) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
disabled={disabled}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
rowClasses,
|
|
interactiveClasses,
|
|
disabled && 'pointer-events-none opacity-45',
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-body-sm text-strong">{label}</span>
|
|
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
|
</span>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
|
|
'transition-colors duration-[120ms] ease-standard',
|
|
// ink-500, not ink-300: an off switch is a UI component and owes 3:1
|
|
// against the row behind it (§8). ink-300 measured 1.78:1 on white,
|
|
// which made "off" read as "disabled" as much as it read as a state.
|
|
checked ? 'bg-brand-500' : 'bg-ink-500',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute top-[0.2rem] h-[1.2rem] w-[1.2rem] rounded-pill bg-white shadow-sm',
|
|
'transition-[left] duration-[120ms] ease-standard',
|
|
checked ? 'left-[1.35rem]' : 'left-[0.2rem]',
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|