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:
+100
-49
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user