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>
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
'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. */
|
||||
/**
|
||||
* 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,
|
||||
@@ -14,7 +21,7 @@ export function SettingsGroup({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<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}
|
||||
@@ -24,11 +31,23 @@ export function SettingsGroup({
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only or navigational row. */
|
||||
/** 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,
|
||||
@@ -36,34 +55,48 @@ export function SettingsRow({
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
hint?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const interactive = Boolean(onClick) && !disabled;
|
||||
const Tag = interactive ? 'button' : 'div';
|
||||
const interactive = Boolean(href ?? onClick) && !disabled;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...(interactive ? { type: 'button' as const, onClick } : {})}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
interactive && 'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
disabled && 'opacity-45',
|
||||
)}
|
||||
>
|
||||
const inner = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={cn('block text-body-sm', danger ? 'text-stop-500' : 'text-strong')}>
|
||||
<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 && (
|
||||
<span className="shrink-0 text-body-sm text-muted">{value}</span>
|
||||
// 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 />}
|
||||
</Tag>
|
||||
</>
|
||||
);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,8 +124,8 @@ export function SettingsToggle({
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
rowClasses,
|
||||
interactiveClasses,
|
||||
disabled && 'pointer-events-none opacity-45',
|
||||
)}
|
||||
>
|
||||
@@ -105,7 +138,10 @@ export function SettingsToggle({
|
||||
className={cn(
|
||||
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
checked ? 'bg-brand-500' : 'bg-ink-300',
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user