cb148c3dc86a70391a62052945218c36bb117089
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cb148c3dc8 |
Panel: an architecture diagram, store costs, and Spanish that reads as Spanish
The right-hand panel now answers the question the stack list provoked — what does a client actually install? Capacitor, iOS and Android lead "Built with", and a new SVG diagram shows the two ways in: customers through the installed app, staff through the admin dashboard behind Cloudflare Access. Costs were half-quoted. The stack named Mapbox, Twilio, Resend and Sentry and priced none of them, so each now states the shape of its bill rather than a figure that would be wrong at every scale. App store accounts are listed too, in the client's own name for the same reason the hosting is. Currency was the serious one. Every figure is US dollars and the panel wrote them as "$59", but this launches in Mexico, where "$" is the peso — the Spanish column understated the running cost roughly seventeenfold to the only reader who matters. money() now prints "$59" in English and "59 USD" in Spanish. The rest of the Spanish was a literal translation of dense English: peninsular vocabulary in a Mexican market, two stray vosotros forms among the tuteo, and calques that were not grammatical — "aplicada en ninguna parte", "todo lo discontinuo" for dashed lines. Reviewed all 126 strings. The architecture note was 120 words in one block covering five topics; it is now two paragraphs, one idea each, and the PostGIS sentence went because "What it does" already makes that promise in language a client can act on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0192585727 |
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> |
||
|
|
1808ad4cba |
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> |
||
|
|
974e312534 |
M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
Closes the funnel. Before this the product could match two people and then stopped: `quotes`, `bookings` and `reviews` had tables and state machines and nothing that wrote a row, the entry deck's right swipe was wired to an empty handler, and every address resolved to the city centre. Jobs tab and chat - message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not a job — one job with three interested pros is three private conversations. - Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the pros who accepted, and the conversation itself with attachments. Hiring from the deck - A right swipe on the entry deck opened nothing. It now resolves "which job?" through a sheet — sign in, pick an open job, or post one — and calls the same deck.swipe the per-job deck does, so the open-request cap and row lock apply exactly once. Swipes are vetoable so closing the sheet returns the card. Geocoding - ST_Distance and ST_DWithin rank and filter every deck, and both operands were placeholders. Addresses now resolve through Mapbox (permanent=true, which is what licenses storing the coordinates), the server resolves points rather than trusting client-supplied lat/lng, and every stored point records how it was obtained. A `city`-precision base cannot reach the verification queue. Quote -> booking -> review - The commercial chain, minus payments. Accepting a quote is the only place a booking is created; confirming completion is what unlocks reviews and moves the pro's completed_jobs. - Reviews publish double-blind with no sweeper: each is written with published_at already set to its embargo deadline and every read filters published_at <= now(), so it publishes itself. The second review pulls both forward. A silent counterparty cannot bury a bad review by never replying. State machine changes, both deliberate - booked -> matched: a cancelled booking is not a cancelled job. - scheduled -> awaiting_confirmation: in_progress is optional, so a pro who never tapped Start can still say the work is done. Test suite - api tests ran files in parallel against one database and failed roughly one run in three on whichever file lost the race. Serialised, and three fixtures that grabbed "the first client" pinned to the seeded accounts. Also includes work from a parallel session: admin verification queue, pro public profile and reviews read path, notification sending, denormalised stats recompute, search, and observability. 318 tests passing; typecheck and lint clean across 7 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
176ba187c8 |
M1: phone app shell, settings, profile, dev login
Everything now renders inside a phone illustration on the entry screen, with a five-tab bar. The frame lives in the root layout rather than one page, so sign-in, onboarding and the job form are inside it too. - Entry screen is the product running, not a marketing page: a live swipeable deck of real verified pros with a trade-filter strip above the card. deck.showcase is the only public procedure in that router and writes nothing, so an anonymous right swipe reaches no one. - Settings: notification preferences (new table, defaults returned when no row exists), signed-in devices, GDPR export, deletion request. Closes the setEmail finding: an unverified address is no longer written to users.email, which is UNIQUE -- claiming a stranger's address used to block them from ever signing up with Google, and the uniqueness error leaked whether an address was registered. Now parked in email_change_requests until a token proves ownership. - Profile: for a pro it leads with their REAL deck card, rendered by the same exported <Card> clients swipe, so the two cannot drift. Adds pro.previewCard (works at draft/pending, where publicProfile 404s) and pro.reorderMedia (photo position 0 is the deck card). Warns before an edit that would send a verified pro back for review, rather than after it silently drops them off the deck. Clients get a thin profile plus a route into pro onboarding -- supply is the launch blocker. - Dev login: +34600000000 / 000000, behind THREE guards (NODE_ENV, an explicit ALLOW_DEV_LOGIN flag, and an exact number match). It overwrites the stored code rather than skipping verification, so the real expiry, attempt cap and single-use consumption still apply. - Seed uses portrait photos. The cards previously showed picsum stock scenery -- a locksmith standing on a railway track. Fixes found along the way: the card's name rendered ink-950 navy on a dark photo because globals.css sets h1..h6 colour in @layer base, which beat the inherited text-white; and the card referenced --color-go-500, --border and --card, none of which exist, so the SEND JOB stamp had no colour. Also adds public/sw.js as a kill-switch: a service worker left registered on localhost:3000 by a different project was intercepting this app's chunks. typecheck, lint clean; 186 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
582f13fa99 |
Entry screen is the app: phone frame with a live deck inside
The homepage was a marketing brochure -- a hero paragraph, a "three steps" explainer and a tag list. It described the gesture in prose while the actual Tinder deck sat behind /deck/[jobId], reachable only after signing in AND posting a job. Nobody opening the app ever saw the product. Now / renders a phone illustration with the real app running inside it: the same <Deck>, the same drag physics, real verified pros. On a phone the bezel collapses and the deck simply fills the viewport -- drawing a picture of a phone on a phone is absurd, and it would eat the width the cards need. - Removed the fixed app bar and bottom tab bar. AppShell now renders the screen title as an in-flow h1; the bar owned the only h1 on every screen, so dropping it silently would have left every page headingless. The /jobs "post" action moved from bar chrome into the content, since the tab bar was its only other route there. - getShowcaseDeck(): a deck with no job behind it. getDeck is job-scoped (joins jobs for category and location, anti-joins swipes), which an anonymous visitor has none of, so this centres on the launch city. Eligibility rules are copied verbatim -- nobody may appear in the shop window who could not appear on a real deck. - deck.showcase: the only public procedure in the router. list and swipe stay behind clientProcedure. It reads nothing about the caller and writes nothing, so a right swipe on the entry screen is purely local. No real tradesperson is contacted until a job is posted. - <Deck> filled a hardcoded 560px desktop box; it now fills its container. The card counter moved out from between the two action buttons so the thumb zone holds nothing but the two controls. Verified against the seeded database: 22 eligible pros returned, and all three seeded traps excluded for the right reason -- Pau Ribas (22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not accepting). 7 new integration tests cover exactly that. Also corrects a label I had written as "Plumbers near you" -- the showcase deck is not category-filtered and shows every trade. Includes concurrent edits to the mobile shell, ui/ primitives and DESIGN.md made outside this session. typecheck, lint, build clean; 141 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
66dd4ac942 |
M1 (partial): tRPC API layer, storage, and three real fixes
Stands up packages/api so the swipe path stops trusting its caller, and puts the auth library behind an interface we own. - packages/api: tRPC v11 with a Session type WE define, not one re-exported from an auth library. Swapping providers means rewriting one SessionResolver, not touching a router. - Procedure layers: public / protected / client / pro / verifiedPro / admin. Admin routes 404 rather than 403 so they cannot be probed. - deck router replaces the untrusted server action. Ownership is checked on every operation and returns NOT_FOUND, never FORBIDDEN, so job ids cannot be enumerated. 23 tests, mostly authorization. - packages/storage: presigned direct-to-R2 uploads. The server picks the key, so a caller can only write under their own user id. 14 tests. Three defects found and fixed: - The lazy db Proxy failed drizzle's is(db, PgDatabase) because it did not trap getPrototypeOf. Auth adapters dispatch on exactly that check, so this would have failed at runtime inside third-party code. Fixed and pinned with a regression test. - The open-request cap was a read-then-write race: concurrent swipes could both read 4 and both insert. Now one transaction with the job row locked. The cap is checked before the tombstone is written, so a rejected swipe leaves no trace and the card stays on the deck. - superjson was configured in two of the three required places. Without the QueryClient dehydrate/hydrate pair, RSC-prefetched data arrives as a raw envelope with no type error to warn you. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
19623bcccb |
M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.
- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|