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
+18 -13
View File
@@ -4,6 +4,11 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# ---- Database (Postgres 16 + PostGIS) ---- # ---- Database (Postgres 16 + PostGIS) ----
DATABASE_URL=postgresql://linkder:linkder@localhost:5442/linkder DATABASE_URL=postgresql://linkder:linkder@localhost:5442/linkder
# Managed Postgres only. Verifies the server's IDENTITY, not merely that the
# link is encrypted — sslmode=require alone leaves you open to anything that can
# answer for the hostname. Takes a path to the provider's .crt, or the PEM
# inline for a platform whose secrets are environment variables.
DATABASE_CA_CERT=
# ---- Redis (pub/sub for SSE chat + BullMQ queues) ---- # ---- Redis (pub/sub for SSE chat + BullMQ queues) ----
REDIS_URL=redis://localhost:6389 REDIS_URL=redis://localhost:6389
@@ -46,7 +51,7 @@ AUTH_MICROSOFT_TENANT_ID=common
# GitHub only returns a primary email if the OAuth app requests `user:email` # GitHub only returns a primary email if the OAuth app requests `user:email`
# AND the account has a verified one; a user whose email is private signs up # AND the account has a verified one; a user whose email is private signs up
# with no address, so never assume `users.email` is reachable mail — gate # with no address, so never assume `users.email` is reachable mail — gate
# outbound on isSyntheticEmail() from @linkder/shared, same as phone signups. # outbound on isSyntheticEmail() from @linkdr/shared, same as phone signups.
AUTH_GITHUB_ID= AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET= AUTH_GITHUB_SECRET=
@@ -63,7 +68,7 @@ AUTH_GITHUB_SECRET=
MAPBOX_TOKEN= MAPBOX_TOKEN=
# ISO 3166-1 alpha-2. Bounds results to one country: "Carrer de Sants" matches # ISO 3166-1 alpha-2. Bounds results to one country: "Carrer de Sants" matches
# in several places and the wrong continent is a worse answer than none. # in several places and the wrong continent is a worse answer than none.
MAPBOX_COUNTRY=es MAPBOX_COUNTRY=mx
# ---- Phone OTP (Twilio Verify) ---- # ---- Phone OTP (Twilio Verify) ----
TWILIO_ACCOUNT_SID= TWILIO_ACCOUNT_SID=
@@ -82,21 +87,14 @@ DIDIT_API_KEY=
DIDIT_WORKFLOW_ID= DIDIT_WORKFLOW_ID=
DIDIT_WEBHOOK_SECRET= DIDIT_WEBHOOK_SECRET=
# ---- Cloudflare R2 (S3-compatible object storage) ----
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=linkder-uploads
R2_PUBLIC_URL=
# ---- Resend (transactional email) ---- # ---- Resend (transactional email) ----
RESEND_API_KEY= RESEND_API_KEY=
EMAIL_FROM=noreply@linkder.app EMAIL_FROM=noreply@linkdr.app
# ---- Launch market (city-scoped MVP) ---- # ---- Launch market (city-scoped MVP) ----
NEXT_PUBLIC_CITY_NAME=Barcelona NEXT_PUBLIC_CITY_NAME=Ciudad de México
NEXT_PUBLIC_CITY_LAT=41.3874 NEXT_PUBLIC_CITY_LAT=19.4326
NEXT_PUBLIC_CITY_LNG=2.1686 NEXT_PUBLIC_CITY_LNG=-99.1332
TWILIO_FROM_NUMBER= TWILIO_FROM_NUMBER=
# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. # Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production.
@@ -105,3 +103,10 @@ ALLOW_DEV_LOGIN=false
# Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the # Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the
# client bundle. Leave blank to disable reporting entirely. # client bundle. Leave blank to disable reporting entirely.
NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_SENTRY_DSN=
# ---- Object storage (DigitalOcean Spaces) ----
SPACES_REGION=nyc3
SPACES_BUCKET=
SPACES_KEY=
SPACES_SECRET=
SPACES_CDN_URL=
+5 -5
View File
@@ -1,8 +1,8 @@
# Linkder Design System # Linkdr Design System
**Version 1.0** · derived from the wix.com design language, adapted for Linkder. **Version 1.0** · derived from the wix.com design language, adapted for Linkdr.
This document is the single source of truth for how Linkder looks. Every screen must be This document is the single source of truth for how Linkdr looks. Every screen must be
buildable from the tokens and components below. If a screen needs something that is not in buildable from the tokens and components below. If a screen needs something that is not in
here, add it here first, then build it. here, add it here first, then build it.
@@ -15,7 +15,7 @@ whitespace rhythm. Values below were read off the live stylesheet, not eyeballed
Adopted: typefaces (Wix Madefor Display / Text, published under the SIL Open Font License via Adopted: typefaces (Wix Madefor Display / Text, published under the SIL Open Font License via
Google Fonts), colour ramps, radius and spacing conventions, component geometry. Google Fonts), colour ramps, radius and spacing conventions, component geometry.
**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkder is **Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkdr is
not affiliated with Wix and must never present itself as such. not affiliated with Wix and must never present itself as such.
--- ---
@@ -153,7 +153,7 @@ Display sizes use `clamp()` so one token works from 360px to desktop.
## 4. Space and layout ## 4. Space and layout
**Linkder is a mobile-only product.** There is no desktop layout, no marketing site and no **Linkdr is a mobile-only product.** There is no desktop layout, no marketing site and no
responsive breakpoint work. Design for a 360430px viewport and nothing else. On a wider responsive breakpoint work. Design for a 360430px viewport and nothing else. On a wider
screen the app renders as a single 480px column centred on `ink-50` — that is a courtesy for screen the app renders as a single 480px column centred on `ink-50` — that is a courtesy for
someone who opened it on a laptop, not a layout to design for. someone who opened it on a laptop, not a layout to design for.
+3 -3
View File
@@ -1,4 +1,4 @@
# Linkder # Linkdr
Swipe-to-hire marketplace for local professional services. A client describes a job once, then Swipe-to-hire marketplace for local professional services. A client describes a job once, then
swipes through **verified** local pros — plumbers, electricians, handymen. A right swipe sends the swipes through **verified** local pros — plumbers, electricians, handymen. A right swipe sends the
@@ -61,8 +61,8 @@ packages/ui shared components (M1)
```bash ```bash
pnpm test # everything pnpm test # everything
pnpm --filter @linkder/shared test # 46 unit tests, no database needed pnpm --filter @linkdr/shared test # 46 unit tests, no database needed
pnpm --filter @linkder/db test # 18 integration tests, needs a seeded database pnpm --filter @linkdr/db test # 18 integration tests, needs a seeded database
``` ```
The seed is deterministic: every pro sits at a **known** distance and bearing from the city centre, The seed is deterministic: every pro sits at a **known** distance and bearing from the city centre,
+1 -1
View File
@@ -11,7 +11,7 @@ so nobody re-reports them; the rest are open and are the M1 exit criteria.
| 1 | `pro.upsertProfile` computed `requiresReReview` and never applied it — a verified plumber could silently become a verified electrician 40 km away | Demotes a `verified` profile to `pending` in the same transaction, writes an audit row, and now counts a **trade change** as material (it did not before). Needed a new `verified -> pending` edge in `VERIFICATION_GRAPH` | | 1 | `pro.upsertProfile` computed `requiresReReview` and never applied it — a verified plumber could silently become a verified electrician 40 km away | Demotes a `verified` profile to `pending` in the same transaction, writes an audit row, and now counts a **trade change** as material (it did not before). Needed a new `verified -> pending` edge in `VERIFICATION_GRAPH` |
| 2 | `phoneNumber()` registers `/phone-number/request-password-reset`, `/phone-number/reset-password` and `/sign-in/phone-number` **unconditionally** — not gated on `emailAndPassword.enabled: false`. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP | All three served as 404 via `disabledPaths` (checked in `onRequest`, before rate limiting) | | 2 | `phoneNumber()` registers `/phone-number/request-password-reset`, `/phone-number/reset-password` and `/sign-in/phone-number` **unconditionally** — not gated on `emailAndPassword.enabled: false`. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP | All three served as 404 via `disabledPaths` (checked in `onRequest`, before rate limiting) |
| 3 | `bearer()` accepted the raw plaintext `sessions.token` column as an `Authorization: Bearer` credential — one leaked DB row is a replayable login | Plugin removed. The mobile client it was for does not exist yet | | 3 | `bearer()` accepted the raw plaintext `sessions.token` column as an `Authorization: Bearer` credential — one leaked DB row is a replayable login | Plugin removed. The mobile client it was for does not exist yet |
| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkder/shared` | | 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkdr/shared` |
| 5 | `NEXT_PUBLIC_APP_URL` fell back to `http://localhost:3000`, which drops `Secure` and the `__Secure-` prefix from the production session cookie | Throws at boot in production | | 5 | `NEXT_PUBLIC_APP_URL` fell back to `http://localhost:3000`, which drops `Secure` and the `__Secure-` prefix from the production session cookie | Throws at boot in production |
## Open — must close before M1 ships ## Open — must close before M1 ships
+1 -1
View File
@@ -8,7 +8,7 @@ loadEnv({ path: '../../.env' });
const config: NextConfig = { const config: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
// The workspace packages ship TypeScript source, not build output. // The workspace packages ship TypeScript source, not build output.
transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'], transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'],
images: { images: {
remotePatterns: [ remotePatterns: [
// Seed data only — real pros upload to R2. The deck renders a plain <img>, // Seed data only — real pros upload to R2. The deck renders a plain <img>,
+6 -6
View File
@@ -1,5 +1,5 @@
{ {
"name": "@linkder/web", "name": "@linkdr/web",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -12,11 +12,11 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@linkder/api": "workspace:*", "@linkdr/api": "workspace:*",
"@linkder/db": "workspace:*", "@linkdr/db": "workspace:*",
"@linkder/notify": "workspace:*", "@linkdr/notify": "workspace:*",
"@linkder/shared": "workspace:*", "@linkdr/shared": "workspace:*",
"@linkder/storage": "workspace:*", "@linkdr/storage": "workspace:*",
"@sentry/nextjs": "^10.70.0", "@sentry/nextjs": "^10.70.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@trpc/client": "^11.18.0", "@trpc/client": "^11.18.0",
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function AdminProPage({ params }: { params: Promise<{ proId
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-body-sm"> <dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-body-sm">
<Row label="Status" value={pro.profile.verificationStatus} /> <Row label="Status" value={pro.profile.verificationStatus} />
<Row label="Accepting jobs" value={pro.profile.isAcceptingJobs ? 'Yes' : 'No'} /> <Row label="Accepting jobs" value={pro.profile.isAcceptingJobs ? 'Yes' : 'No'} />
<Row label="Hourly rate" value={`${(pro.profile.hourlyRateCents / 100).toFixed(0)}`} /> <Row label="Hourly rate" value={`$${(pro.profile.hourlyRateCents / 100).toFixed(0)}`} />
<Row label="Experience" value={`${pro.profile.yearsExperience} years`} /> <Row label="Experience" value={`${pro.profile.yearsExperience} years`} />
<Row <Row
label="Service area" label="Service area"
+1 -1
View File
@@ -39,7 +39,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<header className="sticky top-0 z-10 border-b border-hairline bg-page/95 backdrop-blur-[12px]"> <header className="sticky top-0 z-10 border-b border-hairline bg-page/95 backdrop-blur-[12px]">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-4"> <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-4">
<Link href="/admin" className="font-display text-h4 text-strong"> <Link href="/admin" className="font-display text-h4 text-strong">
Linkder admin Linkdr admin
</Link> </Link>
<span className="text-meta text-faint"> <span className="text-meta text-faint">
Signed in as {me.name ?? me.email ?? 'admin'} Signed in as {me.name ?? me.email ?? 'admin'}
+2 -2
View File
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from '@linkder/api'; import { appRouter, createContext } from '@linkdr/api';
import { db } from '@linkder/db'; import { db } from '@linkdr/db';
import { resolveSession } from '@/server/session'; import { resolveSession } from '@/server/session';
/** /**
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { Deck } from '@/components/deck'; import { Deck } from '@/components/deck';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
+2 -2
View File
@@ -182,14 +182,14 @@ export function NewJobForm({
<FieldSet label="Budget (optional)"> <FieldSet label="Budget (optional)">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="From "> <Field label="From $">
<Input <Input
value={budgetMin} value={budgetMin}
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))} onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal" inputMode="decimal"
/> />
</Field> </Field>
<Field label="To "> <Field label="To $">
<Input <Input
value={budgetMax} value={budgetMax}
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))} onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
+27 -6
View File
@@ -3,6 +3,7 @@ import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
import { TRPCProvider } from '@/lib/trpc'; import { TRPCProvider } from '@/lib/trpc';
import { ToastProvider } from '@/components/ui'; import { ToastProvider } from '@/components/ui';
import { PhoneFrame } from '@/components/chrome/phone-frame'; import { PhoneFrame } from '@/components/chrome/phone-frame';
import { ProjectPanel } from '@/components/chrome/project-panel';
import '@/styles/globals.css'; import '@/styles/globals.css';
/** /**
@@ -24,12 +25,12 @@ const text = Wix_Madefor_Text({
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: 'Linkder — hire a verified local pro', default: 'Linkdr — hire a verified local pro',
template: '%s · Linkder', template: '%s · Linkdr',
}, },
description: description:
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.', 'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.',
appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkder' }, appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkdr' },
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
@@ -57,13 +58,33 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<TRPCProvider> <TRPCProvider>
<ToastProvider> <ToastProvider>
{/* {/*
Two columns: the product on the left, what it is on the right.
The phone lives HERE, not in a page, so that every route renders The phone lives HERE, not in a page, so that every route renders
inside the screen. Putting it in one page meant sign-in, onboarding inside the screen — sign-in, onboarding and the job form included.
and the job form all escaped the frame. That is also what lets somebody use the WHOLE app inside the left
column without ever leaving it, while the panel beside it stays put.
Below `lg` the panel drops underneath rather than squashing beside,
and on a handset PhoneFrame collapses its bezel so the app simply
fills the viewport with the panel below the fold.
*/} */}
<div className="flex min-h-dvh w-full items-center justify-center overflow-hidden sm:p-8"> <div className="flex min-h-dvh w-full flex-col lg:flex-row lg:items-start">
<div
className={
'flex w-full shrink-0 justify-center sm:p-8 ' +
// Pinned beside the panel: the client keeps swiping while they
// read, so the phone must not scroll away with the text.
'lg:sticky lg:top-0 lg:h-dvh lg:w-auto lg:items-center'
}
>
<PhoneFrame>{children}</PhoneFrame> <PhoneFrame>{children}</PhoneFrame>
</div> </div>
<div className="min-w-0 flex-1 bg-page">
<ProjectPanel />
</div>
</div>
</ToastProvider> </ToastProvider>
</TRPCProvider> </TRPCProvider>
</body> </body>
+1 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic';
* The entry screen. * The entry screen.
* *
* Not a marketing page. This is the product itself, running: a phone with a * Not a marketing page. This is the product itself, running: a phone with a
* live, draggable deck of real verified pros inside it. Linkder's whole promise * live, draggable deck of real verified pros inside it. Linkdr's whole promise
* is a gesture, and a paragraph describing a gesture is worth nothing next to * is a gesture, and a paragraph describing a gesture is worth nothing next to
* being able to do it. * being able to do it.
* *
+3 -3
View File
@@ -9,7 +9,7 @@ import {
DEFAULT_SERVICE_RADIUS_M, DEFAULT_SERVICE_RADIUS_M,
MAX_SERVICE_RADIUS_M, MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M,
} from '@linkder/shared'; } from '@linkdr/shared';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { uploadFile } from '@/lib/upload'; import { uploadFile } from '@/lib/upload';
import { import {
@@ -167,7 +167,7 @@ export function OnboardingWizard({
{step === 1 && ( {step === 1 && (
<Section title="About you" hint="This is what a customer reads on your card."> <Section title="About you" hint="This is what a customer reads on your card.">
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona"> <Field label="Headline" hint="e.g. Emergency plumber, 15 years in CDMX">
<Input <Input
value={headline} value={headline}
onChange={(e) => setHeadline(e.target.value)} onChange={(e) => setHeadline(e.target.value)}
@@ -186,7 +186,7 @@ export function OnboardingWizard({
</Field> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Hourly rate ()"> <Field label="Hourly rate ($)">
<Input <Input
value={hourlyRate} value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))} onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
+1 -1
View File
@@ -64,7 +64,7 @@ export default async function ProHomePage() {
} }
/> />
<Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} /> <Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
<Stat label="Rate" value={`${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} /> <Stat label="Rate" value={`$${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
</dl> </dl>
</AppShell> </AppShell>
); );
+1 -1
View File
@@ -176,7 +176,7 @@ function ProProfile() {
value={String(p.media.length)} value={String(p.media.length)}
/> />
<SettingsRow label="Trades" value={String(p.categoryIds.length)} /> <SettingsRow label="Trades" value={String(p.categoryIds.length)} />
<SettingsRow label="Hourly rate" value={`${(p.hourlyRateCents / 100).toFixed(0)}`} /> <SettingsRow label="Hourly rate" value={`$${(p.hourlyRateCents / 100).toFixed(0)}`} />
<SettingsRow <SettingsRow
label="Service area" label="Service area"
value={`${Math.round(p.serviceRadiusM / 1000)} km`} value={`${Math.round(p.serviceRadiusM / 1000)} km`}
+2 -2
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { useDebouncedValue } from '@/lib/use-debounced-value'; import { useDebouncedValue } from '@/lib/use-debounced-value';
import { EmptyState } from '@/components/ui'; import { EmptyState } from '@/components/ui';
+56 -53
View File
@@ -1,11 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { SettingsGroup, SettingsRow, SettingsToggle, useToast } from '@/components/ui';
import { SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
import { api, type RouterOutputs } from '@/lib/trpc'; import { api, type RouterOutputs } from '@/lib/trpc';
import { SignedOut } from '@/components/chrome/signed-out'; import { SignedOut } from '@/components/chrome/signed-out';
import { LocationGroup } from '@/components/settings/location-group'; import { LocationGroup } from '@/components/settings/location-group';
import { authClient } from '@/lib/auth-client'; import { AccountSection } from '@/components/settings/account-section';
import { AccountFooter } from '@/components/settings/account-footer';
import type { PhoneTab } from '@/components/chrome/phone-tabs';
/** /**
* The Settings tab. * The Settings tab.
@@ -13,11 +14,15 @@ import { authClient } from '@/lib/auth-client';
* Signed-in only. An anonymous visitor gets a sign-in prompt rather than a * Signed-in only. An anonymous visitor gets a sign-in prompt rather than a
* disabled tab, so the bar does not look dead on first open. * disabled tab, so the bar does not look dead on first open.
*/ */
export function SettingsPanel() { export function SettingsPanel({ onNavigate }: { onNavigate?: (tab: PhoneTab) => void }) {
const me = api.user.me.useQuery(undefined, { retry: false }); const me = api.user.me.useQuery(undefined, { retry: false });
if (me.isLoading) { if (me.isLoading) {
return <PanelShell><div className="h-40 animate-pulse rounded-card bg-inset" /></PanelShell>; return (
<PanelShell>
<LoadingSkeleton />
</PanelShell>
);
} }
if (me.error || !me.data) { if (me.error || !me.data) {
return ( return (
@@ -28,22 +33,37 @@ export function SettingsPanel() {
); );
} }
return <SignedIn me={me.data} />; return <SignedIn me={me.data} onNavigate={onNavigate} />;
} }
function PanelShell({ children }: { children: React.ReactNode }) { function PanelShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-[3.25rem] pb-4"> <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-[3.25rem] pb-8">
<h1 className="mb-5 text-h2">Settings</h1> <h1 className="mb-5 text-h2">Settings</h1>
{children} {children}
</div> </div>
); );
} }
/**
* §6.11. Shaped like what it is waiting for — an identity card and two groups —
* rather than one grey slab, so the layout does not jump when the data lands.
*/
function LoadingSkeleton() {
return (
<div aria-busy className="flex flex-col gap-8">
<div className="h-[5.5rem] animate-pulse rounded-card bg-inset" />
<div className="h-32 animate-pulse rounded-card bg-inset" />
<div className="h-44 animate-pulse rounded-card bg-inset" />
</div>
);
}
type Me = RouterOutputs['user']['me']; type Me = RouterOutputs['user']['me'];
function SignedIn({ me }: { me: Me }) { function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) => void }) {
const utils = api.useUtils(); const utils = api.useUtils();
const toast = useToast();
const prefs = api.notification.get.useQuery(); const prefs = api.notification.get.useQuery();
const updatePrefs = api.notification.update.useMutation({ const updatePrefs = api.notification.update.useMutation({
onMutate: async (next) => { onMutate: async (next) => {
@@ -55,34 +75,21 @@ function SignedIn({ me }: { me: Me }) {
}, },
onError: (_e, _next, context) => { onError: (_e, _next, context) => {
if (context?.previous) utils.notification.get.setData(undefined, context.previous); if (context?.previous) utils.notification.get.setData(undefined, context.previous);
// Without this the switch just slides back under the thumb, which reads as
// the tap not registering rather than as the save failing.
toast('That did not save. Check your connection and try again.', { tone: 'error' });
}, },
onSettled: () => void utils.notification.get.invalidate(), onSettled: () => void utils.notification.get.invalidate(),
}); });
const sessions = api.user.sessions.useQuery(); const sessions = api.user.sessions.useQuery();
const requestDeletion = api.user.requestDeletion.useMutation();
const [deletionAsked, setDeletionAsked] = useState(false);
const p = prefs.data; const p = prefs.data;
const isPro = me.role === 'pro'; const isPro = me.role === 'pro';
return ( return (
<PanelShell> <PanelShell>
<SettingsGroup title="Account"> <AccountSection me={me} />
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
<SettingsRow
label="Email"
value={me.hasContactableEmail ? me.email : 'Add an email'}
hint={me.hasContactableEmail ? undefined : 'Needed for receipts and payout statements'}
/>
{/*
Read-only by necessity, not by choice: better-auth's phoneNumber plugin
rejects any update carrying a phone, and the number is the login
credential and the unique key.
*/}
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} hint="Contact support to change" />
<SettingsRow label="Account type" value={isPro ? 'Professional' : 'Customer'} />
</SettingsGroup>
<LocationGroup isPro={isPro} /> <LocationGroup isPro={isPro} />
@@ -119,8 +126,21 @@ function SignedIn({ me }: { me: Me }) {
</SettingsGroup> </SettingsGroup>
{isPro && ( {isPro && (
<SettingsGroup title="Working" note="Changing your trades sends your profile back for review."> <SettingsGroup
<SettingsRow label="Trades" hint="Edit in your profile" onClick={() => {}} /> title="Working"
note="Changing your trades sends your profile back for review."
>
{/*
The chevron is conditional on the callback because the tab is owned
by the shell above this panel. Rendered without one — in a test, or
anywhere this panel is mounted alone — it stays a plain fact instead
of a button that goes nowhere.
*/}
<SettingsRow
label="Trades"
hint="Edit in your profile"
onClick={onNavigate ? () => onNavigate('profile') : undefined}
/>
</SettingsGroup> </SettingsGroup>
)} )}
@@ -139,8 +159,13 @@ function SignedIn({ me }: { me: Me }) {
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Legal and data"> <SettingsGroup title="Legal and data">
<SettingsRow label="Terms of service" onClick={() => {}} /> {/*
<SettingsRow label="Privacy policy" onClick={() => {}} /> No onClick, so no chevron. These two documents do not exist yet, and a
row that opens nothing is worse than a row that says so — it is
indistinguishable from a link that is broken.
*/}
<SettingsRow label="Terms of service" hint="Published before launch" />
<SettingsRow label="Privacy policy" hint="Published before launch" />
<SettingsRow <SettingsRow
label="Download my data" label="Download my data"
hint="Everything we hold about you, as JSON" hint="Everything we hold about you, as JSON"
@@ -150,36 +175,14 @@ function SignedIn({ me }: { me: Me }) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'linkder-data.json'; a.download = 'linkdr-data.json';
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}} }}
/> />
<SettingsRow
label={deletionAsked ? 'Deletion requested' : 'Delete my account'}
hint={
deletionAsked
? 'We will action this within 30 days'
: 'We action requests within 30 days'
}
danger
disabled={deletionAsked || requestDeletion.isPending}
onClick={() => {
requestDeletion.mutate({}, { onSuccess: () => setDeletionAsked(true) });
}}
/>
</SettingsGroup> </SettingsGroup>
<button <AccountFooter />
type="button"
onClick={async () => {
await authClient.signOut();
window.location.href = '/';
}}
className={buttonClasses({ variant: 'outline', size: 'md', block: true })}
>
Sign out
</button>
</PanelShell> </PanelShell>
); );
} }
+12 -3
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { Deck, type SwipeVerdict } from '@/components/deck'; import { Deck, type SwipeVerdict } from '@/components/deck';
import { Chip, ScrollStrip } from '@/components/ui'; import { Chip, ScrollStrip } from '@/components/ui';
import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs'; import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs';
@@ -167,7 +167,7 @@ export function ShowcaseDeck({
return ( return (
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden"> <div className="flex h-full w-full min-w-0 flex-col overflow-hidden">
{tab === 'settings' ? ( {tab === 'settings' ? (
<SettingsPanel /> <SettingsPanel onNavigate={setTab} />
) : tab === 'profile' ? ( ) : tab === 'profile' ? (
<ProfilePanel /> <ProfilePanel />
) : tab === 'search' ? ( ) : tab === 'search' ? (
@@ -251,7 +251,16 @@ export function ShowcaseDeck({
{/* Inside the phone frame, not the page — the sheet belongs to this {/* Inside the phone frame, not the page — the sheet belongs to this
screen and must not cover the browser chrome around the mock. */} screen and must not cover the browser chrome around the mock. */}
<SendJobSheet pro={hiring} open={hiring !== null} onResolved={onResolved} /> <SendJobSheet
pro={hiring}
open={hiring !== null}
onResolved={onResolved}
// Straight to the job they just sent, on the Current segment.
onViewJobs={() => {
setJobs({ ...jobs, segment: 'current', view: { kind: 'list' } });
setTab('jobs');
}}
/>
<AskSheet <AskSheet
pro={asking} pro={asking}
+1 -1
View File
@@ -7,7 +7,7 @@ export const metadata = { title: 'Sign in' };
export default function SignInPage() { export default function SignInPage() {
return ( return (
<BareShell back> <BareShell back>
<p className="text-overline uppercase text-accent">Linkder</p> <p className="text-overline uppercase text-accent">Linkdr</p>
<h1 className="mt-3 text-h1">Sign in</h1> <h1 className="mt-3 text-h1">Sign in</h1>
<p className="mt-3 text-body text-muted"> <p className="mt-3 text-body text-muted">
We will text you a 6-digit code. No password to forget. We will text you a 6-digit code. No password to forget.
+1 -1
View File
@@ -69,7 +69,7 @@ export function SignInForm() {
autoComplete="tel" autoComplete="tel"
inputMode="tel" inputMode="tel"
required required
placeholder="+34 600 123 456" placeholder="+52 55 1234 5678"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))} onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
/> />
@@ -0,0 +1,636 @@
'use client';
import { useState } from 'react';
import {
BadgeCheck,
CalendarCheck,
Check,
Copy,
MapPin,
MessagesSquare,
Star,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* The right-hand column: what the app in the phone beside it actually is.
*
* Static on purpose. The client reads this while USING the product in the left
* column, so nothing here navigates, nothing here is a screenshot, and nothing
* here moves when they swipe.
*
* Content lives in the arrays below rather than in the markup, so adding a
* feature or swapping a dependency is one line and the layout is untouched.
*
* Both languages live in the SAME entry rather than in two parallel documents —
* a decision and its `today` line have to move together, and the fastest way to
* end up with a Spanish half-truth is to let two copies of this file drift.
*/
type Lang = 'en' | 'es';
/** One string in both languages. Everything the client reads is one of these. */
type Copy = { en: string; es: string };
const LANGS: { code: Lang; label: string }[] = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
];
const DEMO = { phone: '+525500000000', code: '000000' };
const UI = {
language: { en: 'Language', es: 'Idioma' },
copy: { en: 'Copy', es: 'Copiar' },
copied: { en: 'copied', es: 'copiado' },
today: { en: 'Today: ', es: 'Hoy: ' },
} satisfies Record<string, Copy>;
const INTRO = {
title: {
en: 'Hire a tradesperson the way you swipe',
es: 'Contrata a un profesional deslizando',
},
body: {
en: 'A mobile marketplace connecting customers with verified local trades. Post a job, swipe through pros who cover your street, agree a price in chat, book the slot and review each other afterwards.',
es: 'Un marketplace móvil que conecta a clientes con profesionales locales verificados. Publica un trabajo, desliza entre los profesionales que cubren tu calle, acuerda un precio en el chat, reserva la cita y valoraos después.',
},
} satisfies Record<string, Copy>;
const TRY = {
heading: { en: 'Try it yourself', es: 'Pruébalo tú mismo' },
body: {
en: 'Sign in on the phone to the left. The whole product runs in there.',
es: 'Inicia sesión en el móvil de al lado. El producto entero funciona ahí dentro.',
},
phone: { en: 'Mobile', es: 'Móvil' },
code: { en: 'Code', es: 'Código' },
} satisfies Record<string, Copy>;
const STACK: { group: Copy; items: string[] }[] = [
{
group: { en: 'App', es: 'App' },
items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'],
},
{ group: { en: 'API', es: 'API' }, items: ['tRPC v11', 'Zod', 'better-auth'] },
{
group: { en: 'Data', es: 'Datos' },
items: ['DO Managed Postgres', 'PostGIS', 'Drizzle ORM', 'DO Managed Redis'],
},
{
group: { en: 'Services', es: 'Servicios' },
items: ['DO Spaces (S3)', 'Mapbox', 'Twilio', 'Resend', 'Sentry'],
},
{ group: { en: 'Tooling', es: 'Herramientas' }, items: ['Turborepo', 'pnpm', 'Vitest'] },
];
const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [
{
icon: Zap,
title: { en: 'Swipe to hire', es: 'Desliza para contratar' },
body: { en: 'Send a job with one gesture.', es: 'Envía un trabajo con un solo gesto.' },
},
{
icon: MapPin,
title: { en: 'Real distance', es: 'Distancia real' },
body: {
en: 'PostGIS ranks by metres, not postcodes.',
es: 'PostGIS ordena por metros, no por códigos postales.',
},
},
{
icon: BadgeCheck,
title: { en: 'Verified pros', es: 'Profesionales verificados' },
body: {
en: 'ID, insurance and licence checked first.',
es: 'Identidad, seguro y licencia comprobados antes de entrar.',
},
},
{
icon: MessagesSquare,
title: { en: 'Chat per job', es: 'Un chat por trabajo' },
body: { en: 'Private, with photos and receipts.', es: 'Privado, con fotos y recibos.' },
},
{
icon: CalendarCheck,
title: { en: 'Quote to booking', es: 'Del presupuesto a la reserva' },
body: {
en: 'Agree a price, book the slot, confirm.',
es: 'Acordáis un precio, se reserva la cita y se confirma.',
},
},
{
icon: Star,
title: { en: 'Blind reviews', es: 'Valoraciones a ciegas' },
body: {
en: 'Hidden until both sides have written.',
es: 'Ocultas hasta que ambas partes han escrito.',
},
},
];
/**
* The open questions, grouped.
*
* Every one of these has a CURRENT behaviour — nothing here is unbuilt because
* it was forgotten. `today` says what happens if nobody decides, which is the
* only honest way to present a decision: the client is confirming or changing
* something, not filling in a blank.
*/
const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [
{
group: { en: 'Money', es: 'Dinero' },
items: [
{
q: {
en: 'Do we hold the money until the job is done, or do customers pay the pro directly?',
es: '¿Retenemos el dinero hasta que el trabajo esté hecho, o el cliente paga directamente al profesional?',
},
today: {
en: 'Nothing is charged. Quotes and bookings work; no payment is taken at any point.',
es: 'No se cobra nada. Los presupuestos y las reservas funcionan; no se cobra en ningún momento.',
},
},
{
q: {
en: 'What is the commission, and who pays it — the customer, the pro, or split?',
es: '¿Cuál es la comisión y quién la paga: el cliente, el profesional o a medias?',
},
today: {
en: 'Set to 15% in config, applied nowhere.',
es: 'Fijada al 15% en la configuración, aplicada en ninguna parte.',
},
},
{
q: {
en: 'Deposit up front, or the whole amount on completion?',
es: '¿Señal por adelantado o el importe completo al terminar?',
},
today: {
en: 'Neither. The slot is booked on a promise.',
es: 'Ninguna de las dos. La cita se reserva con una promesa.',
},
},
{
q: {
en: 'A customer cancels the day before — what do they owe?',
es: 'Un cliente cancela el día antes: ¿qué debe pagar?',
},
today: {
en: 'Free up to 24h before, then 25%. The rule is written and tested; no money moves.',
es: 'Gratis hasta 24 h antes, después el 25%. La regla está escrita y probada; no se mueve dinero.',
},
},
],
},
{
group: { en: 'Scheduling', es: 'Agenda' },
items: [
{
q: {
en: 'Do pros publish real availability, or is a time agreed in the chat?',
es: '¿Los profesionales publican disponibilidad real, o se acuerda la hora en el chat?',
},
today: {
en: 'Agreed in chat. The customer picks any date and time when accepting a quote.',
es: 'Se acuerda en el chat. El cliente elige cualquier fecha y hora al aceptar un presupuesto.',
},
},
{
q: {
en: 'Should the system stop a pro being double-booked?',
es: '¿Debe el sistema impedir que un profesional tenga dos reservas a la vez?',
},
today: {
en: 'No check. Two customers can book the same pro for the same hour.',
es: 'No hay ninguna comprobación. Dos clientes pueden reservar al mismo profesional a la misma hora.',
},
},
{
q: {
en: 'If a customer never confirms the work is finished, should it auto-confirm?',
es: 'Si el cliente nunca confirma que el trabajo está terminado, ¿debe confirmarse solo?',
},
today: {
en: 'It waits forever. A 72-hour rule is written but nothing runs it.',
es: 'Espera para siempre. Hay una regla de 72 horas escrita, pero nada la ejecuta.',
},
},
],
},
{
group: { en: 'Trust and safety', es: 'Confianza y seguridad' },
items: [
{
q: {
en: 'Should we block phone numbers and emails in chat?',
es: '¿Bloqueamos teléfonos y correos en el chat?',
},
today: {
en: 'Anything can be sent. Two people can agree to take the job off the platform.',
es: 'Se puede enviar cualquier cosa. Dos personas pueden acordar sacar el trabajo de la plataforma.',
},
},
{
q: {
en: 'What happens when the two sides disagree about finished work?',
es: '¿Qué pasa cuando las dos partes no se ponen de acuerdo sobre un trabajo terminado?',
},
today: {
en: 'A disputed state exists in the model. Nothing can reach it.',
es: 'Existe un estado «en disputa» en el modelo. Nada puede llegar a él.',
},
},
{
q: {
en: 'ID checks — automated, or a person reviewing documents?',
es: 'Verificación de identidad: ¿automática o revisada por una persona?',
},
today: {
en: 'A person. Documents are uploaded and reviewed by hand in the admin queue.',
es: 'Una persona. Los documentos se suben y se revisan a mano en la cola de administración.',
},
},
{
q: {
en: 'A pros insurance expires. Do they come off the platform automatically?',
es: 'El seguro de un profesional caduca. ¿Sale de la plataforma automáticamente?',
},
today: {
en: 'The expiry date is stored. Nothing checks it.',
es: 'La fecha de caducidad se guarda. Nada la comprueba.',
},
},
],
},
{
group: { en: 'Launch', es: 'Lanzamiento' },
items: [
{
q: {
en: 'Launch with the trades we have supply for, or all fifty?',
es: '¿Lanzamos con los oficios para los que hay oferta, o con los cincuenta?',
},
today: {
en: 'Fifty trades listed; eight have any pros. The rest look empty to a customer.',
es: 'Hay cincuenta oficios listados; ocho tienen profesionales. El resto se ven vacíos para un cliente.',
},
},
{
q: {
en: 'One city, or several from the start?',
es: '¿Una ciudad o varias desde el principio?',
},
today: {
en: 'One. The city is a setting, so a second is configuration rather than a rebuild.',
es: 'Una. La ciudad es un ajuste, así que una segunda es configuración, no rehacer nada.',
},
},
{
q: {
en: 'Which events are worth an SMS, given each one costs money?',
es: '¿Qué eventos merecen un SMS, teniendo en cuenta que cada uno cuesta dinero?',
},
today: {
en: 'A pro is texted about a new job and an answer. Messages and bookings are silent.',
es: 'Al profesional se le avisa por SMS de un trabajo nuevo y de una respuesta. Los mensajes y las reservas son silenciosos.',
},
},
],
},
];
/**
* Running costs, paid to DigitalOcean rather than to us.
*
* Listed per line rather than as one number because they scale independently —
* the database is the first thing that needs a bigger tier, and storage is the
* only one that grows with use.
*/
const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [
{
item: { en: 'Managed Postgres', es: 'Postgres gestionado' },
detail: { en: 'The database, with PostGIS', es: 'La base de datos, con PostGIS' },
usd: 15,
},
{
item: { en: 'Managed Redis', es: 'Redis gestionado' },
detail: { en: 'Sessions, caching, job queue', es: 'Sesiones, caché y cola de trabajos' },
usd: 15,
},
{
item: { en: 'App Platform', es: 'App Platform' },
detail: { en: 'Runs the app itself', es: 'Ejecuta la propia aplicación' },
usd: 24,
},
{
item: { en: 'Spaces', es: 'Spaces' },
detail: { en: 'Photos and documents', es: 'Fotos y documentos' },
usd: 5,
},
];
const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0);
const SECTIONS = {
features: { en: 'What it does', es: 'Qué hace' },
stack: { en: 'Built with', es: 'Hecho con' },
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.',
es: 'Todo lo de abajo ya tiene un comportamiento. Estas son las decisiones que conviene tomar a propósito en lugar de heredarlas.',
},
cost: { en: 'Delivery and cost', es: 'Entrega y coste' },
build: { en: 'To build and launch', es: 'Construirlo y lanzarlo' },
buildNote: {
en: 'One-off. Where it lands depends on the answers above.',
es: 'Pago único. Dónde caiga depende de las respuestas de arriba.',
},
timeline: { en: 'Timeline', es: 'Plazo' },
timelineValue: { en: '610 weeks', es: '610 semanas' },
timelineNote: {
en: 'Six if the open questions are settled early, ten if they are not.',
es: 'Seis si las preguntas abiertas se cierran pronto, diez si no.',
},
hosting: { en: 'Hosting, per month', es: 'Alojamiento, al mes' },
total: { en: 'Total', es: 'Total' },
perMonth: { en: '/mo', es: '/mes' },
} satisfies Record<string, Copy>;
/** The three caveats under the hosting table — lead sentence, then the rest. */
const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [
{
lead: { en: 'You pay DigitalOcean directly.', es: 'Pagas directamente a DigitalOcean.' },
rest: {
en: ' This is not part of our fee and there is no markup on it — the account is yours, so you can see the bill and change the plan without going through us.',
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: `$${HOSTING_TOTAL} is the smallest tier of each.`,
es: `${HOSTING_TOTAL} $ es el plan más pequeño de cada uno.`,
},
rest: {
en: ' Enough to launch on and to run while the platform is finding its first customers.',
es: ' Suficiente para lanzar y para funcionar mientras la plataforma consigue sus primeros clientes.',
},
},
{
lead: {
en: 'Costs rise with use, unevenly.',
es: 'Los costes suben con el uso, de forma desigual.',
},
rest: {
en: ' The database is the first thing that will need a larger plan; storage creeps up slowly as photos accumulate; the app itself can stay where it is for a long time.',
es: ' La base de datos es lo primero que necesitará un plan mayor; el almacenamiento crece despacio a medida que se acumulan fotos; la aplicación en sí puede quedarse donde está mucho tiempo.',
},
},
];
export function ProjectPanel() {
const [lang, setLang] = useState<Lang>('en');
const t = (copy: Copy) => copy[lang];
return (
// `lang` on the wrapper, not only in state: it is what tells a screen reader
// which voice to read this in and a browser which dictionary to hyphenate by.
<div lang={lang} className="flex flex-col gap-10 px-6 py-10 lg:px-12 lg:py-14">
{/* Above the title it changes, so the client sees the switch before they
have started reading. Right-aligned: it is a control on the panel, not
a heading of it, and the title keeps the left edge to itself. */}
<div className="flex justify-end">
<div
role="group"
aria-label={t(UI.language)}
className="inline-flex gap-0.5 rounded-pill border border-hairline bg-raised p-1"
>
{LANGS.map(({ code, label }) => (
<button
key={code}
type="button"
lang={code}
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={cn(
'rounded-pill px-3 py-1 text-meta',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
lang === code
? 'bg-accent-soft font-semibold text-accent'
: 'text-muted hover:text-strong',
)}
>
{label}
</button>
))}
</div>
</div>
<header>
<p className="text-overline uppercase text-accent">Linkdr</p>
<h1 className="mt-2 text-h1">{t(INTRO.title)}</h1>
<p className="mt-3 max-w-[60ch] text-body text-muted text-pretty">{t(INTRO.body)}</p>
</header>
<section>
<h2 className="mb-2 text-h3">{t(TRY.heading)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(TRY.body)}</p>
{/* Side by side: two short values do not need two full-width rows.
The code column is content-width so the number keeps the room. */}
<div className="flex max-w-lg flex-wrap gap-2">
<CopyRow
label={t(TRY.phone)}
value={DEMO.phone}
copyLabel={t(UI.copy)}
copiedLabel={t(UI.copied)}
className="min-w-56 flex-1"
/>
<CopyRow
label={t(TRY.code)}
value={DEMO.code}
copyLabel={t(UI.copy)}
copiedLabel={t(UI.copied)}
className="shrink-0"
/>
</div>
</section>
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.features)}</h2>
{/* Tight two-column grid: small icon, title and its line on one row.
Read standing up, mid-sentence — so it has to scan, not be read. */}
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{FEATURES.map((f) => (
// Keyed on the English string throughout: the key has to survive the
// toggle, or React remounts every row on a language change.
<div key={f.title.en} className="flex items-start gap-2.5">
<f.icon className="mt-0.5 h-4 w-4 shrink-0 text-accent" aria-hidden />
<span className="min-w-0">
<dt className="inline font-semibold text-body-sm text-strong">{t(f.title)}</dt>
<dd className="inline text-body-sm text-muted"> {t(f.body)}</dd>
</span>
</div>
))}
</dl>
</section>
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.stack)}</h2>
<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">
{/* w-20, not w-16: "Herramientas" is twice the width of "Tooling". */}
<span className="w-20 shrink-0 text-meta text-faint">{t(row.group)}</span>
{row.items.map((item) => (
<span
key={item}
className="rounded-pill border border-hairline px-3 py-1 text-meta text-strong"
>
{item}
</span>
))}
</div>
))}
</div>
</section>
<section>
<h2 className="mb-1 text-h3">{t(SECTIONS.decisions)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(SECTIONS.decisionsBody)}</p>
<div className="flex flex-col gap-6">
{DECISIONS.map((section) => (
<div key={section.group.en}>
<h3 className="mb-2 text-overline uppercase text-faint">{t(section.group)}</h3>
<ul className="flex flex-col gap-3">
{section.items.map((item) => (
<li key={item.q.en} className="border-l-2 border-hairline pl-3">
<p className="text-body-sm font-semibold text-strong text-pretty">
{t(item.q)}
</p>
<p className="mt-0.5 text-meta text-muted text-pretty">
<span className="text-faint">{t(UI.today)}</span>
{t(item.today)}
</p>
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* Last, and deliberately after the decisions: the range IS the answer to
those questions, so quoting a single number above them would be a
promise made before the scope exists. */}
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.cost)}</h2>
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
<p className="text-meta text-faint">{t(SECTIONS.build)}</p>
<p className="mt-1 font-display text-h2 text-strong tabular-nums">$4,4006,000</p>
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.buildNote)}</p>
</div>
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
<p className="text-meta text-faint">{t(SECTIONS.timeline)}</p>
<p className="mt-1 font-display text-h2 text-strong tabular-nums">
{t(SECTIONS.timelineValue)}
</p>
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.timelineNote)}</p>
</div>
</div>
<h3 className="mb-2 text-overline uppercase text-faint">{t(SECTIONS.hosting)}</h3>
<ul className="flex flex-col gap-1.5">
{HOSTING.map((h) => (
<li key={h.item.en} className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(h.item)}</span>
<span className="min-w-0 flex-1 truncate text-meta text-muted">{t(h.detail)}</span>
<span className="shrink-0 text-strong tabular-nums">${h.usd}</span>
</li>
))}
<li className="mt-1.5 flex items-baseline gap-3 border-t border-hairline pt-2 text-body-sm">
<span className="flex-1 font-semibold text-strong">{t(SECTIONS.total)}</span>
<span className="shrink-0 font-display text-h4 text-strong tabular-nums">
${HOSTING_TOTAL}
{t(SECTIONS.perMonth)}
</span>
</li>
</ul>
<div className="mt-3 flex max-w-[60ch] flex-col gap-1.5 text-meta text-muted">
{HOSTING_NOTES.map((note) => (
<p key={note.lead.en}>
<span className="font-semibold text-strong">{t(note.lead)}</span>
{t(note.rest)}
</p>
))}
</div>
</section>
</div>
);
}
/**
* A value to hand over verbatim, with one tap to copy.
*
* Reading a phone number off a screen into a form while somebody watches is a
* small humiliation; mistyping one in front of a client is a worse one.
*/
function CopyRow({
label,
value,
copyLabel,
copiedLabel,
className,
}: {
label: string;
value: string;
copyLabel: string;
copiedLabel: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-hairline bg-raised px-4 py-2.5',
className,
)}
>
<span className="shrink-0 text-meta text-faint">{label}</span>
<code className="min-w-0 flex-1 truncate font-mono text-body-sm text-strong tabular-nums">
{value}
</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard can be refused (insecure origin). The value is on screen
// and readable, which is the fallback that always works.
}
}}
// The state is in the accessible name too, not only the icon — §8.
aria-label={copied ? `${label} ${copiedLabel}` : `${copyLabel} ${label.toLowerCase()}`}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
copied ? 'text-go-600' : 'text-muted hover:bg-inset hover:text-accent',
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react'; import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react'; import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { cn, formatDistance, formatResponseTime } from '@/lib/utils'; import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
/** Horizontal drag past this many pixels commits the swipe. */ /** Horizontal drag past this many pixels commits the swipe. */
@@ -264,7 +264,7 @@ export function Card({
<MapPin className="h-4 w-4" aria-hidden /> <MapPin className="h-4 w-4" aria-hidden />
{formatDistance(card.distanceM)} {formatDistance(card.distanceM)}
</span> </span>
<span>{(card.hourlyRateCents / 100).toFixed(0)}/hr</span> <span>${(card.hourlyRateCents / 100).toFixed(0)}/hr</span>
{card.completedJobs > 0 && <span>{card.completedJobs} jobs done</span>} {card.completedJobs > 0 && <span>{card.completedJobs} jobs done</span>}
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { Banner, Button, Sheet, Textarea } from '@/components/ui'; import { Banner, Button, Sheet, Textarea } from '@/components/ui';
import { setPendingHire } from '@/lib/pending-hire'; import { setPendingHire } from '@/lib/pending-hire';
@@ -2,8 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AlertTriangle, Check } from 'lucide-react'; import { AlertTriangle, Check, CheckCircle2 } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { SocialSignIn } from '@/components/auth/social-sign-in'; import { SocialSignIn } from '@/components/auth/social-sign-in';
import { Banner, Button, Sheet } from '@/components/ui'; import { Banner, Button, Sheet } from '@/components/ui';
@@ -22,6 +22,7 @@ export function SendJobSheet({
pro, pro,
open, open,
onResolved, onResolved,
onViewJobs,
}: { }: {
pro: DeckCard | null; pro: DeckCard | null;
open: boolean; open: boolean;
@@ -30,9 +31,20 @@ export function SendJobSheet({
* `dismissed` when nothing happened and the card should come back. * `dismissed` when nothing happened and the card should come back.
*/ */
onResolved: (outcome: 'sent' | 'dismissed') => void; onResolved: (outcome: 'sent' | 'dismissed') => void;
/** Take them to the conversation they just started. */
onViewJobs: () => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const [chosen, setChosen] = useState<string | null>(null); const [chosen, setChosen] = useState<string | null>(null);
/**
* What was just sent, and how long they have to answer.
*
* The sheet used to close the instant the mutation resolved, which is
* indistinguishable from nothing happening: the card is gone, the sheet is
* gone, and the one thing the person wanted to know — did it work — is the
* one thing not on screen. Sending a job to a stranger deserves a receipt.
*/
const [sent, setSent] = useState<{ jobTitle: string; expiresInHours: number } | null>(null);
const me = api.user.me.useQuery(undefined, { retry: false }); const me = api.user.me.useQuery(undefined, { retry: false });
const sendable = api.deck.sendable.useQuery( const sendable = api.deck.sendable.useQuery(
@@ -44,10 +56,15 @@ export function SendJobSheet({
const utils = api.useUtils(); const utils = api.useUtils();
const swipe = api.deck.swipe.useMutation({ const swipe = api.deck.swipe.useMutation({
onSuccess: () => { onSuccess: (result, variables) => {
void utils.job.mine.invalidate(); void utils.job.mine.invalidate();
void utils.deck.sendable.invalidate(); void utils.deck.sendable.invalidate();
onResolved('sent');
const job = sendable.data?.jobs.find((j) => j.id === variables.jobId);
setSent({
jobTitle: job?.title ?? 'your job',
expiresInHours: result.requested ? result.expiresInHours : 0,
});
}, },
}); });
@@ -60,9 +77,58 @@ export function SendJobSheet({
const close = () => { const close = () => {
setChosen(null); setChosen(null);
swipe.reset(); swipe.reset();
onResolved('dismissed'); // A sheet dismissed AFTER a successful send must not put the card back —
// the pro really does have the job now.
onResolved(sent ? 'sent' : 'dismissed');
setSent(null);
}; };
/* ── sent ── */
if (sent) {
return (
<Sheet
open={open}
onClose={close}
title={`Sent to ${name}`}
body={sent.jobTitle}
actions={
<div className="flex flex-col gap-2">
<Button
size="lg"
block
onClick={() => {
onResolved('sent');
setSent(null);
onViewJobs();
}}
>
See it in your jobs
</Button>
<Button variant="ghost" size="sm" block onClick={close}>
Keep swiping
</Button>
</div>
}
>
<div className="flex items-start gap-3 rounded-card border border-go-100 bg-go-50 p-4">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-go-600" aria-hidden />
<div className="min-w-0 text-body-sm text-ink-800">
<p className="font-semibold text-ink-950">{name} has your job.</p>
<p className="mt-1">
{sent.expiresInHours > 0
? `They have ${sent.expiresInHours} hours to answer. If they accept, a private conversation opens and you can agree a price there.`
: 'If they accept, a private conversation opens and you can agree a price there.'}
</p>
<p className="mt-1 text-muted">
You can send the same job to other pros while you wait whoever answers first is
not automatically the one you book.
</p>
</div>
</div>
</Sheet>
);
}
/* ── anonymous ── */ /* ── anonymous ── */
if (!me.isLoading && (me.error || !me.data)) { if (!me.isLoading && (me.error || !me.data)) {
return ( return (
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { CalendarClock, CheckCircle2, FileText } from 'lucide-react'; import { CalendarClock, CheckCircle2, FileText } from 'lucide-react';
import { formatCents } from '@linkder/shared'; import { formatCents } from '@linkdr/shared';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui'; import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui';
import { cn, formatWhen } from '@/lib/utils'; import { cn, formatWhen } from '@/lib/utils';
@@ -265,7 +265,7 @@ function QuoteSheet({
} }
> >
<label className="mb-4 flex flex-col gap-2"> <label className="mb-4 flex flex-col gap-2">
<span className="text-body-sm text-strong">Price ()</span> <span className="text-body-sm text-strong">Price ($)</span>
<Input <Input
value={amount} value={amount}
inputMode="decimal" inputMode="decimal"
+1 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { CalendarClock, ChevronLeft, ChevronRight, MessageSquare, Search, Star } from 'lucide-react'; import { CalendarClock, ChevronLeft, ChevronRight, MessageSquare, Search, Star } from 'lucide-react';
import { PAST_JOB_STATUSES } from '@linkder/shared'; import { PAST_JOB_STATUSES } from '@linkdr/shared';
import { api, type RouterOutputs } from '@/lib/trpc'; import { api, type RouterOutputs } from '@/lib/trpc';
import { Banner, buttonClasses, EmptyState } from '@/components/ui'; import { Banner, buttonClasses, EmptyState } from '@/components/ui';
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils'; import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { CalendarClock, ChevronRight, MessageSquare, Users } from 'lucide-react'; import { CalendarClock, ChevronRight, MessageSquare, Users } from 'lucide-react';
import type { JobStatus } from '@linkder/shared'; import type { JobStatus } from '@linkdr/shared';
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils'; import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
export type Perspective = 'client' | 'pro'; export type Perspective = 'client' | 'pro';
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { ChevronLeft, MapPin, ShieldCheck, Star } from 'lucide-react'; import { ChevronLeft, MapPin, ShieldCheck, Star } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { Button, EmptyState, ScrollStrip, Tag } from '@/components/ui'; import { Button, EmptyState, ScrollStrip, Tag } from '@/components/ui';
import { ReviewList } from './review-list'; import { ReviewList } from './review-list';
@@ -106,7 +106,7 @@ export function ProProfilePanel({
<MapPin className="h-3.5 w-3.5" aria-hidden /> <MapPin className="h-3.5 w-3.5" aria-hidden />
{formatDistance(pro.distanceM)} {formatDistance(pro.distanceM)}
</span> </span>
<span>{((p?.hourlyRateCents ?? pro.hourlyRateCents) / 100).toFixed(0)}/hr</span> <span>${((p?.hourlyRateCents ?? pro.hourlyRateCents) / 100).toFixed(0)}/hr</span>
<span>{p?.yearsExperience ?? pro.yearsExperience} yrs experience</span> <span>{p?.yearsExperience ?? pro.yearsExperience} yrs experience</span>
{completedJobs > 0 && <span>{completedJobs} jobs done</span>} {completedJobs > 0 && <span>{completedJobs} jobs done</span>}
</div> </div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Plus, X } from 'lucide-react'; import { Plus, X } from 'lucide-react';
import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared'; import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkdr/shared';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { Button, FormError, Input, SettingsGroup } from '@/components/ui'; import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { ChevronRight, Star } from 'lucide-react'; import { ChevronRight, Star } from 'lucide-react';
import type { DeckCard } from '@linkder/db'; import type { DeckCard } from '@linkdr/db';
import { formatDistance } from '@/lib/utils'; import { formatDistance } from '@/lib/utils';
/** /**
@@ -59,7 +59,7 @@ export function ResultRow({ pro, onOpen }: { pro: DeckCard; onOpen: (proId: stri
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta text-faint tabular-nums"> <span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta text-faint tabular-nums">
<span>{formatDistance(pro.distanceM)}</span> <span>{formatDistance(pro.distanceM)}</span>
<span aria-hidden>·</span> <span aria-hidden>·</span>
<span>{(pro.hourlyRateCents / 100).toFixed(0)}/hr</span> <span>${(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
{pro.categories[0] && ( {pro.categories[0] && (
<> <>
<span aria-hidden>·</span> <span aria-hidden>·</span>
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { MAX_SEARCH_QUERY_LENGTH } from '@linkder/shared'; import { MAX_SEARCH_QUERY_LENGTH } from '@linkdr/shared';
import { Input } from '@/components/ui'; import { Input } from '@/components/ui';
/** /**
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { SlidersHorizontal } from 'lucide-react'; import { SlidersHorizontal } from 'lucide-react';
import type { SearchSort } from '@linkder/shared'; import type { SearchSort } from '@linkdr/shared';
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkder/shared'; import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkdr/shared';
import { Chip } from '@/components/ui'; import { Chip } from '@/components/ui';
import type { Category } from '@/app/showcase-deck'; import type { Category } from '@/app/showcase-deck';
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { Banner, Button, Sheet } from '@/components/ui';
import { api } from '@/lib/trpc';
import { authClient } from '@/lib/auth-client';
/**
* The two ways out, at the bottom where the thumb is. §9.
*
* Deletion used to be a red row inside "Legal and data", between the privacy
* policy and nothing — one tap, no confirmation, and styled like the two links
* above it. It is now the last thing on the screen, it is quieter than Sign out
* rather than louder, and the irreversible half happens inside a sheet, which
* is the rule §6.14 already stated and this was the case that broke it.
*/
export function AccountFooter() {
const [confirming, setConfirming] = useState(false);
const [requested, setRequested] = useState(false);
const requestDeletion = api.user.requestDeletion.useMutation({
onSuccess: () => {
setConfirming(false);
setRequested(true);
},
});
return (
<div className="mt-2 flex flex-col gap-4">
<Button
variant="outline"
size="md"
block
onClick={async () => {
await authClient.signOut();
window.location.href = '/';
}}
>
Sign out
</Button>
{requested ? (
<Banner tone="warning" role="status" title="Deletion requested">
We will action this within 30 days. Contact support if you change your mind.
</Banner>
) : (
// A text button, not a filled red one. A big red button at the end of a
// scroll is a target; this has to be looked for.
<button
type="button"
onClick={() => setConfirming(true)}
className="mx-auto h-11 rounded-pill px-4 text-body-sm text-danger hover:underline"
>
Delete my account
</button>
)}
<Sheet
open={confirming}
onClose={() => setConfirming(false)}
title="Delete your account?"
body="We action deletion requests within 30 days. Your jobs, messages and reviews go with it, and none of it can be brought back."
actions={
<div className="flex flex-col gap-2">
<Button
variant="danger"
size="lg"
block
busy={requestDeletion.isPending}
onClick={() => requestDeletion.mutate({})}
>
Request deletion
</Button>
<Button variant="ghost" size="md" block onClick={() => setConfirming(false)}>
Keep my account
</Button>
</div>
}
/>
</div>
);
}
@@ -0,0 +1,242 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import {
Button,
Field,
FormError,
Input,
SettingsGroup,
SettingsRow,
Sheet,
useToast,
} from '@/components/ui';
import { api, type RouterOutputs } from '@/lib/trpc';
/**
* Who you are, at the top of the screen that is about you.
*
* This replaces four flat rows that read "Name — Not set". Both of the things
* worth changing here already had a mutation on the server and no way in from
* the UI, so the work was never "add editing" — it was to stop the settings
* screen presenting an editable fact as a fixed one.
*/
type Me = RouterOutputs['user']['me'];
/** Month and year only. A join date is context, not a timestamp. */
const MONTH_YEAR = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' });
export function AccountSection({ me }: { me: Me }) {
const [editing, setEditing] = useState<'name' | 'email' | null>(null);
return (
<>
<IdentityCard me={me} onEdit={() => setEditing('name')} />
<SettingsGroup title="Account">
<SettingsRow
label="Email"
value={me.hasContactableEmail ? me.email : 'Not set'}
hint={me.hasContactableEmail ? undefined : 'Needed for receipts and payout statements'}
onClick={() => setEditing('email')}
/>
{/*
Read-only by necessity, not by choice: better-auth's phoneNumber plugin
rejects any update carrying a phone, and the number is the login
credential and the unique key.
*/}
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} hint="Contact support to change" />
</SettingsGroup>
<NameSheet
open={editing === 'name'}
initial={me.name ?? ''}
onClose={() => setEditing(null)}
/>
<EmailSheet open={editing === 'email'} onClose={() => setEditing(null)} />
</>
);
}
/**
* The card the screen opens on.
*
* Tappable, and the chevron says so — the name is the one thing here anybody
* actually wants to change, and burying it in a list below its own display was
* how it ended up uneditable in the first place.
*
* The role is plain text rather than a pill on purpose. §4 principle 4: a pill
* is clickable. This is a fact about the account, and dressing a fact as a
* control is the same lie the chevron rows were telling.
*/
function IdentityCard({ me, onEdit }: { me: Me; onEdit: () => void }) {
const since = MONTH_YEAR.format(me.createdAt);
const role = me.role === 'pro' ? 'Professional' : 'Customer';
return (
<button
type="button"
onClick={onEdit}
aria-label="Edit your name"
className={
'mb-8 flex w-full items-center gap-4 rounded-card border border-hairline bg-raised p-4 ' +
'text-left transition-colors duration-[120ms] ease-standard hover:bg-sunken'
}
>
{me.image ? (
// eslint-disable-next-line @next/next/no-img-element -- remote avatar, no loader configured
<img src={me.image} alt="" className="h-14 w-14 shrink-0 rounded-pill object-cover" />
) : (
<span
aria-hidden
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-pill bg-inset font-display text-h4 text-muted"
>
{me.name?.[0]?.toUpperCase() ?? '?'}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-display text-h4 text-strong">
{me.name ?? 'Add your name'}
</span>
<span className="mt-0.5 block truncate text-meta text-muted">
{role} · joined {since}
</span>
</span>
<ChevronRight className="h-5 w-5 shrink-0 text-faint" aria-hidden />
</button>
);
}
function NameSheet({
open,
initial,
onClose,
}: {
open: boolean;
initial: string;
onClose: () => void;
}) {
const utils = api.useUtils();
const [name, setName] = useState(initial);
const [error, setError] = useState<string | null>(null);
// The sheet stays mounted so it can animate out, so its draft has to be reset
// on the way in — otherwise a cancelled edit is still sitting there next time.
useEffect(() => {
if (!open) return;
setName(initial);
setError(null);
}, [open, initial]);
const save = api.user.updateProfile.useMutation({
onSuccess: () => {
void utils.user.me.invalidate();
onClose();
},
onError: (e) => setError(e.message),
});
const trimmed = name.trim();
const unchanged = trimmed === initial.trim();
return (
<Sheet
open={open}
onClose={onClose}
title="Your name"
body="This is what pros see when you send them a job."
actions={
<Button
block
size="lg"
busy={save.isPending}
disabled={trimmed.length === 0 || unchanged}
onClick={() => save.mutate({ name: trimmed })}
>
Save
</Button>
}
>
<Field label="Name">
<Input
value={name}
maxLength={80}
autoComplete="name"
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
/>
</Field>
{error && <FormError>{error}</FormError>}
</Sheet>
);
}
/**
* Asking for an address, not setting one.
*
* `requestEmailChange` parks the address until a token comes back, and it
* deliberately never reports a collision — so there is no failure state to show
* here beyond a malformed address, and no "saved" state either. What happened
* happened in an inbox, which is exactly the outcome a toast is for.
*/
function EmailSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const toast = useToast();
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setEmail('');
setError(null);
}, [open]);
const request = api.user.requestEmailChange.useMutation({
onSuccess: () => {
toast('Check your inbox — we sent a link to confirm the address.', { tone: 'success' });
onClose();
},
onError: (e) => setError(e.message),
});
const trimmed = email.trim();
return (
<Sheet
open={open}
onClose={onClose}
title="Your email"
body="We send a link to confirm it. Nothing changes until you follow it."
actions={
<Button
block
size="lg"
busy={request.isPending}
disabled={trimmed.length === 0}
onClick={() => request.mutate({ email: trimmed })}
>
Send confirmation
</Button>
}
>
<Field label="Email address">
<Input
type="email"
inputMode="email"
autoComplete="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError(null);
}}
/>
</Field>
{error && <FormError>{error}</FormError>}
</Sheet>
);
}
@@ -5,7 +5,7 @@ import {
DEFAULT_SERVICE_RADIUS_M, DEFAULT_SERVICE_RADIUS_M,
MAX_SERVICE_RADIUS_M, MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M,
} from '@linkder/shared'; } from '@linkdr/shared';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { import {
AddressField, AddressField,
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { AlertTriangle, Check, LocateFixed, MapPin } from 'lucide-react'; import { AlertTriangle, Check, LocateFixed, MapPin } from 'lucide-react';
import type { LocationInput } from '@linkder/shared'; import type { LocationInput } from '@linkdr/shared';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { useDebouncedValue } from '@/lib/use-debounced-value'; import { useDebouncedValue } from '@/lib/use-debounced-value';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
+1 -1
View File
@@ -64,7 +64,7 @@ export function Banner({ tone = 'info', title, children, className, role }: Bann
/** Inline form error. §6.2 — always announced. */ /** Inline form error. §6.2 — always announced. */
export function FormError({ children }: { children: React.ReactNode }) { export function FormError({ children }: { children: React.ReactNode }) {
return ( return (
<p role="alert" className="text-body-sm text-stop-500"> <p role="alert" className="text-body-sm text-danger">
{children} {children}
</p> </p>
); );
+1 -1
View File
@@ -62,7 +62,7 @@ export function Field({
{hint && <span className="text-meta text-muted">{hint}</span>} {hint && <span className="text-meta text-muted">{hint}</span>}
{children} {children}
{error && ( {error && (
<span role="alert" className="text-body-sm text-stop-500"> <span role="alert" className="text-body-sm text-danger">
{error} {error}
</span> </span>
)} )}
+56 -20
View File
@@ -1,9 +1,16 @@
'use client'; 'use client';
import Link from 'next/link';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils'; 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({ export function SettingsGroup({
title, title,
note, note,
@@ -14,7 +21,7 @@ export function SettingsGroup({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="mb-6"> <section className="mb-8">
<h2 className="mb-2 px-1 text-overline uppercase text-faint">{title}</h2> <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"> <div className="divide-y divide-hairline overflow-hidden rounded-card border border-hairline bg-raised">
{children} {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({ export function SettingsRow({
label, label,
value, value,
hint, hint,
href,
onClick, onClick,
danger, danger,
disabled, disabled,
@@ -36,34 +55,48 @@ export function SettingsRow({
label: string; label: string;
value?: React.ReactNode; value?: React.ReactNode;
hint?: string; hint?: string;
href?: string;
onClick?: () => void; onClick?: () => void;
danger?: boolean; danger?: boolean;
disabled?: boolean; disabled?: boolean;
}) { }) {
const interactive = Boolean(onClick) && !disabled; const interactive = Boolean(href ?? onClick) && !disabled;
const Tag = interactive ? 'button' : 'div';
return ( const inner = (
<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',
)}
>
<span className="min-w-0 flex-1"> <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} {label}
</span> </span>
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>} {hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
</span> </span>
{value !== undefined && ( {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 />} {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} disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={cn( className={cn(
'flex w-full items-center gap-3 px-4 py-3.5 text-left', rowClasses,
'transition-colors duration-[120ms] ease-standard hover:bg-sunken', interactiveClasses,
disabled && 'pointer-events-none opacity-45', disabled && 'pointer-events-none opacity-45',
)} )}
> >
@@ -105,7 +138,10 @@ export function SettingsToggle({
className={cn( className={cn(
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill', 'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
'transition-colors duration-[120ms] ease-standard', '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 <span
+4 -4
View File
@@ -2,8 +2,8 @@ import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, phoneNumber } from 'better-auth/plugins'; import { admin, phoneNumber } from 'better-auth/plugins';
import { nextCookies } from 'better-auth/next-js'; import { nextCookies } from 'better-auth/next-js';
import { db, schema } from '@linkder/db'; import { db, schema } from '@linkdr/db';
import { isE164 } from '@linkder/shared'; import { isE164 } from '@linkdr/shared';
import { sendVerificationSms } from '@/server/sms'; import { sendVerificationSms } from '@/server/sms';
import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login'; import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login';
@@ -206,7 +206,7 @@ export const auth = betterAuth({
* form is load-bearing: if "+34600111222" and "0034600111222" can both be * form is load-bearing: if "+34600111222" and "0034600111222" can both be
* written, one handset holds two "unique" accounts, a ban is escapable by * written, one handset holds two "unique" accounts, a ban is escapable by
* retyping, and findPossibleDuplicates cannot see the pair. Callers must * retyping, and findPossibleDuplicates cannot see the pair. Callers must
* send E.164 — normalise with toE164() from @linkder/shared before * send E.164 — normalise with toE164() from @linkdr/shared before
* calling. This runs on both /phone-number/send-otp and * calling. This runs on both /phone-number/send-otp and
* /sign-in/phone-number. * /sign-in/phone-number.
*/ */
@@ -217,7 +217,7 @@ export const auth = betterAuth({
* not have one, so we mint a synthetic address on a domain we control * not have one, so we mint a synthetic address on a domain we control
* and never send to. * and never send to.
* *
* ALWAYS gate outbound mail on isSyntheticEmail() from @linkder/shared. * ALWAYS gate outbound mail on isSyntheticEmail() from @linkdr/shared.
* Pros are required to supply a real address during onboarding — they * Pros are required to supply a real address during onboarding — they
* need payout statements, tax records and dispute notices. Clients stay * need payout statements, tax records and dispute notices. Clients stay
* phone-only and get SMS receipts. * phone-only and get SMS receipts.
+1 -1
View File
@@ -12,7 +12,7 @@
* pasted into a chat, and this is nobody else's business. Not `localStorage` * pasted into a chat, and this is nobody else's business. Not `localStorage`
* either — an intent from last Tuesday is not an intent. * either — an intent from last Tuesday is not an intent.
*/ */
const KEY = 'linkder:pending-hire'; const KEY = 'linkdr:pending-hire';
export interface PendingHire { export interface PendingHire {
proId: string; proId: string;
+1 -1
View File
@@ -6,7 +6,7 @@ import { httpBatchLink } from '@trpc/client';
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query'; import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
import { deserialize, serialize } from 'superjson'; import { deserialize, serialize } from 'superjson';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@linkder/api'; import type { AppRouter } from '@linkdr/api';
// Explicit annotation: pnpm's strict node_modules layout means the inferred // Explicit annotation: pnpm's strict node_modules layout means the inferred
// type cannot be named from here (TS2742). // type cannot be named from here (TS2742).
+1 -1
View File
@@ -1,4 +1,4 @@
import type { UploadKind } from '@linkder/storage'; import type { UploadKind } from '@linkdr/storage';
interface PresignResult { interface PresignResult {
url: string; url: string;
+2 -2
View File
@@ -1,7 +1,7 @@
import { cache } from 'react'; import { cache } from 'react';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { appRouter, createCallerFactory, createInnerContext } from '@linkder/api'; import { appRouter, createCallerFactory, createInnerContext } from '@linkdr/api';
import { db } from '@linkder/db'; import { db } from '@linkdr/db';
import { resolveSession } from './session'; import { resolveSession } from './session';
const createCaller = createCallerFactory(appRouter); const createCaller = createCallerFactory(appRouter);
+2 -2
View File
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, schema } from '@linkder/db'; import { db, schema } from '@linkdr/db';
/** /**
* A fixed test account for local development. * A fixed test account for local development.
@@ -17,7 +17,7 @@ import { db, schema } from '@linkder/db';
* *
* Any one of them failing falls straight back to the real OTP path. * Any one of them failing falls straight back to the real OTP path.
*/ */
const DEV_PHONE = '+34600000000'; const DEV_PHONE = '+525500000000';
const DEV_CODE = '000000'; const DEV_CODE = '000000';
export function isDevLoginEnabled(): boolean { export function isDevLoginEnabled(): boolean {
+2 -2
View File
@@ -1,6 +1,6 @@
import { and, eq, ne, sql } from 'drizzle-orm'; import { and, eq, ne, sql } from 'drizzle-orm';
import { db, schema } from '@linkder/db'; import { db, schema } from '@linkdr/db';
import { isSyntheticEmail } from '@linkder/shared'; import { isSyntheticEmail } from '@linkdr/shared';
/** /**
* Duplicate-account detection. * Duplicate-account detection.
+5 -5
View File
@@ -1,15 +1,15 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import type { Session, SessionResolver } from '@linkder/api'; import type { Session, SessionResolver } from '@linkdr/api';
import { db, schema } from '@linkder/db'; import { db, schema } from '@linkdr/db';
import type { Role, VerificationStatus } from '@linkder/shared'; import type { Role, VerificationStatus } from '@linkdr/shared';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
/** /**
* Turns an incoming request into a Linkder session. * Turns an incoming request into a Linkdr session.
* *
* The auth library lives behind this one function. Everything downstream — every * The auth library lives behind this one function. Everything downstream — every
* tRPC procedure, every authorization check — is written against `Session` from * tRPC procedure, every authorization check — is written against `Session` from
* @linkder/api, so replacing the provider means rewriting this file and nothing * @linkdr/api, so replacing the provider means rewriting this file and nothing
* else. * else.
* *
* Two responsibilities beyond "who is this": * Two responsibilities beyond "who is this":
+3 -3
View File
@@ -1,9 +1,9 @@
import { sendSms } from '@linkder/notify'; import { sendSms } from '@linkdr/notify';
/** /**
* SMS delivery for one-time codes. * SMS delivery for one-time codes.
* *
* The transport itself now lives in @linkder/notify, so the API package can * The transport itself now lives in @linkdr/notify, so the API package can
* reach it too — a tRPC procedure cannot import from `apps/web`, and the sign-in * reach it too — a tRPC procedure cannot import from `apps/web`, and the sign-in
* code and a "somebody wants to hire you" text have no business going out * code and a "somebody wants to hire you" text have no business going out
* through two different Twilio clients with two different failure policies. * through two different Twilio clients with two different failure policies.
@@ -14,6 +14,6 @@ import { sendSms } from '@linkder/notify';
export async function sendVerificationSms(to: string, code: string): Promise<void> { export async function sendVerificationSms(to: string, code: string): Promise<void> {
await sendSms( await sendSms(
to, to,
`${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`, `${code} is your Linkdr code. It expires in 5 minutes. We will never ask you for it.`,
); );
} }
+13 -1
View File
@@ -1,7 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
/* /*
* Linkder design tokens — see DESIGN.md at the repo root. * Linkdr design tokens — see DESIGN.md at the repo root.
* Hex values are sampled from wix.com and are normative. Do not hand-tune them * Hex values are sampled from wix.com and are normative. Do not hand-tune them
* in a component; change them here or add a step to the ramp. * in a component; change them here or add a step to the ramp.
*/ */
@@ -138,6 +138,14 @@
--accent: var(--color-brand-500); --accent: var(--color-brand-500);
--accent-hover: var(--color-brand-600); --accent-hover: var(--color-brand-600);
--accent-soft: var(--color-brand-50); --accent-soft: var(--color-brand-50);
/*
* Destructive TEXT, which is a different job from the destructive fill.
* stop-500 is the error colour of §2.3 and stays the fill and icon colour —
* as a graphical object it only owes 3:1 and it clears that. As body text it
* measures 3.97:1 on white and misses the 4.5:1 floor in §8, so a label that
* says "Delete my account" uses this instead. §8 is the floor; it wins.
*/
--danger: var(--color-stop-600);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -154,6 +162,9 @@
--accent: var(--color-brand-400); --accent: var(--color-brand-400);
--accent-hover: var(--color-brand-200); --accent-hover: var(--color-brand-200);
--accent-soft: rgb(94 151 255 / 0.14); --accent-soft: rgb(94 151 255 / 0.14);
/* Inverted for the same reason: stop-600 is 3.0:1 on the dark page. §2.3
already names stop-400 "error on dark" — this is where that gets used. */
--danger: var(--color-stop-400);
} }
} }
@@ -170,6 +181,7 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-hover: var(--accent-hover); --color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft); --color-accent-soft: var(--accent-soft);
--color-danger: var(--danger);
} }
@layer base { @layer base {
+3 -3
View File
@@ -2,7 +2,7 @@
* Auth integration test — runs against the live seeded database. * Auth integration test — runs against the live seeded database.
* *
* pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/web test * pnpm --filter @linkdr/web test
* *
* This is deliberately an integration test rather than a unit test, because the * This is deliberately an integration test rather than a unit test, because the
* thing most likely to break is not our logic. better-auth declares * thing most likely to break is not our logic. better-auth declares
@@ -18,9 +18,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db, schema } = await import('@linkder/db'); const { closePool, db, schema } = await import('@linkdr/db');
const { auth } = await import('@/lib/auth'); const { auth } = await import('@/lib/auth');
const { isSyntheticEmail } = await import('@linkder/shared'); const { isSyntheticEmail } = await import('@linkdr/shared');
/** A number no seed row uses, so the test owns its own user. */ /** A number no seed row uses, so the test owns its own user. */
const PHONE = '+34699000111'; const PHONE = '+34699000111';
+2 -2
View File
@@ -56,7 +56,7 @@ describe('beforeSend', () => {
it('drops cookies and headers entirely', () => { it('drops cookies and headers entirely', () => {
const event = { const event = {
request: { request: {
url: 'https://linkder.app/api', url: 'https://linkdr.app/api',
cookies: { session: 'live-credential' }, cookies: { session: 'live-credential' },
headers: { authorization: 'Bearer live-credential' }, headers: { authorization: 'Bearer live-credential' },
}, },
@@ -88,7 +88,7 @@ describe('beforeSend', () => {
it('scrubs request body data and the query string', () => { it('scrubs request body data and the query string', () => {
const event = { const event = {
request: { request: {
url: 'https://linkder.app/verify?code=123456', url: 'https://linkdr.app/verify?code=123456',
query_string: 'code=123456', query_string: 'code=123456',
data: { phoneNumber: '+34600111222', code: '123456' }, data: { phoneNumber: '+34600111222', code: '123456' },
}, },
+25
View File
@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIERDCCAqygAwIBAgIUT+Zwrrq80kuT4VpUr5nmPHR8eZ0wDQYJKoZIhvcNAQEM
BQAwOjE4MDYGA1UEAwwvOTFkNTNlYjctMDA5MC00Mzg5LWFjN2EtYjAxN2U2Mjli
NjY0IFByb2plY3QgQ0EwHhcNMjYwNDE2MTMwNzMzWhcNMzYwNDEzMTMwNzMzWjA6
MTgwNgYDVQQDDC85MWQ1M2ViNy0wMDkwLTQzODktYWM3YS1iMDE3ZTYyOWI2NjQg
UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKFiJhw2
DC3Xf0HzVb+glBzZajAPgkJP0EwRN4sdxPNR17ajN4mFuDXIjHuil/zhJiwVbByC
NA1NX+2wPNJ9MZVSVyYvB6G8xRhBFvlawS+u5KQQ6TqdJ02/398D5cY5L1vbRzNU
CZgrDtzuztOlER02dltmE8/mZmKN6rh+p7gyYVRNe+uXMn9VJQj5853fA0yw6OZh
1CJdf6xUqVCROf6PaTaeOKq0tu/1YkvKjY/cNioOgAHZe3WcKixdbnjXAwU3P4RU
A7CHunxfccIGh32lItz/pSwFGIvEaUcbEcu+343DtO0ADRELNQLXREOUduJTRaNs
kApA3YRGImi56CagCklrzL6kAGC6yxRqQDAMIcad11Msk6qreCOy0ozQeg8MRvSy
m9qNoOt1OsQXykI0CjsmG/R+lAO3DDL0Fgf34Vaq8BDDAYa4GhwIuWQKOkiotC8q
XDLkdc6AYvmGYEr8HfQ+r82Ydbbg4Sp2FSUeTKDoWLt2mgwsP4X+ouT16QIDAQAB
o0IwQDAdBgNVHQ4EFgQUnCW66rguV+CMWB4qWelnjPwMoV0wEgYDVR0TAQH/BAgw
BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAC0JQDGjl+oY
GcvpvSMylDo7SY+WvvB1bGaW3lPoh97qPdVdlyAQKKSu4np/hygeJvaX3+I4Ongw
GVrdP0OsqIcB51W7c/ktg5BhBhKyXyiXDLHKHovIB0kyK3x4D9J2jHfjBjYzD/eX
Fy52AFyZnvVeiRjOh2ZCUpKdCKRjQwtX5c36NQEMn6APCy3toduSPoxjsTsnUan3
Kq1bAq2YPnwkNwfpNHE2IYqTnAhp+EjJDPmttcFtxoDQBbQ1V0Ug2oxWxj2N7w2S
x6rWF3XTf3jl7ZX5FNk2s6es4BXXw9A0Jylq80t1TZ3BKJdS9thCyWrLlrOcb//W
qaqxZtOfqyAG8FgFRO22cHJEEi0oANKU9ZNMG7zq83oEKkFFs5eGE+9eiBCXP/sx
oUpw/Le4D6oajrqHMp2R2bQXbX+tId2RCsYTwF+3MkRJk0cCQQa3d6d7S8/c/y8c
XgcnmXkdLOsegv6Xt7BsCxNn74YNxwJCP34LdUaFmkPIC2ZcVqY+Gg==
-----END CERTIFICATE-----
+8 -7
View File
@@ -1,5 +1,5 @@
{ {
"name": "linkder", "name": "linkdr",
"private": true, "private": true,
"packageManager": "pnpm@9.15.4", "packageManager": "pnpm@9.15.4",
"engines": { "engines": {
@@ -13,13 +13,14 @@
"test": "turbo run test --concurrency=1", "test": "turbo run test --concurrency=1",
"test:e2e": "turbo run test:e2e", "test:e2e": "turbo run test:e2e",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
"db:generate": "pnpm --filter @linkder/db generate", "db:generate": "pnpm --filter @linkdr/db generate",
"db:migrate": "pnpm --filter @linkder/db migrate", "db:migrate": "pnpm --filter @linkdr/db migrate",
"db:seed": "pnpm --filter @linkder/db seed", "db:seed": "pnpm --filter @linkdr/db seed",
"db:recompute-stats": "pnpm --filter @linkder/db recompute-stats", "db:recompute-stats": "pnpm --filter @linkdr/db recompute-stats",
"db:studio": "pnpm --filter @linkder/db studio", "db:studio": "pnpm --filter @linkdr/db studio",
"services:up": "docker compose up -d postgres redis", "services:up": "docker compose up -d postgres redis",
"services:down": "docker compose down" "services:down": "docker compose down",
"assets:migrate": "pnpm --filter @linkdr/db assets:migrate"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
+6 -6
View File
@@ -1,5 +1,5 @@
{ {
"name": "@linkder/api", "name": "@linkdr/api",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -14,11 +14,11 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@linkder/db": "workspace:*", "@linkdr/db": "workspace:*",
"@linkder/geocode": "workspace:*", "@linkdr/geocode": "workspace:*",
"@linkder/notify": "workspace:*", "@linkdr/notify": "workspace:*",
"@linkder/shared": "workspace:*", "@linkdr/shared": "workspace:*",
"@linkder/storage": "workspace:*", "@linkdr/storage": "workspace:*",
"@opentelemetry/api": "1.9.1", "@opentelemetry/api": "1.9.1",
"@trpc/server": "^11.18.0", "@trpc/server": "^11.18.0",
"drizzle-orm": "0.38.4", "drizzle-orm": "0.38.4",
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Db } from '@linkder/db'; import type { Db } from '@linkdr/db';
import type { Role, VerificationStatus } from '@linkder/shared'; import type { Role, VerificationStatus } from '@linkdr/shared';
/** /**
* The session shape the API depends on. * The session shape the API depends on.
+2 -2
View File
@@ -1,5 +1,5 @@
import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkder/geocode'; import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkdr/geocode';
import type { LatLng, LocationInput, LocationPrecision } from '@linkder/shared'; import type { LatLng, LocationInput, LocationPrecision } from '@linkdr/shared';
/** /**
* The one place a stored coordinate is decided. * The one place a stored coordinate is decided.
+5 -5
View File
@@ -1,10 +1,10 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'; import { and, asc, count, desc, eq, inArray } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db'; import { recomputeProStats, schema } from '@linkdr/db';
import { notify } from '@linkder/notify'; import { notify } from '@linkdr/notify';
import { createPresignedDownload } from '@linkder/storage'; import { createPresignedDownload } from '@linkdr/storage';
import { assertTransition, VERIFICATION_STATUSES } from '@linkder/shared'; import { assertTransition, VERIFICATION_STATUSES } from '@linkdr/shared';
import { adminProcedure, router } from '../trpc'; import { adminProcedure, router } from '../trpc';
/** /**
@@ -20,7 +20,7 @@ import { adminProcedure, router } from '../trpc';
* everyone else: an admin surface that announces itself is a target. * everyone else: an admin surface that announces itself is a target.
* *
* Nothing in this router trusts a status it was handed. Each transition goes * Nothing in this router trusts a status it was handed. Each transition goes
* through the graph in @linkder/shared, and each writes an `audit_log` row — * through the graph in @linkdr/shared, and each writes an `audit_log` row —
* these are the decisions that a regulator, an insurer or a court would ask us * these are the decisions that a regulator, an insurer or a court would ask us
* to account for. * to account for.
*/ */
+3 -3
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm'; import { desc, eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { recomputeProStats, schema, type Db } from '@linkder/db'; import { recomputeProStats, schema, type Db } from '@linkdr/db';
import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkder/shared'; import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkdr/shared';
import { requireMatchParticipant } from './message'; import { requireMatchParticipant } from './message';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
@@ -162,7 +162,7 @@ export const bookingRouter = router({
* Call it off. * Call it off.
* *
* Either side may, and who cancelled decides who pays — `cancellationOutcome` * Either side may, and who cancelled decides who pays — `cancellationOutcome`
* in @linkder/shared owns that rule and is already tested. The result is * in @linkdr/shared owns that rule and is already tested. The result is
* written into the audit log now, while the scheduled time and the quote are * written into the audit log now, while the scheduled time and the quote are
* still the facts they were; recomputing it later from a slot that has since * still the facts they were; recomputing it later from a slot that has since
* passed would give a different answer. * passed would give a different answer.
+3 -3
View File
@@ -1,14 +1,14 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'; import { and, count, desc, eq, inArray, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db'; import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkdr/db';
import { notify } from '@linkder/notify'; import { notify } from '@linkdr/notify';
import { import {
DECK_PAGE_SIZE, DECK_PAGE_SIZE,
MAX_OPEN_REQUESTS_PER_JOB, MAX_OPEN_REQUESTS_PER_JOB,
REQUEST_TTL_HOURS, REQUEST_TTL_HOURS,
swipeSchema, swipeSchema,
} from '@linkder/shared'; } from '@linkdr/shared';
import { clientProcedure, publicProcedure, router } from '../trpc'; import { clientProcedure, publicProcedure, router } from '../trpc';
import type { Context } from '../context'; import type { Context } from '../context';
+2 -2
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, desc, eq, gt, isNull, or, sql } from 'drizzle-orm'; import { and, desc, eq, gt, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkder/shared'; import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkdr/shared';
import { clientProcedure, proProcedure, protectedProcedure, router } from '../trpc'; import { clientProcedure, proProcedure, protectedProcedure, router } from '../trpc';
/** /**
+2 -2
View File
@@ -7,8 +7,8 @@ import {
MAX_SUGGESTIONS, MAX_SUGGESTIONS,
reverse, reverse,
type GeocodeResult, type GeocodeResult,
} from '@linkder/geocode'; } from '@linkdr/geocode';
import { latLngSchema } from '@linkder/shared'; import { latLngSchema } from '@linkdr/shared';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
/** /**
+2 -2
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, desc, eq, sql } from 'drizzle-orm'; import { and, desc, eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkder/shared'; import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkdr/shared';
import { resolveLocation } from '../location'; import { resolveLocation } from '../location';
import { clientProcedure, proProcedure, publicProcedure, router } from '../trpc'; import { clientProcedure, proProcedure, publicProcedure, router } from '../trpc';
+2 -2
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, desc, eq, isNull, ne, or, sql } from 'drizzle-orm'; import { and, desc, eq, isNull, ne, or, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema, type Db } from '@linkder/db'; import { schema, type Db } from '@linkdr/db';
import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkder/shared'; import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkdr/shared';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
/** /**
+3 -3
View File
@@ -2,8 +2,8 @@ import { TRPCError } from '@trpc/server';
import { and, desc, eq, inArray, lt } from 'drizzle-orm'; import { and, desc, eq, inArray, lt } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core'; import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod'; import { z } from 'zod';
import { eligibleProAtAnyDistance, schema, searchPros } from '@linkder/db'; import { eligibleProAtAnyDistance, schema, searchPros } from '@linkdr/db';
import { notify } from '@linkder/notify'; import { notify } from '@linkdr/notify';
import { import {
assertTransition, assertTransition,
credentialSchema, credentialSchema,
@@ -12,7 +12,7 @@ import {
REVIEWS_PAGE_SIZE, REVIEWS_PAGE_SIZE,
searchProsSchema, searchProsSchema,
updateSkillsSchema, updateSkillsSchema,
} from '@linkder/shared'; } from '@linkdr/shared';
import { resolveLocation } from '../location'; import { resolveLocation } from '../location';
import { proProcedure, publicProcedure, router } from '../trpc'; import { proProcedure, publicProcedure, router } from '../trpc';
+2 -2
View File
@@ -1,13 +1,13 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, desc, eq } from 'drizzle-orm'; import { and, desc, eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import { import {
assertTransition, assertTransition,
createBookingSchema, createBookingSchema,
createQuoteSchema, createQuoteSchema,
QUOTE_VALIDITY_HOURS, QUOTE_VALIDITY_HOURS,
} from '@linkder/shared'; } from '@linkdr/shared';
import { requireMatchParticipant } from './message'; import { requireMatchParticipant } from './message';
import { clientProcedure, protectedProcedure, router, verifiedProProcedure } from '../trpc'; import { clientProcedure, protectedProcedure, router, verifiedProProcedure } from '../trpc';
+3 -3
View File
@@ -1,9 +1,9 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, eq, gt, sql } from 'drizzle-orm'; import { and, eq, gt, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db'; import { recomputeProStats, schema } from '@linkdr/db';
import { notify } from '@linkder/notify'; import { notify } from '@linkdr/notify';
import { assertTransition } from '@linkder/shared'; import { assertTransition } from '@linkdr/shared';
import { proProcedure, router, verifiedProProcedure } from '../trpc'; import { proProcedure, router, verifiedProProcedure } from '../trpc';
/** /**
+2 -2
View File
@@ -1,12 +1,12 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, eq, ne, sql } from 'drizzle-orm'; import { and, eq, ne, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db'; import { recomputeProStats, schema } from '@linkdr/db';
import { import {
createReviewSchema, createReviewSchema,
REVIEW_EMBARGO_HOURS, REVIEW_EMBARGO_HOURS,
REVIEW_WINDOW_DAYS, REVIEW_WINDOW_DAYS,
} from '@linkder/shared'; } from '@linkdr/shared';
import { requireMatchParticipant } from './message'; import { requireMatchParticipant } from './message';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
+1 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkder/storage'; import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkdr/storage';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
/** /**
+2 -2
View File
@@ -2,8 +2,8 @@ import { randomUUID } from 'node:crypto';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, desc, eq, isNull } from 'drizzle-orm'; import { and, desc, eq, isNull } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared'; import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkdr/shared';
import { resolveLocation } from '../location'; import { resolveLocation } from '../location';
import { protectedProcedure, publicProcedure, router } from '../trpc'; import { protectedProcedure, publicProcedure, router } from '../trpc';
+1 -1
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import { protectedProcedure, router } from '../trpc'; import { protectedProcedure, router } from '../trpc';
/** /**
+1 -1
View File
@@ -2,7 +2,7 @@ import { TRPCError, initTRPC } from '@trpc/server';
import superjson from 'superjson'; import superjson from 'superjson';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { schema } from '@linkder/db'; import { schema } from '@linkdr/db';
import type { Context } from './context'; import type { Context } from './context';
const t = initTRPC.context<Context>().create({ const t = initTRPC.context<Context>().create({
+2 -2
View File
@@ -23,7 +23,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -86,7 +86,7 @@ async function makePro(name: string, status: string): Promise<string> {
${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000, ${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000,
-- Right on the city centre, so an approval is visible to a search run -- Right on the city centre, so an approval is visible to a search run
-- from there and the "it lands on every surface" assertions are real. -- from there and the "it lands on every surface" assertions are real.
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 20000, ${status} ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 20000, ${status}
) )
`); `);
+3 -3
View File
@@ -2,7 +2,7 @@
* Integration tests for the deck router, run against the live seeded database. * Integration tests for the deck router, run against the live seeded database.
* *
* pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/api test * pnpm --filter @linkdr/api test
* *
* The point of these is authorization. The swipe path previously lived in a Next * The point of these is authorization. The swipe path previously lived in a Next
* server action that trusted whatever jobId it was handed, so anyone could swipe * server action that trusted whatever jobId it was handed, so anyone could swipe
@@ -11,11 +11,11 @@
import { config } from 'dotenv'; import { config } from 'dotenv';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared'; import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkdr/shared';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
+2 -2
View File
@@ -12,11 +12,11 @@
import { config } from 'dotenv'; import { config } from 'dotenv';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { MAX_OPEN_ENQUIRIES } from '@linkder/shared'; import { MAX_OPEN_ENQUIRIES } from '@linkdr/shared';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
+7 -7
View File
@@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -37,8 +37,8 @@ const clientSession = (userId: string): Session => ({
const RUN = Math.random().toString(36).slice(2, 8); const RUN = Math.random().toString(36).slice(2, 8);
const CITY = { const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
}; };
let client: string; let client: string;
@@ -140,13 +140,13 @@ describe('job.create resolves the point server-side', () => {
const job = await callerFor(clientSession(client)).job.create({ const job = await callerFor(clientSession(client)).job.create({
...base, ...base,
categoryId: plumberCat, categoryId: plumberCat,
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' }, place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa' },
}); });
const row = await readJob(job.id); const row = await readJob(job.id);
// A handset fix is real, so the coordinates are kept as sent... // A handset fix is real, so the coordinates are kept as sent...
expect(Number(row.lat)).toBeCloseTo(41.4036, 4); expect(Number(row.lat)).toBeCloseTo(19.4194, 4);
expect(Number(row.lng)).toBeCloseTo(2.1744, 4); expect(Number(row.lng)).toBeCloseTo(-99.1655, 4);
// ...but it is metres out on a good day, so it must not rank as a rooftop. // ...but it is metres out on a good day, so it must not rank as a rooftop.
expect(row.precision).toBe('approximate'); expect(row.precision).toBe('approximate');
}); });
@@ -157,7 +157,7 @@ describe('job.create resolves the point server-side', () => {
const job = await callerFor(clientSession(client)).job.create({ const job = await callerFor(clientSession(client)).job.create({
...base, ...base,
categoryId: plumberCat, categoryId: plumberCat,
place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' }, place: { source: 'place', placeId: 'made-up-id', label: 'Av. Álvaro Obregón 12' },
}); });
const row = await readJob(job.id); const row = await readJob(job.id);
+6 -6
View File
@@ -15,11 +15,11 @@
import { config } from 'dotenv'; import { config } from 'dotenv';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { REVIEW_EMBARGO_HOURS } from '@linkder/shared'; import { REVIEW_EMBARGO_HOURS } from '@linkdr/shared';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -98,7 +98,7 @@ beforeAll(async () => {
) )
VALUES ( VALUES (
${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000, ${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000,
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
15000, 'verified', now(), false 15000, 'verified', now(), false
) )
`); `);
@@ -109,8 +109,8 @@ beforeAll(async () => {
VALUES ( VALUES (
${owner}, ${plumber!.id}, 'Lifecycle fixture job', ${owner}, ${plumber!.id}, 'Lifecycle fixture job',
'A job that exists to be quoted, booked, completed and reviewed.', 'A job that exists to be quoted, booked, completed and reviewed.',
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
'Carrer de Prova 1', 'matched' 'Calle Colima 1', 'matched'
) )
RETURNING id RETURNING id
`); `);
@@ -220,7 +220,7 @@ describe('booking', () => {
) )
VALUES ( VALUES (
${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500, ${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500,
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
15000, 'verified', now(), false 15000, 'verified', now(), false
) )
`); `);
+5 -5
View File
@@ -2,7 +2,7 @@
* Integration tests for chat, against the live seeded database. * Integration tests for chat, against the live seeded database.
* *
* pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/api test * pnpm --filter @linkdr/api test
* *
* A thread is a private conversation between exactly two people, so most of this * A thread is a private conversation between exactly two people, so most of this
* file is about the third person: a stranger must not be able to read it, write * file is about the third person: a stranger must not be able to read it, write
@@ -18,7 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -78,8 +78,8 @@ async function insertJobWithMatch(categoryId: string, status: string): Promise<{
VALUES ( VALUES (
${owner}, ${categoryId}, 'Chat fixture job', ${owner}, ${categoryId}, 'Chat fixture job',
'A job that exists only so a conversation can hang off it.', 'A job that exists only so a conversation can hang off it.',
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography,
'Carrer de Prova 1', ${sql.raw(`'${status}'`)} 'Calle Colima 1', ${sql.raw(`'${status}'`)}
) )
RETURNING id RETURNING id
`); `);
@@ -116,7 +116,7 @@ beforeAll(async () => {
) )
VALUES ( VALUES (
${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000, ${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000,
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 15000, 'verified', now() ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 15000, 'verified', now()
) )
`); `);
+1 -1
View File
@@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
+5 -5
View File
@@ -19,7 +19,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -45,12 +45,12 @@ let probeClient: string;
beforeAll(async () => { beforeAll(async () => {
const [marc] = await db.execute<{ id: string }>( const [marc] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`, sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`,
); );
reviewedPro = marc!.id; reviewedPro = marc!.id;
const [arnau] = await db.execute<{ id: string }>( const [arnau] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`, sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`,
); );
awayPro = arnau!.id; awayPro = arnau!.id;
@@ -194,7 +194,7 @@ describe('pro.reviews', () => {
}); });
it('is not a way to read a pro who is off the deck', async () => { it('is not a way to read a pro who is off the deck', async () => {
// Away Arnau has a seeded review history and is verified — only holiday mode // Away Arturo has a seeded review history and is verified — only holiday mode
// hides him. If this stopped 404ing, reviews would be the way around // hides him. If this stopped 404ing, reviews would be the way around
// publicProfile rather than a view onto it. // publicProfile rather than a view onto it.
await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i); await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i);
@@ -205,7 +205,7 @@ describe('pro.reviews', () => {
it('404s for an unverified pro, exactly as the profile does', async () => { it('404s for an unverified pro, exactly as the profile does', async () => {
const [ulla] = await db.execute<{ id: string }>( const [ulla] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`, sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`,
); );
await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i); await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i);
}); });
+6 -6
View File
@@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -62,12 +62,12 @@ beforeAll(async () => {
client = aClient!.id; client = aClient!.id;
const [marc] = await db.execute<{ id: string }>( const [marc] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`, sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`,
); );
verifiedPro = marc!.id; verifiedPro = marc!.id;
const [arnau] = await db.execute<{ id: string }>( const [arnau] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`, sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`,
); );
awayPro = arnau!.id; awayPro = arnau!.id;
@@ -121,7 +121,7 @@ describe('pro.search', () => {
const ids = results.map((p) => p.proId); const ids = results.map((p) => p.proId);
const names = results.map((p) => p.name); const names = results.map((p) => p.name);
expect(names).not.toContain('Unverified Ulla'); expect(names).not.toContain('Unverified Ulises');
expect(ids).not.toContain(awayPro); expect(ids).not.toContain(awayPro);
expect(ids).not.toContain(bannedPro); expect(ids).not.toContain(bannedPro);
}); });
@@ -131,7 +131,7 @@ describe('pro.search', () => {
// the pros next to the city centre must fall out of range. // the pros next to the city centre must fall out of range.
await db.execute(sql` await db.execute(sql`
UPDATE users UPDATE users
SET location = ST_SetSRID(ST_MakePoint(2.1686, 41.5674), 4326)::geography, SET location = ST_SetSRID(ST_MakePoint(-99.1332, 19.6126), 4326)::geography,
search_radius_m = 2000 search_radius_m = 2000
WHERE id = ${client} WHERE id = ${client}
`); `);
@@ -166,7 +166,7 @@ describe('pro.publicProfile', () => {
it('refuses a pro who was never verified', async () => { it('refuses a pro who was never verified', async () => {
const [ulla] = await db.execute<{ id: string }>( const [ulla] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`, sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`,
); );
await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow(); await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow();
}); });
+6 -6
View File
@@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' }); config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db'); const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root'); const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context'); const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc'); const { createCallerFactory } = await import('../src/trpc');
@@ -258,15 +258,15 @@ describe('location and range', () => {
// coordinates the server takes at face value, so this test does not need a // coordinates the server takes at face value, so this test does not need a
// geocoder to be configured. // geocoder to be configured.
await caller.user.updateLocation({ await caller.user.updateLocation({
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' }, place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa, Ciudad de México' },
radiusM: 8_000, radiusM: 8_000,
}); });
const location = await caller.user.location(); const location = await caller.user.location();
expect(location.addressText).toBe('Gracia, Barcelona'); expect(location.addressText).toBe('Condesa, Ciudad de México');
expect(location.radiusM).toBe(8_000); expect(location.radiusM).toBe(8_000);
expect(location.location?.lat).toBeCloseTo(41.4036, 4); expect(location.location?.lat).toBeCloseTo(19.4194, 4);
expect(location.location?.lng).toBeCloseTo(2.1744, 4); expect(location.location?.lng).toBeCloseTo(-99.1655, 4);
}); });
it('changes only what it was given', async () => { it('changes only what it was given', async () => {
@@ -276,7 +276,7 @@ describe('location and range', () => {
const location = await caller.user.location(); const location = await caller.user.location();
expect(location.radiusM).toBe(25_000); expect(location.radiusM).toBe(25_000);
// The pin saved by the previous test is still there. // The pin saved by the previous test is still there.
expect(location.location?.lat).toBeCloseTo(41.4036, 4); expect(location.location?.lat).toBeCloseTo(19.4194, 4);
}); });
it('refuses a radius outside the supported range', async () => { it('refuses a radius outside the supported range', async () => {
+17 -1
View File
@@ -1,13 +1,29 @@
import { readFileSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from 'drizzle-kit';
config({ path: '../../.env' }); config({ path: '../../.env' });
/**
* Same CA rule as src/client.ts, restated because drizzle-kit runs this file on
* its own and cannot import from the package it is generating for.
*/
const caEnv = process.env.DATABASE_CA_CERT;
const ca = caEnv
? caEnv.includes('BEGIN CERTIFICATE')
? caEnv
: readFileSync(isAbsolute(caEnv) ? caEnv : resolve(process.cwd(), caEnv), 'utf8')
: undefined;
export default defineConfig({ export default defineConfig({
schema: './src/schema/index.ts', schema: './src/schema/index.ts',
out: './drizzle', out: './drizzle',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! }, dbCredentials: {
url: process.env.DATABASE_URL!,
...(ca ? { ssl: { ca, rejectUnauthorized: true } } : {}),
},
verbose: true, verbose: true,
strict: true, strict: true,
}); });
+6 -4
View File
@@ -1,5 +1,5 @@
{ {
"name": "@linkder/db", "name": "@linkdr/db",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -17,13 +17,15 @@
"seed": "tsx src/seed.ts", "seed": "tsx src/seed.ts",
"recompute-stats": "tsx src/recompute-stats.ts", "recompute-stats": "tsx src/recompute-stats.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run",
"assets:migrate": "tsx src/migrate-assets.ts"
}, },
"dependencies": { "dependencies": {
"@linkder/shared": "workspace:*", "@linkdr/shared": "workspace:*",
"@opentelemetry/api": "1.9.1", "@opentelemetry/api": "1.9.1",
"drizzle-orm": "0.38.4", "drizzle-orm": "0.38.4",
"postgres": "^3.4.5" "postgres": "^3.4.5",
"@aws-sdk/client-s3": "^3.717.0"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
+56 -9
View File
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
import * as schema from './schema/index'; import * as schema from './schema/index';
@@ -13,10 +15,52 @@ import * as schema from './schema/index';
* pool on every hot reload and exhaust Postgres connections within a minute. * pool on every hot reload and exhaust Postgres connections within a minute.
*/ */
const globalForDb = globalThis as unknown as { const globalForDb = globalThis as unknown as {
__linkderPool?: postgres.Sql; __linkdrPool?: postgres.Sql;
__linkderDb?: PostgresJsDatabase<typeof schema>; __linkdrDb?: PostgresJsDatabase<typeof schema>;
}; };
/**
* TLS for a managed database.
*
* A hosted Postgres is reached over the public internet, so `sslmode=require`
* alone is not enough: it encrypts the connection but verifies nothing, which
* leaves it open to anyone who can answer for the hostname. Handing the
* provider's CA to the client turns that into a checked identity.
*
* `DATABASE_CA_CERT` takes either a path to the .crt or the certificate inline
* — a path locally where the file is on disk, the PEM itself on a platform
* where secrets are environment variables and there is no filesystem to put it
* on. Unset means a plain connection, which is what local Docker wants.
*/
/**
* Find the certificate file from wherever the caller happens to be.
*
* The scripts run from `packages/db`, Next runs from `apps/web`, and the cert
* sits at the repo root — so a relative path resolved against `process.cwd()`
* alone is wrong for every one of them. Walk up instead, the same way the
* scripts already reach `../../.env`.
*/
function readCaFile(path: string): string {
if (isAbsolute(path)) return readFileSync(path, 'utf8');
let dir = process.cwd();
for (let up = 0; up < 5; up += 1) {
const candidate = resolve(dir, path);
if (existsSync(candidate)) return readFileSync(candidate, 'utf8');
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`DATABASE_CA_CERT points at ${path}, which was not found from ${process.cwd()}`);
}
export function readSsl(): postgres.Options<Record<string, never>>['ssl'] {
const ca = process.env.DATABASE_CA_CERT;
if (!ca) return undefined;
return { ca: ca.includes('BEGIN CERTIFICATE') ? ca : readCaFile(ca), rejectUnauthorized: true };
}
function createPool(): postgres.Sql { function createPool(): postgres.Sql {
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
if (!connectionString) { if (!connectionString) {
@@ -24,25 +68,28 @@ function createPool(): postgres.Sql {
'DATABASE_URL is not set. Copy .env.example to .env and start Postgres with `pnpm services:up`.', 'DATABASE_URL is not set. Copy .env.example to .env and start Postgres with `pnpm services:up`.',
); );
} }
const ssl = readSsl();
return postgres(connectionString, { return postgres(connectionString, {
max: Number(process.env.DB_POOL_MAX ?? 10), max: Number(process.env.DB_POOL_MAX ?? 10),
idle_timeout: 20, idle_timeout: 20,
...(ssl ? { ssl } : {}),
}); });
} }
export function getPool(): postgres.Sql { export function getPool(): postgres.Sql {
const existing = globalForDb.__linkderPool; const existing = globalForDb.__linkdrPool;
if (existing) return existing; if (existing) return existing;
const created = createPool(); const created = createPool();
globalForDb.__linkderPool = created; globalForDb.__linkdrPool = created;
return created; return created;
} }
function getDb(): PostgresJsDatabase<typeof schema> { function getDb(): PostgresJsDatabase<typeof schema> {
const existing = globalForDb.__linkderDb; const existing = globalForDb.__linkdrDb;
if (existing) return existing; if (existing) return existing;
const created = drizzle(getPool(), { schema }); const created = drizzle(getPool(), { schema });
globalForDb.__linkderDb = created; globalForDb.__linkdrDb = created;
return created; return created;
} }
@@ -82,11 +129,11 @@ export const db: Db = new Proxy({} as Db, {
/** Close the pool. For scripts and test teardown — never call this from a request. */ /** Close the pool. For scripts and test teardown — never call this from a request. */
export async function closePool(): Promise<void> { export async function closePool(): Promise<void> {
const existing = globalForDb.__linkderPool; const existing = globalForDb.__linkdrPool;
if (!existing) return; if (!existing) return;
await existing.end(); await existing.end();
globalForDb.__linkderPool = undefined; globalForDb.__linkdrPool = undefined;
globalForDb.__linkderDb = undefined; globalForDb.__linkdrDb = undefined;
} }
export { schema }; export { schema };
+175
View File
@@ -0,0 +1,175 @@
/**
* Pull every externally-hosted image into our own bucket.
*
* pnpm assets:migrate
*
* The seed used to point at pravatar, picsum and Unsplash. That is fine for a
* scratch database and wrong for anything anybody is shown: those services rate
* limit, change what a URL returns, and go down — and when they do, a demo is a
* grid of broken images with no way to fix it in the moment.
*
* This fetches each one once, stores it under a DETERMINISTIC key, and rewrites
* the row to our own origin. Idempotent: a key that already exists is left
* alone, so re-running after a reseed costs one HEAD per object rather than
* re-downloading the internet.
*/
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import * as schema from './schema/index';
import { readSsl } from './client';
for (const line of readFileSync(new URL('../../../.env', import.meta.url), 'utf8').split('\n')) {
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
if (m?.[1]) process.env[m[1]] ??= m[2];
}
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
const region = process.env.SPACES_REGION;
const bucket = process.env.SPACES_BUCKET;
if (!region || !bucket || !process.env.SPACES_KEY || !process.env.SPACES_SECRET) {
throw new Error('Spaces is not configured. Set SPACES_REGION / BUCKET / KEY / SECRET.');
}
const origin = process.env.SPACES_CDN_URL || `https://${bucket}.${region}.digitaloceanspaces.com`;
const client = new S3Client({
region,
endpoint: `https://${region}.digitaloceanspaces.com`,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
});
const ssl = readSsl();
const pg = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(pg, { schema });
const EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
};
/** Already ours? Then there is nothing to fetch. */
function isLocal(u: string): boolean {
return u.startsWith(origin);
}
async function exists(key: string): Promise<boolean> {
try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return true;
} catch {
return false;
}
}
/**
* Fetch one image and store it, returning the URL it now lives at.
*
* The key is a hash of the SOURCE url, so the same source always lands on the
* same object: reseeding gives every pro a new uuid, and keying on that would
* fill the bucket with a fresh copy of every photo on every run.
*/
async function adopt(source: string, prefix: string): Promise<string | null> {
const hash = createHash('sha1').update(source).digest('hex').slice(0, 16);
const response = await fetch(source, { redirect: 'follow' });
if (!response.ok) {
console.warn(` ! ${response.status} ${source.slice(0, 60)}`);
return null;
}
const type = (response.headers.get('content-type') ?? 'image/jpeg').split(';')[0]!.trim();
const key = `${prefix}/${hash}.${EXT[type] ?? 'jpg'}`;
if (await exists(key)) return `${origin}/${key}`;
const body = Buffer.from(await response.arrayBuffer());
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: type,
// Public: these are the photos on the deck card. Credential documents are
// a different prefix and are never given an ACL.
ACL: 'public-read',
// A year. The key is content-addressed by source, so a changed image is a
// changed key rather than a stale cache.
CacheControl: 'public, max-age=31536000, immutable',
}),
);
console.log(` + ${(body.length / 1024).toFixed(0)}kb ${key}`);
return `${origin}/${key}`;
}
async function main() {
console.log(`Migrating assets into ${origin}\n`);
const media = await db
.select({ id: schema.proMedia.id, url: schema.proMedia.url, kind: schema.proMedia.kind })
.from(schema.proMedia);
const external = media.filter((m) => !isLocal(m.url));
console.log(`pro_media: ${media.length} rows, ${external.length} still external`);
// Sequential on purpose. Twenty parallel fetches against a free image host is
// how you get rate limited half way through and end up with a bucket that is
// partly migrated and rows that disagree with it.
let moved = 0;
for (const row of external) {
const hosted = await adopt(row.url, row.kind === 'photo' ? 'pro-media' : 'work-samples');
if (!hosted) continue;
await db
.update(schema.proMedia)
.set({ url: hosted })
.where(sql`${schema.proMedia.id} = ${row.id}`);
moved += 1;
}
// Job photos are uploaded by customers and already ours, but a seeded one
// could point outward too.
const jobs = await db
.select({ id: schema.jobs.id, photos: schema.jobs.photos })
.from(schema.jobs);
let jobPhotos = 0;
for (const job of jobs) {
const outward = job.photos.filter((p) => !isLocal(p));
if (outward.length === 0) continue;
const rewritten: string[] = [];
for (const photo of job.photos) {
rewritten.push(isLocal(photo) ? photo : ((await adopt(photo, 'job-photos')) ?? photo));
}
await db
.update(schema.jobs)
.set({ photos: rewritten })
.where(sql`${schema.jobs.id} = ${job.id}`);
jobPhotos += outward.length;
}
console.log(`\n${moved} pro images and ${jobPhotos} job photos now served from ${origin}`);
const left = (
await db.select({ url: schema.proMedia.url }).from(schema.proMedia)
).filter((m) => !isLocal(m.url));
if (left.length) console.warn(`${left.length} still external — rerun to retry.`);
}
await main();
await pg.end();
+5 -1
View File
@@ -3,13 +3,17 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator'; import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import postgres from 'postgres'; import postgres from 'postgres';
import { readSsl } from './client';
config({ path: '../../.env' }); config({ path: '../../.env' });
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set'); if (!url) throw new Error('DATABASE_URL is not set');
const client = postgres(url, { max: 1 }); // Same TLS rule as the app — a managed database is reached over the internet,
// and a migration is the last thing that should run unverified.
const ssl = readSsl();
const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(client); const db = drizzle(client);
// PostGIS must exist before any migration that declares a geography column. // PostGIS must exist before any migration that declares a geography column.
+1 -1
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared'; import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkdr/shared';
import type { Db } from '../client'; import type { Db } from '../client';
import { eligiblePro } from './eligibility'; import { eligiblePro } from './eligibility';
+1 -1
View File
@@ -1,5 +1,5 @@
import { sql, type SQL } from 'drizzle-orm'; import { sql, type SQL } from 'drizzle-orm';
import { score, type RankingInput } from '@linkder/shared'; import { score, type RankingInput } from '@linkdr/shared';
import type { Db } from '../client'; import type { Db } from '../client';
import { eligiblePro } from './eligibility'; import { eligiblePro } from './eligibility';
import type { DeckCard } from './deck'; import type { DeckCard } from './deck';
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Db } from '../client';
* Recompute the denormalised ranking counters on `pro_profiles`. * Recompute the denormalised ranking counters on `pro_profiles`.
* *
* `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and * `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and
* `avg_response_minutes` are inputs to `score()` in @linkder/shared, and until * `avg_response_minutes` are inputs to `score()` in @linkdr/shared, and until
* this existed nothing ever wrote them after the seed. The deck ranked on * this existed nothing ever wrote them after the seed. The deck ranked on
* numbers that were invented once and never moved, and the card told customers * numbers that were invented once and never moved, and the card told customers
* "usually replies in 25 min" on the strength of it. * "usually replies in 25 min" on the strength of it.
+3 -3
View File
@@ -9,7 +9,7 @@ import {
unique, unique,
uuid, uuid,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import { point } from '../postgis'; import { point } from '../postgis';
import { locationPrecision, userRole } from './enums'; import { locationPrecision, userRole } from './enums';
@@ -38,7 +38,7 @@ export const users = pgTable(
/** /**
* Required and unique by better-auth. Phone-first users get a synthetic * Required and unique by better-auth. Phone-first users get a synthetic
* address on a domain we control — ALWAYS gate outbound mail on * address on a domain we control — ALWAYS gate outbound mail on
* `isSyntheticEmail()` from @linkder/shared. Pros must supply a real * `isSyntheticEmail()` from @linkdr/shared. Pros must supply a real
* address during onboarding; clients may never have one. * address during onboarding; clients may never have one.
*/ */
email: text('email').notNull().unique(), email: text('email').notNull().unique(),
@@ -71,7 +71,7 @@ export const users = pgTable(
* "we do not know yet", and callers fall back to the city centre. * "we do not know yet", and callers fall back to the city centre.
*/ */
location: point('location'), location: point('location'),
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */ /** Human label for `location` — "Condesa, Ciudad de México". Display only; never matched on. */
locationText: text('location_text'), locationText: text('location_text'),
/** /**
* See jobs.location_precision. Lowest stakes of the three: this only centres * See jobs.location_precision. Lowest stakes of the three: this only centres
+3 -3
View File
@@ -7,10 +7,10 @@ import {
QUOTE_STATUSES, QUOTE_STATUSES,
REQUEST_STATUSES, REQUEST_STATUSES,
VERIFICATION_STATUSES, VERIFICATION_STATUSES,
} from '@linkder/shared'; } from '@linkdr/shared';
/** /**
* Enums mirror the status unions in @linkder/shared/state-machines. * Enums mirror the status unions in @linkdr/shared/state-machines.
* Importing them here means a new status cannot be added to the DB without * Importing them here means a new status cannot be added to the DB without
* also being added to the transition graph. * also being added to the transition graph.
*/ */
@@ -22,7 +22,7 @@ export const bookingStatus = pgEnum('booking_status', BOOKING_STATUSES);
export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES); export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES);
export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES); export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES);
export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']); export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']);
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkder/shared. */ /** How a stored point was obtained — see LOCATION_PRECISIONS in @linkdr/shared. */
export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS); export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS);
export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']); export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']);
export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']); export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']);
+1 -1
View File
@@ -9,7 +9,7 @@ import { users } from './auth';
* where an existing user has no preferences. Every column therefore defaults to * where an existing user has no preferences. Every column therefore defaults to
* the value we would use in the absence of a row. * the value we would use in the absence of a row.
* *
* These are read on every send — see `notify()` in @linkder/notify, which maps * These are read on every send — see `notify()` in @linkdr/notify, which maps
* each notification kind to the column that governs it and drops the message * each notification kind to the column that governs it and drops the message
* when the answer is false. * when the answer is false.
*/ */
+100 -49
View File
@@ -10,24 +10,51 @@ import { config } from 'dotenv';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; 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 * as schema from './schema/index';
import { recomputeAllProStats } from './queries/stats'; import { recomputeAllProStats } from './queries/stats';
import { readSsl } from './client';
config({ path: '../../.env' }); config({ path: '../../.env' });
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set'); 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 db = drizzle(client, { schema });
const CITY = { const CITY = {
name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Barcelona', name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Ciudad de México',
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), 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. */ /** 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) { function offset(lat: number, lng: number, metres: number, bearingDeg: number) {
const R = 6_371_000; const R = 6_371_000;
@@ -118,6 +145,19 @@ interface SeedPro {
cat: string; cat: string;
/** Free-text specialisms. Search matches these, so a few pros must have some. */ /** Free-text specialisms. Search matches these, so a few pros must have some. */
skills?: string[]; 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; distanceM: number;
rating: number | null; rating: number | null;
reviews: number; reviews: number;
@@ -129,43 +169,51 @@ interface SeedPro {
/** distanceM is measured from the city centre — deck tests assert against these. */ /** distanceM is measured from the city centre — deck tests assert against these. */
const PROS: SeedPro[] = [ const PROS: SeedPro[] = [
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, 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: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] },
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
skills: ['Bathroom fitting', 'Leak detection'] }, skills: ['Bathroom fitting', 'Leak detection'] },
{ name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 }, { name: 'Jorge Pineda', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', 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: '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. // 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: 'Pablo Rivas', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', 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: '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'] }, skills: ['EV chargers', 'Rewiring', 'Fuse boards'] },
{ name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 }, { name: 'Omar Campos', cat: 'electrician', photo: 'photo-1660330589693-99889d60181e', 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: 'Marta Villanueva', cat: 'electrician', photo: 'photo-1646640381839-02748ae8ddf0', 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: 'Sergio Bonilla', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', 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: 'Iván Serrano', cat: 'handyman', photo: 'photo-1698998882494-57c3e043f340', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
{ name: 'Ivan Serra', cat: 'handyman', 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: 'Elena Prat', cat: 'painter', 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: 'Toni Blanch', cat: 'painter', 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,
{ name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
skills: ['Fitted wardrobes', 'Listed buildings'] }, skills: ['Fitted wardrobes', 'Listed buildings'] },
{ name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 }, { name: 'Guillermo Cortés', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', 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: 'Javier Durán', cat: 'locksmith', photo: 'photo-1676630656246-3047520adfdf', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Xavi Duran', cat: 'locksmith', 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: 'Berta Lloret', cat: 'appliance-repair', 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: 'Adria Font', cat: 'appliance-repair', 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,
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
skills: ['Split units', 'Heat pumps'] }, 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. // 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: 'Néstor Bosque', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', 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 }, // 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. // 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. // 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. * 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 * 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 * 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. * 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 * 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 * cannot log in as — which makes the seed data invisible in the app it
* exists to fill. * 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, role: 'client' as const,
emailVerified: true, emailVerified: true,
phoneNumberVerified: true, phoneNumberVerified: true,
@@ -344,9 +392,9 @@ async function main() {
console.log(` ${clientRows.length} clients`); console.log(` ${clientRows.length} clients`);
await db.insert(schema.users).values({ await db.insert(schema.users).values({
name: 'Linkder Admin', name: 'Linkdr Admin',
email: 'admin@linkder.test', email: 'admin@linkder.test',
phoneNumber: '+34600009999', phoneNumber: '+525500009999',
role: 'admin', role: 'admin',
emailVerified: true, emailVerified: true,
}); });
@@ -364,7 +412,7 @@ async function main() {
.values({ .values({
name: p.name, name: p.name,
email: `pro${i + 1}@linkder.test`, 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, role: 'pro' as const,
emailVerified: true, emailVerified: true,
phoneNumberVerified: true, phoneNumberVerified: true,
@@ -406,18 +454,21 @@ async function main() {
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId }); await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
proRows.push({ id: user.id, seed: p, 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 * The card photo, and a wider crop of the same shot as the work sample.
// landscape stock scenery — a locksmith on a railway track — which makes the *
// deck unreadable as a people-picker no matter what the layout does. * Seeded with the ORIGINAL Unsplash url, then rewritten to our own bucket by
// pravatar serves ~70 portraits; i is 0-based and img is 1-based. * `pnpm assets:migrate`. Keeping the source here rather than a hardcoded
const portrait = (i % 70) + 1; * 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([ 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. // The second slot is genuinely for work: scenery is fine here.
proId: user.id, 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, kind: 'work_sample' as const,
position: 1, position: 1,
}, },
@@ -494,7 +545,7 @@ async function main() {
urgency: 'flexible' as const, urgency: 'flexible' as const,
location: { lat: CITY.lat, lng: CITY.lng }, location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const, locationPrecision: 'exact' as const,
addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`, addressText: streetAddress(10 + (k % 40), k),
status: 'completed' as const, status: 'completed' as const,
createdAt: new Date(at(k).getTime() - 6 * 86_400_000), createdAt: new Date(at(k).getTime() - 6 * 86_400_000),
}; };
@@ -510,7 +561,7 @@ async function main() {
urgency: 'this_week' as const, urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng }, location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const, locationPrecision: 'exact' as const,
addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`, addressText: streetAddress(60 + (k % 20), k + 3),
status: 'cancelled' as const, status: 'cancelled' as const,
createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000), createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000),
})), })),
@@ -623,7 +674,7 @@ async function main() {
budgetMaxCents: 20_000, budgetMaxCents: 20_000,
location: { lat: CITY.lat, lng: CITY.lng }, location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const, locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`, addressText: streetAddress(12),
}) })
.returning(); .returning();
console.log(` 1 open job at the city centre (${job?.id})`); console.log(` 1 open job at the city centre (${job?.id})`);
@@ -679,7 +730,7 @@ async function main() {
urgency: 'this_week' as const, urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng }, location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const, locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`, addressText: streetAddress(12),
status: c.status, status: c.status,
}) })
.returning(); .returning();
+30 -19
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database. * Integration test — runs against a live seeded database.
* *
* pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test * pnpm --filter @linkdr/db test
* *
* The seed places every pro at a known distance from the city centre, and the * The seed places every pro at a known distance from the city centre, and the
* fixture job sits exactly at the centre, so the expected deck is not "roughly * fixture job sits exactly at the centre, so the expected deck is not "roughly
@@ -52,23 +52,34 @@ describe('getDeck', () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
const names = deck.map((c) => c.name).sort(); const names = deck.map((c) => c.name).sort();
expect(names).toEqual(['Ana Ferrer', 'Jordi Puig', 'Marc Oliveras', 'Nil Bosch', 'Nuria Sala']); expect(names).toEqual([
'Alejandro Dávila',
'Antón Bautista',
'Gabriel Torres',
'Gerardo Solís',
'Jorge Pineda',
// Sorted by code unit, so accented letters land after plain ASCII ones.
'Norma Salgado',
'Néstor Bosque',
'Rogelio Amaya',
'Sergio Fabela',
]);
}); });
it('excludes a pro whose service radius does not reach the job', async () => { it('excludes a pro whose service radius does not reach the job', async () => {
// Pau Ribas is 22km away but only travels 5km. // Pablo Rivas is 22km away but only travels 5km.
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Pau Ribas'); expect(deck.map((c) => c.name)).not.toContain('Pablo Rivas');
}); });
it('excludes an unverified pro even though they are 1km away', async () => { it('excludes an unverified pro even though they are 1km away', async () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Unverified Ulla'); expect(deck.map((c) => c.name)).not.toContain('Unverified Ulises');
}); });
it('excludes a verified pro who is not accepting jobs', async () => { it('excludes a verified pro who is not accepting jobs', async () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Away Arnau'); expect(deck.map((c) => c.name)).not.toContain('Away Arturo');
}); });
it('excludes pros from other trades', async () => { it('excludes pros from other trades', async () => {
@@ -80,32 +91,32 @@ describe('getDeck', () => {
it('reports distance in metres, ascending-ish and sane', async () => { it('reports distance in metres, ascending-ish and sane', async () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
const marc = deck.find((c) => c.name === 'Marc Oliveras'); const sergi = deck.find((c) => c.name === 'Sergio Fabela');
expect(marc).toBeDefined(); expect(sergi).toBeDefined();
expect(marc!.distanceM).toBeGreaterThan(700); expect(sergi!.distanceM).toBeGreaterThan(1_000);
expect(marc!.distanceM).toBeLessThan(900); expect(sergi!.distanceM).toBeLessThan(1_200);
}); });
it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => { it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
const marc = deck.findIndex((c) => c.name === 'Marc Oliveras'); // 800m, 4.9 x47 const sergi = deck.findIndex((c) => c.name === 'Sergio Fabela'); // 1.1km, 4.8 x62
const nuria = deck.findIndex((c) => c.name === 'Nuria Sala'); // 18km, 5.0 x3 const nuria = deck.findIndex((c) => c.name === 'Norma Salgado'); // 18km, 5.0 x3
expect(marc).toBeLessThan(nuria); expect(sergi).toBeLessThan(nuria);
}); });
it('does not bury a brand-new unrated pro at the bottom', async () => { it('does not bury a brand-new unrated pro at the bottom', async () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
const nil = deck.findIndex((c) => c.name === 'Nil Bosch'); const nil = deck.findIndex((c) => c.name === 'Néstor Bosque');
expect(nil).toBeGreaterThanOrEqual(0); expect(nil).toBeGreaterThanOrEqual(0);
expect(nil).toBeLessThan(deck.length - 1); expect(nil).toBeLessThan(deck.length - 1);
}); });
it('carries the media and rating a card needs to render', async () => { it('carries the media and rating a card needs to render', async () => {
const deck = await getDeck(db, { jobId }); const deck = await getDeck(db, { jobId });
const card = deck.find((c) => c.name === 'Marc Oliveras')!; const card = deck.find((c) => c.name === 'Sergio Fabela')!;
expect(card.photos.length).toBeGreaterThan(0); expect(card.photos.length).toBeGreaterThan(0);
expect(card.ratingAvg).toBeCloseTo(4.9, 1); expect(card.ratingAvg).toBeCloseTo(4.8, 1);
expect(card.ratingCount).toBe(47); expect(card.ratingCount).toBe(62);
expect(card.hourlyRateCents).toBeGreaterThan(0); expect(card.hourlyRateCents).toBeGreaterThan(0);
}); });
@@ -184,8 +195,8 @@ describe('PostGIS round-trip', () => {
it('reads back the exact coordinates it wrote', async () => { it('reads back the exact coordinates it wrote', async () => {
const rows = await db.select().from(schema.jobs).limit(1); const rows = await db.select().from(schema.jobs).limit(1);
const job = rows[0]!; const job = rows[0]!;
expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), 4); expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), 4);
expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), 4); expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), 4);
}); });
it('never puts the client on their own deck', async () => { it('never puts the client on their own deck', async () => {
+16 -13
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database. * Integration test — runs against a live seeded database.
* *
* pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test * pnpm --filter @linkdr/db test
* *
* Search is a second door onto the same supply as the deck, so the test that * Search is a second door onto the same supply as the deck, so the test that
* matters most is the parity one: a pro who can be found here must be a pro who * matters most is the parity one: a pro who can be found here must be a pro who
@@ -19,7 +19,10 @@ const { searchPros } = await import('../src/queries/search');
const { getShowcaseDeck } = await import('../src/queries/deck'); const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places every pro relative to this point. */ /** The seed places every pro relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 }; const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let plumberId: string; let plumberId: string;
@@ -31,17 +34,17 @@ beforeAll(async () => {
plumberId = plumber.id; plumberId = plumber.id;
// Seeded skills are empty, so text search has nothing to match until we give // Seeded skills are empty, so text search has nothing to match until we give
// one pro something to find. Marc Oliveras is 800 m from the centre. // one pro something to find. Sergio Fabela is 1.1 km from the centre.
await db.execute(sql` await db.execute(sql`
UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts'] UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts']
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras') WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`); `);
}); });
afterAll(async () => { afterAll(async () => {
await db.execute(sql` await db.execute(sql`
UPDATE pro_profiles SET skills = '{}'::text[] UPDATE pro_profiles SET skills = '{}'::text[]
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras') WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`); `);
await closePool(); await closePool();
}); });
@@ -66,17 +69,17 @@ describe('searchPros', () => {
it('excludes the unverified, the away and the too-far', async () => { it('excludes the unverified, the away and the too-far', async () => {
const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name); const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name);
expect(names).not.toContain('Unverified Ulla'); expect(names).not.toContain('Unverified Ulises');
expect(names).not.toContain('Away Arnau'); expect(names).not.toContain('Away Arturo');
expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius expect(names).not.toContain('Pablo Rivas'); // 22 km out, 5 km radius
}); });
it('matches a skill', async () => { it('matches a skill', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map( const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map(
(p) => p.name, (p) => p.name,
); );
expect(names).toContain('Marc Oliveras'); expect(names).toContain('Sergio Fabela');
expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill expect(names).not.toContain('Martino Gómez'); // an electrician with no such skill
}); });
it('matches a trade name', async () => { it('matches a trade name', async () => {
@@ -86,8 +89,8 @@ describe('searchPros', () => {
}); });
it('matches a pro by name', async () => { it('matches a pro by name', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name); const names = (await searchPros(db, { ...CENTRE, q: 'fabela', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Marc Oliveras']); expect(names).toEqual(['Sergio Fabela']);
}); });
it('treats wildcards as literal characters', async () => { it('treats wildcards as literal characters', async () => {
@@ -105,7 +108,7 @@ describe('searchPros', () => {
it("honours the searcher's own distance limit", async () => { it("honours the searcher's own distance limit", async () => {
const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 }); const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 });
expect(near.every((p) => p.distanceM <= 3_000)).toBe(true); expect(near.every((p) => p.distanceM <= 3_000)).toBe(true);
expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out expect(near.map((p) => p.name)).not.toContain('Marta Villanueva'); // 9.1 km out
}); });
it('sorts by distance, price and rating', async () => { it('sorts by distance, price and rating', async () => {
+19 -16
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database. * Integration test — runs against a live seeded database.
* *
* pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test * pnpm --filter @linkdr/db test
* *
* getShowcaseDeck feeds the entry screen, which is the one deck an anonymous * getShowcaseDeck feeds the entry screen, which is the one deck an anonymous
* visitor sees. Its whole promise is the word "verified": nobody may appear in * visitor sees. Its whole promise is the word "verified": nobody may appear in
@@ -19,7 +19,10 @@ const { closePool, db } = await import('../src/client');
const { getShowcaseDeck } = await import('../src/queries/deck'); const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places the fixture job, and every distance, relative to this point. */ /** The seed places the fixture job, and every distance, relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 }; const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let names: (string | null)[]; let names: (string | null)[];
@@ -38,23 +41,23 @@ describe('getShowcaseDeck', () => {
}); });
it('excludes a pro who will not travel this far', () => { it('excludes a pro who will not travel this far', () => {
// Pau Ribas is seeded 22 km out with a 5 km service radius. // Pablo Rivas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pau Ribas'); expect(names).not.toContain('Pablo Rivas');
}); });
it('excludes a pro whose verification has not passed', () => { it('excludes a pro whose verification has not passed', () => {
// Unverified Ulla is 1 km away — close enough to prove distance is not // Unverified Ulises is 1 km away — close enough to prove distance is not
// what is keeping her out. // what is keeping her out.
expect(names).not.toContain('Unverified Ulla'); expect(names).not.toContain('Unverified Ulises');
}); });
it('excludes a verified pro who is not accepting work', () => { it('excludes a verified pro who is not accepting work', () => {
// Away Arnau is verified and nearby, but on holiday mode. // Away Arturo is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arnau'); expect(names).not.toContain('Away Arturo');
}); });
it('includes the nearest eligible pro', () => { it('includes the nearest eligible pro', () => {
expect(names).toContain('Marc Oliveras'); expect(names).toContain('Sergio Fabela');
}); });
it('honours the limit', async () => { it('honours the limit', async () => {
@@ -63,13 +66,13 @@ describe('getShowcaseDeck', () => {
}); });
it('honours the searcher own range, not just the pro one', async () => { it('honours the searcher own range, not just the pro one', async () => {
// Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel // Marta Villanueva is seeded 9.1 km out with a 30 km radius: she would travel
// here happily, but someone who said "within 3 km" did not ask for her. // here happily, but someone who said "within 3 km" did not ask for her.
const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 }); const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 });
const nearNames = near.map((c) => c.name); const nearNames = near.map((c) => c.name);
expect(nearNames).not.toContain('Marta Vidal'); expect(nearNames).not.toContain('Marta Villanueva');
expect(nearNames).toContain('Marc Oliveras'); // 800 m away expect(nearNames).toContain('Sergio Fabela'); // 1.1 km away
expect(near.every((c) => c.distanceM <= 3_000)).toBe(true); expect(near.every((c) => c.distanceM <= 3_000)).toBe(true);
}); });
@@ -97,11 +100,11 @@ describe('getShowcaseDeck', () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 }); const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
const filtered = cards.map((c) => c.name); const filtered = cards.map((c) => c.name);
// Pau Ribas is a plumber — he is kept out by radius, not by trade, so this // Pablo Rivas is a plumber — he is kept out by radius, not by trade, so this
// proves the category filter did not replace the eligibility rules. // proves the category filter did not replace the eligibility rules.
expect(filtered).not.toContain('Pau Ribas'); expect(filtered).not.toContain('Pablo Rivas');
expect(filtered).not.toContain('Unverified Ulla'); expect(filtered).not.toContain('Unverified Ulises');
expect(filtered).not.toContain('Away Arnau'); expect(filtered).not.toContain('Away Arturo');
}); });
it('never returns a card with a distance beyond that pros own radius', async () => { it('never returns a card with a distance beyond that pros own radius', async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"name": "@linkder/geocode", "name": "@linkdr/geocode",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -13,7 +13,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@linkder/shared": "workspace:*", "@linkdr/shared": "workspace:*",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
+3 -3
View File
@@ -1,10 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import type { LatLng, LocationPrecision } from '@linkder/shared'; import type { LatLng, LocationPrecision } from '@linkdr/shared';
/** /**
* Address → coordinates. * Address → coordinates.
* *
* Linkder matches on distance: `ST_Distance(p.base_location, j.location)` ranks * Linkdr matches on distance: `ST_Distance(p.base_location, j.location)` ranks
* every deck and `ST_DWithin(..., p.service_radius_m)` decides who is eligible * every deck and `ST_DWithin(..., p.service_radius_m)` decides who is eligible
* at all. Before this package both operands were the city centre for any user * at all. Before this package both operands were the city centre for any user
* who declined the browser's location prompt, so the ranking was ordering by * who declined the browser's location prompt, so the ranking was ordering by
@@ -44,7 +44,7 @@ function readConfig(): GeocodeConfig {
throw new GeocodeError('Geocoding is not configured. Missing: MAPBOX_TOKEN'); throw new GeocodeError('Geocoding is not configured. Missing: MAPBOX_TOKEN');
} }
// Bounding results to one country is a quality decision, not a security one: // Bounding results to one country is a quality decision, not a security one:
// "Carrer de Sants" matches in several places and the wrong continent is a // "Av. Juárez" matches in several places and the wrong continent is a
// worse answer than no answer. // worse answer than no answer.
return { token, country: process.env.MAPBOX_COUNTRY ?? 'es' }; return { token, country: process.env.MAPBOX_COUNTRY ?? 'es' };
} }

Some files were not shown because too many files have changed in this diff Show More