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:
serfa
2026-08-23 10:56:31 -04:00
co-authored by Claude Opus 5
parent 5086a238ea
commit 1808ad4cba
113 changed files with 1944 additions and 472 deletions
+100 -49
View File
@@ -10,24 +10,51 @@ import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import * as schema from './schema/index';
import { recomputeAllProStats } from './queries/stats';
import { readSsl } from './client';
config({ path: '../../.env' });
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
const client = postgres(url, { max: 1 });
// Same TLS rule as the app — the seed truncates and rewrites everything, so
// it is the last thing that should reach a managed database unverified.
const ssl = readSsl();
const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(client, { schema });
const CITY = {
name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Barcelona',
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Ciudad de México',
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
/**
* Real CDMX streets, rotated by index.
*
* A demo where every job is at "Example Street 12" reads as filler on the very
* screen — the job list — that is meant to look like real work. These are all
* in Roma/Condesa, which is inside the seeded pros' service radii.
*/
const STREETS = [
'Av. Álvaro Obregón',
'Calle Durango',
'Av. Ámsterdam',
'Calle Colima',
'Av. Michoacán',
'Calle Orizaba',
'Av. Nuevo León',
'Calle Tonalá',
];
/** "Calle Colima 34, Ciudad de México" — a house number and a street, per index. */
function streetAddress(number: number, k = 0): string {
return `${STREETS[k % STREETS.length]} ${number}, ${CITY.name}`;
}
/** Move a known distance along a bearing from an origin. Accurate enough at city scale. */
function offset(lat: number, lng: number, metres: number, bearingDeg: number) {
const R = 6_371_000;
@@ -118,6 +145,19 @@ interface SeedPro {
cat: string;
/** Free-text specialisms. Search matches these, so a few pros must have some. */
skills?: string[];
/**
* REQUIRED. The Unsplash photo shown on this pro's card.
*
* The deck is a people-picker: a card is somebody you are deciding whether to
* let into your home, so a stock portrait of a stranger under the word
* "Electrician" reads as a dating app, and a keyword search for "painter"
* returns oil paintings. Both were tried and both were wrong.
*
* Every id below was chosen by reading Unsplash's own written description and
* keeping only those that say a PERSON is doing THAT trade. Verified by text,
* not by luck — so if one looks wrong, the fix is to swap the id here.
*/
photo: string;
distanceM: number;
rating: number | null;
reviews: number;
@@ -129,43 +169,51 @@ interface SeedPro {
/** distanceM is measured from the city centre — deck tests assert against these. */
const PROS: SeedPro[] = [
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000,
skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] },
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
{ name: 'Antón Bautista', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
skills: ['Bathroom fitting', 'Leak detection'] },
{ name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
{ name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 },
{ name: 'Jorge Pineda', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
{ name: 'Norma Salgado', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 },
// Further away than they are willing to travel — must NOT appear for a central job.
{ name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
{ name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000,
{ name: 'Pablo Rivas', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
{ name: 'Martino Gómez', cat: 'electrician', photo: 'photo-1621905251189-08b45d6a269e', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000,
skills: ['EV chargers', 'Rewiring', 'Fuse boards'] },
{ name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
{ name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
{ name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
{ name: 'Clara Roca', cat: 'handyman', distanceM: 5_200, rating: 4.7, reviews: 34, radius: 18_000 },
{ name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
{ name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
{ name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
{ name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
{ name: 'Omar Campos', cat: 'electrician', photo: 'photo-1660330589693-99889d60181e', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
{ name: 'Marta Villanueva', cat: 'electrician', photo: 'photo-1646640381839-02748ae8ddf0', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
{ name: 'Sergio Bonilla', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
{ name: 'Iván Serrano', cat: 'handyman', photo: 'photo-1698998882494-57c3e043f340', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
{ name: 'Elena Prado', cat: 'painter', photo: 'photo-1717281234297-3def5ae3eee1', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
{ name: 'Antonio Blanco', cat: 'painter', photo: 'photo-1652829069834-2c05031199c5', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
{ name: 'Rosa Ventura', cat: 'carpenter', photo: 'photo-1659930087003-2d64e33181f7', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
skills: ['Fitted wardrobes', 'Listed buildings'] },
{ name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
{ name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 },
{ name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
{ name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
{ name: 'Guillermo Cortés', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
{ name: 'Javier Durán', cat: 'locksmith', photo: 'photo-1676630656246-3047520adfdf', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Berta Loera', cat: 'appliance-repair', photo: 'photo-1698998882494-57c3e043f340', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
{ name: 'Adrián Fonseca', cat: 'appliance-repair', photo: 'photo-1621905251918-48416bd8575a', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
{ name: 'Carlo Rincón', cat: 'hvac', photo: 'photo-1642749776312-aa42ce20c9f5', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
skills: ['Split units', 'Heat pumps'] },
{ name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
{ name: 'Marco Segura', cat: 'hvac', photo: 'photo-1705579605238-24a90c8799c5', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
// Brand new and unrated — proves the new-pro boost keeps fresh supply visible.
{ name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
{ name: 'Julia Camps', cat: 'electrician', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true },
{ name: 'Néstor Bosque', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
// Five more plumbers with real trade photography, so the first deck a client
// sees is deep and looks like the job it is for.
{ name: 'Sergio Fabela', cat: 'plumber', photo: 'photo-1676210133055-eab6ef033ce3', distanceM: 1_100, rating: 4.8, reviews: 62, radius: 18_000,
skills: ['Underfloor heating', 'Emergency callouts', 'Blocked drains', 'Pipe relining'] },
{ name: 'Rogelio Amaya', cat: 'plumber', photo: 'photo-1676210134190-3f2c0d5cf58d', distanceM: 2_900, rating: 4.6, reviews: 38, radius: 20_000,
skills: ['Boiler servicing', 'Radiator installs'] },
{ name: 'Gerardo Solís', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 4_200, rating: 4.9, reviews: 84, radius: 15_000,
skills: ['Bathroom refits', 'Underfloor heating', 'Wet rooms'] },
{ name: 'Alejandro Dávila', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 5_600, rating: 4.4, reviews: 17, radius: 12_000,
skills: ['Leak detection', 'Tap and valve repairs'] },
{ name: 'Gabriel Torres', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', distanceM: 7_300, rating: 4.7, reviews: 29, radius: 25_000,
skills: ['Water heaters', 'Kitchen plumbing', 'Emergency callouts'] },
{ name: 'Julia Cázares', cat: 'electrician', photo: 'photo-1758101755915-462eddc23f57', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true },
// Not verified — must never reach a deck.
{ name: 'Unverified Ulla', cat: 'plumber', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true },
{ name: 'Unverified Ulises', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true },
// Verified but on holiday — must never reach a deck.
{ name: 'Away Arnau', cat: 'plumber', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
{ name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
];
const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres'];
const CLIENTS = ['Sofía Guzmán', 'Daniel Miranda', 'Emma Rivera', 'Lucas Ponce', 'Alba Tovar'];
/**
* Finished work, per trade, for the review histories below.
@@ -282,7 +330,7 @@ const GENERIC_WORK: SeedWork[] = [
*
* Built to AVERAGE to the pro's headline figure rather than scattered around
* it, because the counters are derived from these rows: deck.test.ts asserts
* Marc Oliveras rates 4.9, and that now has to come out of 47 individual
* Sergi Fabra rates 4.8, and that now has to come out of 62 individual
* scores rather than being asserted directly on the profile.
*
* So a 4.9 becomes forty-two 5s and five 4s. Whole stars only — nobody awards
@@ -333,7 +381,7 @@ async function main() {
* cannot log in as — which makes the seed data invisible in the app it
* exists to fill.
*/
phoneNumber: i === 0 ? '+34600000000' : `+3460000${String(i + 1).padStart(4, '0')}`,
phoneNumber: i === 0 ? '+525500000000' : `+52550000${String(i + 1).padStart(4, '0')}`,
role: 'client' as const,
emailVerified: true,
phoneNumberVerified: true,
@@ -344,9 +392,9 @@ async function main() {
console.log(` ${clientRows.length} clients`);
await db.insert(schema.users).values({
name: 'Linkder Admin',
name: 'Linkdr Admin',
email: 'admin@linkder.test',
phoneNumber: '+34600009999',
phoneNumber: '+525500009999',
role: 'admin',
emailVerified: true,
});
@@ -364,7 +412,7 @@ async function main() {
.values({
name: p.name,
email: `pro${i + 1}@linkder.test`,
phoneNumber: `+3461000${String(i + 1).padStart(4, '0')}`,
phoneNumber: `+52551000${String(i + 1).padStart(4, '0')}`,
role: 'pro' as const,
emailVerified: true,
phoneNumberVerified: true,
@@ -406,18 +454,21 @@ async function main() {
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
proRows.push({ id: user.id, seed: p, categoryId: catId });
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
// The first photo is the deck card, so it has to be a FACE. picsum returns
// landscape stock scenery — a locksmith on a railway track — which makes the
// deck unreadable as a people-picker no matter what the layout does.
// pravatar serves ~70 portraits; i is 0-based and img is 1-based.
const portrait = (i % 70) + 1;
/*
* The card photo, and a wider crop of the same shot as the work sample.
*
* Seeded with the ORIGINAL Unsplash url, then rewritten to our own bucket by
* `pnpm assets:migrate`. Keeping the source here rather than a hardcoded
* Spaces url means the seed still works on a machine with no bucket, and the
* migration stays the one place that knows where assets live.
*/
const card = `https://images.unsplash.com/${p.photo}?w=800&h=1000&fit=crop`;
await db.insert(schema.proMedia).values([
{ proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 },
{ proId: user.id, url: card, position: 0 },
{
// The second slot is genuinely for work: scenery is fine here.
proId: user.id,
url: `https://picsum.photos/seed/${slug}-work/800/1000`,
url: `https://images.unsplash.com/${p.photo}?w=1200&h=900&fit=crop`,
kind: 'work_sample' as const,
position: 1,
},
@@ -494,7 +545,7 @@ async function main() {
urgency: 'flexible' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`,
addressText: streetAddress(10 + (k % 40), k),
status: 'completed' as const,
createdAt: new Date(at(k).getTime() - 6 * 86_400_000),
};
@@ -510,7 +561,7 @@ async function main() {
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`,
addressText: streetAddress(60 + (k % 20), k + 3),
status: 'cancelled' as const,
createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000),
})),
@@ -623,7 +674,7 @@ async function main() {
budgetMaxCents: 20_000,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
addressText: streetAddress(12),
})
.returning();
console.log(` 1 open job at the city centre (${job?.id})`);
@@ -679,7 +730,7 @@ async function main() {
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
addressText: streetAddress(12),
status: c.status,
})
.returning();