Stock the trades we serve, and stop offering the ones we don't

A demo hit "That's everyone nearby" on Electrician after four swipes.
That was the deck working — there were exactly four — but the shape of
the catalogue was worse than it looked: 42 of the 50 trades had NO pros
at all, so tapping Roofer or Cleaner hit the dead end immediately rather
than after seven swipes.

Two halves to the fix, and the second matters more.

Depth: 56 more pros, so the fifteen live trades now run 3–15 deep
instead of 1–12. Every photo was picked by reading Unsplash's written
description and keeping only images that show the trade being DONE, then
checking each URL resolves — one of 33 was a 404 and was dropped rather
than seeded broken. Where a photo already belonged to someone in this
file the new pro takes a name of the same gender: the face and the name
on a card have to agree.

Honesty: the other 35 trades are seeded isActive:false. A category with
nobody behind it is not a feature, it is the product claiming to do
something it cannot — and no amount of invented supply fixes that, it
just moves the lie one screen later. LIVE_TRADES is the list, and
widening it means recruiting pros first and adding the slug second.

Locksmith is called out in the file: photo searches return padlocks, not
locksmiths, so two of its cards carry door hardware. A lock is at least
a locksmith's work. A stock portrait of somebody who is plainly not one
is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 14:01:34 -04:00
co-authored by Claude Opus 5
parent 5931cf28fb
commit 0192585727
3 changed files with 250 additions and 2 deletions
@@ -69,6 +69,12 @@ const TRY = {
} satisfies Record<string, Copy>;
const STACK: { group: Copy; items: string[] }[] = [
// First, because it is the answer to the question the rest of this list
// provokes: fine, but what do I actually install on a phone?
{
group: { en: 'Mobile', es: 'Móvil' },
items: ['Capacitor', 'iOS', 'Android'],
},
{
group: { en: 'App', es: 'App' },
items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'],
@@ -333,9 +339,82 @@ const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [
const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0);
/**
* The third-party services the stack above names but never costed.
*
* Every one of these is free at launch volumes and none of them is free
* forever, so a flat monthly figure would be wrong in both directions. What
* matters to somebody deciding is the SHAPE of each bill — what has to happen
* before it starts, and what it climbs with.
*/
const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [
{
item: { en: 'Twilio', es: 'Twilio' },
free: { en: 'Per message', es: 'Por mensaje' },
then: {
en: 'No monthly fee, but every sign-in code and job alert is a few cents, plus a rented number. The only one that costs money from day one.',
es: 'Sin cuota mensual, pero cada código de acceso y cada aviso de trabajo cuesta unos céntimos, más un número alquilado. El único que cuesta dinero desde el primer día.',
},
},
{
item: { en: 'Mapbox', es: 'Mapbox' },
free: { en: 'Free to start', es: 'Gratis al principio' },
then: {
en: 'Free up to tens of thousands of address lookups a month. Climbs with searches, not with customers.',
es: 'Gratis hasta decenas de miles de búsquedas de direcciones al mes. Sube con las búsquedas, no con los clientes.',
},
},
{
item: { en: 'Resend', es: 'Resend' },
free: { en: 'Free to start', es: 'Gratis al principio' },
then: {
en: 'Free for the first few thousand emails a month, about $20 after that.',
es: 'Gratis para los primeros miles de correos al mes, unos 20 $ a partir de ahí.',
},
},
{
item: { en: 'Sentry', es: 'Sentry' },
free: { en: 'Free to start', es: 'Gratis al principio' },
then: {
en: 'Free tier covers early error volumes, around $26 once it does not. Optional — it reports crashes, it does not run anything.',
es: 'El plan gratuito cubre los primeros errores, unos 26 $ cuando deje de hacerlo. Opcional: informa de fallos, no ejecuta nada.',
},
},
];
/**
* What it costs to have the app exist in a store.
*
* Not our fee and not hosting — these are accounts in the client's own name,
* for the same reason the DigitalOcean account is: an app published under our
* developer account is an app they cannot take with them.
*/
const STORE_ACCOUNTS: { item: Copy; cost: Copy; detail: Copy }[] = [
{
item: { en: 'Apple Developer Program', es: 'Apple Developer Program' },
cost: { en: '$99 / year', es: '99 $ / año' },
detail: {
en: 'Required to put anything on the App Store, and it lapses if unpaid — the app comes down with it. A company account also needs a D-U-N-S number, which is free but takes a couple of weeks, so it is worth starting early.',
es: 'Obligatoria para publicar cualquier cosa en la App Store, y caduca si no se renueva: la app se cae con ella. Una cuenta de empresa necesita además un número D-U-N-S, gratuito pero que tarda un par de semanas, así que conviene empezarlo pronto.',
},
},
{
item: { en: 'Google Play Console', es: 'Google Play Console' },
cost: { en: '$25 once', es: '25 $ una vez' },
detail: {
en: 'One payment, for the life of the account. Review is faster and less strict than Apples.',
es: 'Un solo pago, para toda la vida de la cuenta. La revisión es más rápida y menos estricta que la de Apple.',
},
},
];
const SECTIONS = {
features: { en: 'What it does', es: 'Qué hace' },
stack: { en: 'Built with', es: 'Hecho con' },
stackBody: {
en: 'One codebase, three places to install it. The same app runs in a browser and ships to the App Store and Google Play wrapped in Capacitor — so there is one thing to build and one thing to fix, rather than a website and two native apps drifting apart.',
es: 'Un solo código, tres sitios donde instalarlo. La misma app funciona en el navegador y se publica en la App Store y en Google Play envuelta en Capacitor: hay una sola cosa que construir y una sola que arreglar, en lugar de una web y dos apps nativas que se van separando.',
},
decisions: { en: 'Still to decide', es: 'Aún por decidir' },
decisionsBody: {
en: 'Everything below already has a behaviour. These are the ones worth choosing deliberately rather than inheriting.',
@@ -356,6 +435,16 @@ const SECTIONS = {
hosting: { en: 'Hosting, per month', es: 'Alojamiento, al mes' },
total: { en: 'Total', es: 'Total' },
perMonth: { en: '/mo', es: '/mes' },
services: { en: 'Services it calls', es: 'Servicios que utiliza' },
servicesNote: {
en: 'Separate accounts, separate bills, all in your name. None of these is charged by us and none has a markup.',
es: 'Cuentas separadas, facturas separadas, todas a tu nombre. Ninguno lo cobramos nosotros y ninguno lleva recargo.',
},
stores: { en: 'App store accounts', es: 'Cuentas de las tiendas' },
storesNote: {
en: 'The app is published under your developer accounts, not ours — the same reason the hosting is yours. An app on our account is an app you cannot take with you, and moving one afterwards means a new listing and losing its reviews and ranking.',
es: 'La app se publica con tus cuentas de desarrollador, no con las nuestras, por la misma razón que el alojamiento es tuyo. Una app en nuestra cuenta es una app que no te puedes llevar, y moverla después significa una ficha nueva y perder sus valoraciones y su posición.',
},
} satisfies Record<string, Copy>;
/** The three caveats under the hosting table — lead sentence, then the rest. */
@@ -367,6 +456,16 @@ const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [
es: ' No forma parte de nuestros honorarios y no lleva ningún recargo: la cuenta es tuya, así que puedes ver la factura y cambiar de plan sin pasar por nosotros.',
},
},
{
lead: {
en: 'DigitalOcean is the quote, not the requirement.',
es: 'DigitalOcean es el presupuesto, no el requisito.',
},
rest: {
en: ' What runs is a container and a Postgres database, so it runs just as well on AWS, Google Cloud, Hetzner or whatever you already have an account with — only the figures above change. We price DigitalOcean because it is the cheapest of the managed options at this size and its bill is legible.',
es: ' Lo que se ejecuta es un contenedor y una base de datos Postgres, así que funciona igual de bien en AWS, Google Cloud, Hetzner o donde ya tengas cuenta: solo cambian las cifras de arriba. Presupuestamos DigitalOcean porque es la más barata de las opciones gestionadas a este tamaño y su factura se entiende.',
},
},
{
lead: {
en: `$${HOSTING_TOTAL} is the smallest tier of each.`,
@@ -477,7 +576,8 @@ export function ProjectPanel() {
</section>
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.stack)}</h2>
<h2 className="mb-1 text-h3">{t(SECTIONS.stack)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(SECTIONS.stackBody)}</p>
<div className="flex flex-col gap-3">
{STACK.map((row) => (
<div key={row.group.en} className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
@@ -567,6 +667,42 @@ export function ProjectPanel() {
</p>
))}
</div>
{/*
The stack above names five outside services and the table above costs
none of them. No running total here on purpose: four services that are
each free until a different threshold do not add up to a number, and
printing one would be a figure nobody could check.
*/}
<h3 className="mt-8 mb-2 text-overline uppercase text-faint">{t(SECTIONS.services)}</h3>
<ul className="flex flex-col gap-3">
{SERVICES.map((s) => (
<li key={s.item.en}>
<div className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(s.item)}</span>
<span className="min-w-0 flex-1 border-b border-dotted border-hairline" />
<span className="shrink-0 text-strong">{t(s.free)}</span>
</div>
<p className="mt-0.5 max-w-[60ch] text-meta text-muted">{t(s.then)}</p>
</li>
))}
</ul>
<p className="mt-3 max-w-[60ch] text-meta text-muted">{t(SECTIONS.servicesNote)}</p>
<h3 className="mt-8 mb-2 text-overline uppercase text-faint">{t(SECTIONS.stores)}</h3>
<ul className="flex flex-col gap-3">
{STORE_ACCOUNTS.map((s) => (
<li key={s.item.en}>
<div className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(s.item)}</span>
<span className="min-w-0 flex-1 border-b border-dotted border-hairline" />
<span className="shrink-0 text-strong tabular-nums">{t(s.cost)}</span>
</div>
<p className="mt-0.5 max-w-[60ch] text-meta text-muted">{t(s.detail)}</p>
</li>
))}
</ul>
<p className="mt-3 max-w-[60ch] text-meta text-muted">{t(SECTIONS.storesNote)}</p>
</section>
</div>
);