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:
+18
-13
@@ -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=
|
||||||
|
|||||||
@@ -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 360–430px viewport and nothing else. On a wider
|
responsive breakpoint work. Design for a 360–430px 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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, ''))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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, ''))}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 pro’s 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: '6–10 weeks', es: '6–10 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,400–6,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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,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"
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import type { UploadKind } from '@linkder/storage';
|
import type { UploadKind } from '@linkdr/storage';
|
||||||
|
|
||||||
interface PresignResult {
|
interface PresignResult {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,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,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,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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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}
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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,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,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';
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 pro’s own radius', async () => {
|
it('never returns a card with a distance beyond that pro’s own radius', async () => {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user