Move the demo market to Mexico City, priced in US dollars

The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 10:56:31 -04:00
co-authored by Claude Opus 5
parent 5086a238ea
commit 1808ad4cba
113 changed files with 1944 additions and 472 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ loadEnv({ path: '../../.env' });
const config: NextConfig = {
reactStrictMode: true,
// 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: {
remotePatterns: [
// Seed data only — real pros upload to R2. The deck renders a plain <img>,
+6 -6
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/web",
"name": "@linkdr/web",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -12,11 +12,11 @@
"test": "vitest run"
},
"dependencies": {
"@linkder/api": "workspace:*",
"@linkder/db": "workspace:*",
"@linkder/notify": "workspace:*",
"@linkder/shared": "workspace:*",
"@linkder/storage": "workspace:*",
"@linkdr/api": "workspace:*",
"@linkdr/db": "workspace:*",
"@linkdr/notify": "workspace:*",
"@linkdr/shared": "workspace:*",
"@linkdr/storage": "workspace:*",
"@sentry/nextjs": "^10.70.0",
"@tanstack/react-query": "^5.62.0",
"@trpc/client": "^11.18.0",
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function AdminProPage({ params }: { params: Promise<{ proId
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-body-sm">
<Row label="Status" value={pro.profile.verificationStatus} />
<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="Service area"
+1 -1
View File
@@ -39,7 +39,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<header className="sticky top-0 z-10 border-b border-hairline bg-page/95 backdrop-blur-[12px]">
<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">
Linkder admin
Linkdr admin
</Link>
<span className="text-meta text-faint">
Signed in as {me.name ?? me.email ?? 'admin'}
+2 -2
View File
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/nextjs';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from '@linkder/api';
import { db } from '@linkder/db';
import { appRouter, createContext } from '@linkdr/api';
import { db } from '@linkdr/db';
import { resolveSession } from '@/server/session';
/**
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useState } from 'react';
import type { DeckCard } from '@linkder/db';
import type { DeckCard } from '@linkdr/db';
import { Deck } from '@/components/deck';
import { api } from '@/lib/trpc';
+2 -2
View File
@@ -182,14 +182,14 @@ export function NewJobForm({
<FieldSet label="Budget (optional)">
<div className="grid grid-cols-2 gap-3">
<Field label="From ">
<Field label="From $">
<Input
value={budgetMin}
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
/>
</Field>
<Field label="To ">
<Field label="To $">
<Input
value={budgetMax}
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
+28 -7
View File
@@ -3,6 +3,7 @@ import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
import { TRPCProvider } from '@/lib/trpc';
import { ToastProvider } from '@/components/ui';
import { PhoneFrame } from '@/components/chrome/phone-frame';
import { ProjectPanel } from '@/components/chrome/project-panel';
import '@/styles/globals.css';
/**
@@ -24,12 +25,12 @@ const text = Wix_Madefor_Text({
export const metadata: Metadata = {
title: {
default: 'Linkder — hire a verified local pro',
template: '%s · Linkder',
default: 'Linkdr — hire a verified local pro',
template: '%s · Linkdr',
},
description:
'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 = {
@@ -57,12 +58,32 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<TRPCProvider>
<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
inside the screen. Putting it in one page meant sign-in, onboarding
and the job form all escaped the frame.
inside the screen — sign-in, onboarding and the job form included.
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">
<PhoneFrame>{children}</PhoneFrame>
<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>
</div>
<div className="min-w-0 flex-1 bg-page">
<ProjectPanel />
</div>
</div>
</ToastProvider>
</TRPCProvider>
+1 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic';
* The entry screen.
*
* 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
* being able to do it.
*
+3 -3
View File
@@ -9,7 +9,7 @@ import {
DEFAULT_SERVICE_RADIUS_M,
MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M,
} from '@linkder/shared';
} from '@linkdr/shared';
import { cn } from '@/lib/utils';
import { uploadFile } from '@/lib/upload';
import {
@@ -167,7 +167,7 @@ export function OnboardingWizard({
{step === 1 && (
<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
value={headline}
onChange={(e) => setHeadline(e.target.value)}
@@ -186,7 +186,7 @@ export function OnboardingWizard({
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Hourly rate ()">
<Field label="Hourly rate ($)">
<Input
value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
+1 -1
View File
@@ -64,7 +64,7 @@ export default async function ProHomePage() {
}
/>
<Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
<Stat label="Rate" value={`${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
<Stat label="Rate" value={`$${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
</dl>
</AppShell>
);
+1 -1
View File
@@ -176,7 +176,7 @@ function ProProfile() {
value={String(p.media.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
label="Service area"
value={`${Math.round(p.serviceRadiusM / 1000)} km`}
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import type { DeckCard } from '@linkder/db';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc';
import { useDebouncedValue } from '@/lib/use-debounced-value';
import { EmptyState } from '@/components/ui';
+56 -53
View File
@@ -1,11 +1,12 @@
'use client';
import { useState } from 'react';
import { SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
import { SettingsGroup, SettingsRow, SettingsToggle, useToast } from '@/components/ui';
import { api, type RouterOutputs } from '@/lib/trpc';
import { SignedOut } from '@/components/chrome/signed-out';
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.
@@ -13,11 +14,15 @@ import { authClient } from '@/lib/auth-client';
* 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.
*/
export function SettingsPanel() {
export function SettingsPanel({ onNavigate }: { onNavigate?: (tab: PhoneTab) => void }) {
const me = api.user.me.useQuery(undefined, { retry: false });
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) {
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 }) {
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>
{children}
</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'];
function SignedIn({ me }: { me: Me }) {
function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) => void }) {
const utils = api.useUtils();
const toast = useToast();
const prefs = api.notification.get.useQuery();
const updatePrefs = api.notification.update.useMutation({
onMutate: async (next) => {
@@ -55,34 +75,21 @@ function SignedIn({ me }: { me: Me }) {
},
onError: (_e, _next, context) => {
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(),
});
const sessions = api.user.sessions.useQuery();
const requestDeletion = api.user.requestDeletion.useMutation();
const [deletionAsked, setDeletionAsked] = useState(false);
const p = prefs.data;
const isPro = me.role === 'pro';
return (
<PanelShell>
<SettingsGroup title="Account">
<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>
<AccountSection me={me} />
<LocationGroup isPro={isPro} />
@@ -119,8 +126,21 @@ function SignedIn({ me }: { me: Me }) {
</SettingsGroup>
{isPro && (
<SettingsGroup title="Working" note="Changing your trades sends your profile back for review.">
<SettingsRow label="Trades" hint="Edit in your profile" onClick={() => {}} />
<SettingsGroup
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>
)}
@@ -139,8 +159,13 @@ function SignedIn({ me }: { me: Me }) {
</SettingsGroup>
<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
label="Download my data"
hint="Everything we hold about you, as JSON"
@@ -150,36 +175,14 @@ function SignedIn({ me }: { me: Me }) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'linkder-data.json';
a.download = 'linkdr-data.json';
a.click();
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>
<button
type="button"
onClick={async () => {
await authClient.signOut();
window.location.href = '/';
}}
className={buttonClasses({ variant: 'outline', size: 'md', block: true })}
>
Sign out
</button>
<AccountFooter />
</PanelShell>
);
}
+12 -3
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
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 { Chip, ScrollStrip } from '@/components/ui';
import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs';
@@ -167,7 +167,7 @@ export function ShowcaseDeck({
return (
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden">
{tab === 'settings' ? (
<SettingsPanel />
<SettingsPanel onNavigate={setTab} />
) : tab === 'profile' ? (
<ProfilePanel />
) : tab === 'search' ? (
@@ -251,7 +251,16 @@ export function ShowcaseDeck({
{/* Inside the phone frame, not the page — the sheet belongs to this
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
pro={asking}
+1 -1
View File
@@ -7,7 +7,7 @@ export const metadata = { title: 'Sign in' };
export default function SignInPage() {
return (
<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>
<p className="mt-3 text-body text-muted">
We will text you a 6-digit code. No password to forget.
+1 -1
View File
@@ -69,7 +69,7 @@ export function SignInForm() {
autoComplete="tel"
inputMode="tel"
required
placeholder="+34 600 123 456"
placeholder="+52 55 1234 5678"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
/>
@@ -0,0 +1,636 @@
'use client';
import { useState } from 'react';
import {
BadgeCheck,
CalendarCheck,
Check,
Copy,
MapPin,
MessagesSquare,
Star,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* The right-hand column: what the app in the phone beside it actually is.
*
* Static on purpose. The client reads this while USING the product in the left
* column, so nothing here navigates, nothing here is a screenshot, and nothing
* here moves when they swipe.
*
* Content lives in the arrays below rather than in the markup, so adding a
* feature or swapping a dependency is one line and the layout is untouched.
*
* Both languages live in the SAME entry rather than in two parallel documents —
* a decision and its `today` line have to move together, and the fastest way to
* end up with a Spanish half-truth is to let two copies of this file drift.
*/
type Lang = 'en' | 'es';
/** One string in both languages. Everything the client reads is one of these. */
type Copy = { en: string; es: string };
const LANGS: { code: Lang; label: string }[] = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
];
const DEMO = { phone: '+525500000000', code: '000000' };
const UI = {
language: { en: 'Language', es: 'Idioma' },
copy: { en: 'Copy', es: 'Copiar' },
copied: { en: 'copied', es: 'copiado' },
today: { en: 'Today: ', es: 'Hoy: ' },
} satisfies Record<string, Copy>;
const INTRO = {
title: {
en: 'Hire a tradesperson the way you swipe',
es: 'Contrata a un profesional deslizando',
},
body: {
en: 'A mobile marketplace connecting customers with verified local trades. Post a job, swipe through pros who cover your street, agree a price in chat, book the slot and review each other afterwards.',
es: 'Un marketplace móvil que conecta a clientes con profesionales locales verificados. Publica un trabajo, desliza entre los profesionales que cubren tu calle, acuerda un precio en el chat, reserva la cita y valoraos después.',
},
} satisfies Record<string, Copy>;
const TRY = {
heading: { en: 'Try it yourself', es: 'Pruébalo tú mismo' },
body: {
en: 'Sign in on the phone to the left. The whole product runs in there.',
es: 'Inicia sesión en el móvil de al lado. El producto entero funciona ahí dentro.',
},
phone: { en: 'Mobile', es: 'Móvil' },
code: { en: 'Code', es: 'Código' },
} satisfies Record<string, Copy>;
const STACK: { group: Copy; items: string[] }[] = [
{
group: { en: 'App', es: 'App' },
items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'],
},
{ group: { en: 'API', es: 'API' }, items: ['tRPC v11', 'Zod', 'better-auth'] },
{
group: { en: 'Data', es: 'Datos' },
items: ['DO Managed Postgres', 'PostGIS', 'Drizzle ORM', 'DO Managed Redis'],
},
{
group: { en: 'Services', es: 'Servicios' },
items: ['DO Spaces (S3)', 'Mapbox', 'Twilio', 'Resend', 'Sentry'],
},
{ group: { en: 'Tooling', es: 'Herramientas' }, items: ['Turborepo', 'pnpm', 'Vitest'] },
];
const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [
{
icon: Zap,
title: { en: 'Swipe to hire', es: 'Desliza para contratar' },
body: { en: 'Send a job with one gesture.', es: 'Envía un trabajo con un solo gesto.' },
},
{
icon: MapPin,
title: { en: 'Real distance', es: 'Distancia real' },
body: {
en: 'PostGIS ranks by metres, not postcodes.',
es: 'PostGIS ordena por metros, no por códigos postales.',
},
},
{
icon: BadgeCheck,
title: { en: 'Verified pros', es: 'Profesionales verificados' },
body: {
en: 'ID, insurance and licence checked first.',
es: 'Identidad, seguro y licencia comprobados antes de entrar.',
},
},
{
icon: MessagesSquare,
title: { en: 'Chat per job', es: 'Un chat por trabajo' },
body: { en: 'Private, with photos and receipts.', es: 'Privado, con fotos y recibos.' },
},
{
icon: CalendarCheck,
title: { en: 'Quote to booking', es: 'Del presupuesto a la reserva' },
body: {
en: 'Agree a price, book the slot, confirm.',
es: 'Acordáis un precio, se reserva la cita y se confirma.',
},
},
{
icon: Star,
title: { en: 'Blind reviews', es: 'Valoraciones a ciegas' },
body: {
en: 'Hidden until both sides have written.',
es: 'Ocultas hasta que ambas partes han escrito.',
},
},
];
/**
* The open questions, grouped.
*
* Every one of these has a CURRENT behaviour — nothing here is unbuilt because
* it was forgotten. `today` says what happens if nobody decides, which is the
* only honest way to present a decision: the client is confirming or changing
* something, not filling in a blank.
*/
const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [
{
group: { en: 'Money', es: 'Dinero' },
items: [
{
q: {
en: 'Do we hold the money until the job is done, or do customers pay the pro directly?',
es: '¿Retenemos el dinero hasta que el trabajo esté hecho, o el cliente paga directamente al profesional?',
},
today: {
en: 'Nothing is charged. Quotes and bookings work; no payment is taken at any point.',
es: 'No se cobra nada. Los presupuestos y las reservas funcionan; no se cobra en ningún momento.',
},
},
{
q: {
en: 'What is the commission, and who pays it — the customer, the pro, or split?',
es: '¿Cuál es la comisión y quién la paga: el cliente, el profesional o a medias?',
},
today: {
en: 'Set to 15% in config, applied nowhere.',
es: 'Fijada al 15% en la configuración, aplicada en ninguna parte.',
},
},
{
q: {
en: 'Deposit up front, or the whole amount on completion?',
es: '¿Señal por adelantado o el importe completo al terminar?',
},
today: {
en: 'Neither. The slot is booked on a promise.',
es: 'Ninguna de las dos. La cita se reserva con una promesa.',
},
},
{
q: {
en: 'A customer cancels the day before — what do they owe?',
es: 'Un cliente cancela el día antes: ¿qué debe pagar?',
},
today: {
en: 'Free up to 24h before, then 25%. The rule is written and tested; no money moves.',
es: 'Gratis hasta 24 h antes, después el 25%. La regla está escrita y probada; no se mueve dinero.',
},
},
],
},
{
group: { en: 'Scheduling', es: 'Agenda' },
items: [
{
q: {
en: 'Do pros publish real availability, or is a time agreed in the chat?',
es: '¿Los profesionales publican disponibilidad real, o se acuerda la hora en el chat?',
},
today: {
en: 'Agreed in chat. The customer picks any date and time when accepting a quote.',
es: 'Se acuerda en el chat. El cliente elige cualquier fecha y hora al aceptar un presupuesto.',
},
},
{
q: {
en: 'Should the system stop a pro being double-booked?',
es: '¿Debe el sistema impedir que un profesional tenga dos reservas a la vez?',
},
today: {
en: 'No check. Two customers can book the same pro for the same hour.',
es: 'No hay ninguna comprobación. Dos clientes pueden reservar al mismo profesional a la misma hora.',
},
},
{
q: {
en: 'If a customer never confirms the work is finished, should it auto-confirm?',
es: 'Si el cliente nunca confirma que el trabajo está terminado, ¿debe confirmarse solo?',
},
today: {
en: 'It waits forever. A 72-hour rule is written but nothing runs it.',
es: 'Espera para siempre. Hay una regla de 72 horas escrita, pero nada la ejecuta.',
},
},
],
},
{
group: { en: 'Trust and safety', es: 'Confianza y seguridad' },
items: [
{
q: {
en: 'Should we block phone numbers and emails in chat?',
es: '¿Bloqueamos teléfonos y correos en el chat?',
},
today: {
en: 'Anything can be sent. Two people can agree to take the job off the platform.',
es: 'Se puede enviar cualquier cosa. Dos personas pueden acordar sacar el trabajo de la plataforma.',
},
},
{
q: {
en: 'What happens when the two sides disagree about finished work?',
es: '¿Qué pasa cuando las dos partes no se ponen de acuerdo sobre un trabajo terminado?',
},
today: {
en: 'A disputed state exists in the model. Nothing can reach it.',
es: 'Existe un estado «en disputa» en el modelo. Nada puede llegar a él.',
},
},
{
q: {
en: 'ID checks — automated, or a person reviewing documents?',
es: 'Verificación de identidad: ¿automática o revisada por una persona?',
},
today: {
en: 'A person. Documents are uploaded and reviewed by hand in the admin queue.',
es: 'Una persona. Los documentos se suben y se revisan a mano en la cola de administración.',
},
},
{
q: {
en: 'A pros insurance expires. Do they come off the platform automatically?',
es: 'El seguro de un profesional caduca. ¿Sale de la plataforma automáticamente?',
},
today: {
en: 'The expiry date is stored. Nothing checks it.',
es: 'La fecha de caducidad se guarda. Nada la comprueba.',
},
},
],
},
{
group: { en: 'Launch', es: 'Lanzamiento' },
items: [
{
q: {
en: 'Launch with the trades we have supply for, or all fifty?',
es: '¿Lanzamos con los oficios para los que hay oferta, o con los cincuenta?',
},
today: {
en: 'Fifty trades listed; eight have any pros. The rest look empty to a customer.',
es: 'Hay cincuenta oficios listados; ocho tienen profesionales. El resto se ven vacíos para un cliente.',
},
},
{
q: {
en: 'One city, or several from the start?',
es: '¿Una ciudad o varias desde el principio?',
},
today: {
en: 'One. The city is a setting, so a second is configuration rather than a rebuild.',
es: 'Una. La ciudad es un ajuste, así que una segunda es configuración, no rehacer nada.',
},
},
{
q: {
en: 'Which events are worth an SMS, given each one costs money?',
es: '¿Qué eventos merecen un SMS, teniendo en cuenta que cada uno cuesta dinero?',
},
today: {
en: 'A pro is texted about a new job and an answer. Messages and bookings are silent.',
es: 'Al profesional se le avisa por SMS de un trabajo nuevo y de una respuesta. Los mensajes y las reservas son silenciosos.',
},
},
],
},
];
/**
* Running costs, paid to DigitalOcean rather than to us.
*
* Listed per line rather than as one number because they scale independently —
* the database is the first thing that needs a bigger tier, and storage is the
* only one that grows with use.
*/
const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [
{
item: { en: 'Managed Postgres', es: 'Postgres gestionado' },
detail: { en: 'The database, with PostGIS', es: 'La base de datos, con PostGIS' },
usd: 15,
},
{
item: { en: 'Managed Redis', es: 'Redis gestionado' },
detail: { en: 'Sessions, caching, job queue', es: 'Sesiones, caché y cola de trabajos' },
usd: 15,
},
{
item: { en: 'App Platform', es: 'App Platform' },
detail: { en: 'Runs the app itself', es: 'Ejecuta la propia aplicación' },
usd: 24,
},
{
item: { en: 'Spaces', es: 'Spaces' },
detail: { en: 'Photos and documents', es: 'Fotos y documentos' },
usd: 5,
},
];
const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0);
const SECTIONS = {
features: { en: 'What it does', es: 'Qué hace' },
stack: { en: 'Built with', es: 'Hecho con' },
decisions: { en: 'Still to decide', es: 'Aún por decidir' },
decisionsBody: {
en: 'Everything below already has a behaviour. These are the ones worth choosing deliberately rather than inheriting.',
es: 'Todo lo de abajo ya tiene un comportamiento. Estas son las decisiones que conviene tomar a propósito en lugar de heredarlas.',
},
cost: { en: 'Delivery and cost', es: 'Entrega y coste' },
build: { en: 'To build and launch', es: 'Construirlo y lanzarlo' },
buildNote: {
en: 'One-off. Where it lands depends on the answers above.',
es: 'Pago único. Dónde caiga depende de las respuestas de arriba.',
},
timeline: { en: 'Timeline', es: 'Plazo' },
timelineValue: { en: '610 weeks', es: '610 semanas' },
timelineNote: {
en: 'Six if the open questions are settled early, ten if they are not.',
es: 'Seis si las preguntas abiertas se cierran pronto, diez si no.',
},
hosting: { en: 'Hosting, per month', es: 'Alojamiento, al mes' },
total: { en: 'Total', es: 'Total' },
perMonth: { en: '/mo', es: '/mes' },
} satisfies Record<string, Copy>;
/** The three caveats under the hosting table — lead sentence, then the rest. */
const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [
{
lead: { en: 'You pay DigitalOcean directly.', es: 'Pagas directamente a DigitalOcean.' },
rest: {
en: ' This is not part of our fee and there is no markup on it — the account is yours, so you can see the bill and change the plan without going through us.',
es: ' No forma parte de nuestros honorarios y no lleva ningún recargo: la cuenta es tuya, así que puedes ver la factura y cambiar de plan sin pasar por nosotros.',
},
},
{
lead: {
en: `$${HOSTING_TOTAL} is the smallest tier of each.`,
es: `${HOSTING_TOTAL} $ es el plan más pequeño de cada uno.`,
},
rest: {
en: ' Enough to launch on and to run while the platform is finding its first customers.',
es: ' Suficiente para lanzar y para funcionar mientras la plataforma consigue sus primeros clientes.',
},
},
{
lead: {
en: 'Costs rise with use, unevenly.',
es: 'Los costes suben con el uso, de forma desigual.',
},
rest: {
en: ' The database is the first thing that will need a larger plan; storage creeps up slowly as photos accumulate; the app itself can stay where it is for a long time.',
es: ' La base de datos es lo primero que necesitará un plan mayor; el almacenamiento crece despacio a medida que se acumulan fotos; la aplicación en sí puede quedarse donde está mucho tiempo.',
},
},
];
export function ProjectPanel() {
const [lang, setLang] = useState<Lang>('en');
const t = (copy: Copy) => copy[lang];
return (
// `lang` on the wrapper, not only in state: it is what tells a screen reader
// which voice to read this in and a browser which dictionary to hyphenate by.
<div lang={lang} className="flex flex-col gap-10 px-6 py-10 lg:px-12 lg:py-14">
{/* Above the title it changes, so the client sees the switch before they
have started reading. Right-aligned: it is a control on the panel, not
a heading of it, and the title keeps the left edge to itself. */}
<div className="flex justify-end">
<div
role="group"
aria-label={t(UI.language)}
className="inline-flex gap-0.5 rounded-pill border border-hairline bg-raised p-1"
>
{LANGS.map(({ code, label }) => (
<button
key={code}
type="button"
lang={code}
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={cn(
'rounded-pill px-3 py-1 text-meta',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
lang === code
? 'bg-accent-soft font-semibold text-accent'
: 'text-muted hover:text-strong',
)}
>
{label}
</button>
))}
</div>
</div>
<header>
<p className="text-overline uppercase text-accent">Linkdr</p>
<h1 className="mt-2 text-h1">{t(INTRO.title)}</h1>
<p className="mt-3 max-w-[60ch] text-body text-muted text-pretty">{t(INTRO.body)}</p>
</header>
<section>
<h2 className="mb-2 text-h3">{t(TRY.heading)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(TRY.body)}</p>
{/* Side by side: two short values do not need two full-width rows.
The code column is content-width so the number keeps the room. */}
<div className="flex max-w-lg flex-wrap gap-2">
<CopyRow
label={t(TRY.phone)}
value={DEMO.phone}
copyLabel={t(UI.copy)}
copiedLabel={t(UI.copied)}
className="min-w-56 flex-1"
/>
<CopyRow
label={t(TRY.code)}
value={DEMO.code}
copyLabel={t(UI.copy)}
copiedLabel={t(UI.copied)}
className="shrink-0"
/>
</div>
</section>
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.features)}</h2>
{/* Tight two-column grid: small icon, title and its line on one row.
Read standing up, mid-sentence — so it has to scan, not be read. */}
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{FEATURES.map((f) => (
// Keyed on the English string throughout: the key has to survive the
// toggle, or React remounts every row on a language change.
<div key={f.title.en} className="flex items-start gap-2.5">
<f.icon className="mt-0.5 h-4 w-4 shrink-0 text-accent" aria-hidden />
<span className="min-w-0">
<dt className="inline font-semibold text-body-sm text-strong">{t(f.title)}</dt>
<dd className="inline text-body-sm text-muted"> {t(f.body)}</dd>
</span>
</div>
))}
</dl>
</section>
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.stack)}</h2>
<div className="flex flex-col gap-3">
{STACK.map((row) => (
<div key={row.group.en} className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
{/* w-20, not w-16: "Herramientas" is twice the width of "Tooling". */}
<span className="w-20 shrink-0 text-meta text-faint">{t(row.group)}</span>
{row.items.map((item) => (
<span
key={item}
className="rounded-pill border border-hairline px-3 py-1 text-meta text-strong"
>
{item}
</span>
))}
</div>
))}
</div>
</section>
<section>
<h2 className="mb-1 text-h3">{t(SECTIONS.decisions)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(SECTIONS.decisionsBody)}</p>
<div className="flex flex-col gap-6">
{DECISIONS.map((section) => (
<div key={section.group.en}>
<h3 className="mb-2 text-overline uppercase text-faint">{t(section.group)}</h3>
<ul className="flex flex-col gap-3">
{section.items.map((item) => (
<li key={item.q.en} className="border-l-2 border-hairline pl-3">
<p className="text-body-sm font-semibold text-strong text-pretty">
{t(item.q)}
</p>
<p className="mt-0.5 text-meta text-muted text-pretty">
<span className="text-faint">{t(UI.today)}</span>
{t(item.today)}
</p>
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* Last, and deliberately after the decisions: the range IS the answer to
those questions, so quoting a single number above them would be a
promise made before the scope exists. */}
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.cost)}</h2>
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
<p className="text-meta text-faint">{t(SECTIONS.build)}</p>
<p className="mt-1 font-display text-h2 text-strong tabular-nums">$4,4006,000</p>
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.buildNote)}</p>
</div>
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
<p className="text-meta text-faint">{t(SECTIONS.timeline)}</p>
<p className="mt-1 font-display text-h2 text-strong tabular-nums">
{t(SECTIONS.timelineValue)}
</p>
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.timelineNote)}</p>
</div>
</div>
<h3 className="mb-2 text-overline uppercase text-faint">{t(SECTIONS.hosting)}</h3>
<ul className="flex flex-col gap-1.5">
{HOSTING.map((h) => (
<li key={h.item.en} className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(h.item)}</span>
<span className="min-w-0 flex-1 truncate text-meta text-muted">{t(h.detail)}</span>
<span className="shrink-0 text-strong tabular-nums">${h.usd}</span>
</li>
))}
<li className="mt-1.5 flex items-baseline gap-3 border-t border-hairline pt-2 text-body-sm">
<span className="flex-1 font-semibold text-strong">{t(SECTIONS.total)}</span>
<span className="shrink-0 font-display text-h4 text-strong tabular-nums">
${HOSTING_TOTAL}
{t(SECTIONS.perMonth)}
</span>
</li>
</ul>
<div className="mt-3 flex max-w-[60ch] flex-col gap-1.5 text-meta text-muted">
{HOSTING_NOTES.map((note) => (
<p key={note.lead.en}>
<span className="font-semibold text-strong">{t(note.lead)}</span>
{t(note.rest)}
</p>
))}
</div>
</section>
</div>
);
}
/**
* A value to hand over verbatim, with one tap to copy.
*
* Reading a phone number off a screen into a form while somebody watches is a
* small humiliation; mistyping one in front of a client is a worse one.
*/
function CopyRow({
label,
value,
copyLabel,
copiedLabel,
className,
}: {
label: string;
value: string;
copyLabel: string;
copiedLabel: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-hairline bg-raised px-4 py-2.5',
className,
)}
>
<span className="shrink-0 text-meta text-faint">{label}</span>
<code className="min-w-0 flex-1 truncate font-mono text-body-sm text-strong tabular-nums">
{value}
</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard can be refused (insecure origin). The value is on screen
// and readable, which is the fallback that always works.
}
}}
// The state is in the accessible name too, not only the icon — §8.
aria-label={copied ? `${label} ${copiedLabel}` : `${copyLabel} ${label.toLowerCase()}`}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
copied ? 'text-go-600' : 'text-muted hover:bg-inset hover:text-accent',
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/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';
/** Horizontal drag past this many pixels commits the swipe. */
@@ -264,7 +264,7 @@ export function Card({
<MapPin className="h-4 w-4" aria-hidden />
{formatDistance(card.distanceM)}
</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>}
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import type { DeckCard } from '@linkder/db';
import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc';
import { Banner, Button, Sheet, Textarea } from '@/components/ui';
import { setPendingHire } from '@/lib/pending-hire';
@@ -2,8 +2,8 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Check } from 'lucide-react';
import type { DeckCard } from '@linkder/db';
import { AlertTriangle, Check, CheckCircle2 } from 'lucide-react';
import type { DeckCard } from '@linkdr/db';
import { api } from '@/lib/trpc';
import { SocialSignIn } from '@/components/auth/social-sign-in';
import { Banner, Button, Sheet } from '@/components/ui';
@@ -22,6 +22,7 @@ export function SendJobSheet({
pro,
open,
onResolved,
onViewJobs,
}: {
pro: DeckCard | null;
open: boolean;
@@ -30,9 +31,20 @@ export function SendJobSheet({
* `dismissed` when nothing happened and the card should come back.
*/
onResolved: (outcome: 'sent' | 'dismissed') => void;
/** Take them to the conversation they just started. */
onViewJobs: () => void;
}) {
const router = useRouter();
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 sendable = api.deck.sendable.useQuery(
@@ -44,10 +56,15 @@ export function SendJobSheet({
const utils = api.useUtils();
const swipe = api.deck.swipe.useMutation({
onSuccess: () => {
onSuccess: (result, variables) => {
void utils.job.mine.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 = () => {
setChosen(null);
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 ── */
if (!me.isLoading && (me.error || !me.data)) {
return (
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { CalendarClock, CheckCircle2, FileText } from 'lucide-react';
import { formatCents } from '@linkder/shared';
import { formatCents } from '@linkdr/shared';
import { api } from '@/lib/trpc';
import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui';
import { cn, formatWhen } from '@/lib/utils';
@@ -265,7 +265,7 @@ function QuoteSheet({
}
>
<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
value={amount}
inputMode="decimal"
+1 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
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 { Banner, buttonClasses, EmptyState } from '@/components/ui';
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
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';
export type Perspective = 'client' | 'pro';
@@ -1,7 +1,7 @@
'use client';
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 { Button, EmptyState, ScrollStrip, Tag } from '@/components/ui';
import { ReviewList } from './review-list';
@@ -106,7 +106,7 @@ export function ProProfilePanel({
<MapPin className="h-3.5 w-3.5" aria-hidden />
{formatDistance(pro.distanceM)}
</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>
{completedJobs > 0 && <span>{completedJobs} jobs done</span>}
</div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from '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 { Button, FormError, Input, SettingsGroup } from '@/components/ui';
@@ -1,7 +1,7 @@
'use client';
import { ChevronRight, Star } from 'lucide-react';
import type { DeckCard } from '@linkder/db';
import type { DeckCard } from '@linkdr/db';
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>{formatDistance(pro.distanceM)}</span>
<span aria-hidden>·</span>
<span>{(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
<span>${(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
{pro.categories[0] && (
<>
<span aria-hidden>·</span>
@@ -1,7 +1,7 @@
'use client';
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';
/**
@@ -1,8 +1,8 @@
'use client';
import { SlidersHorizontal } from 'lucide-react';
import type { SearchSort } from '@linkder/shared';
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkder/shared';
import type { SearchSort } from '@linkdr/shared';
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkdr/shared';
import { Chip } from '@/components/ui';
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,
MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M,
} from '@linkder/shared';
} from '@linkdr/shared';
import { api } from '@/lib/trpc';
import {
AddressField,
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from '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 { useDebouncedValue } from '@/lib/use-debounced-value';
import { cn } from '@/lib/utils';
+1 -1
View File
@@ -64,7 +64,7 @@ export function Banner({ tone = 'info', title, children, className, role }: Bann
/** Inline form error. §6.2 — always announced. */
export function FormError({ children }: { children: React.ReactNode }) {
return (
<p role="alert" className="text-body-sm text-stop-500">
<p role="alert" className="text-body-sm text-danger">
{children}
</p>
);
+1 -1
View File
@@ -62,7 +62,7 @@ export function Field({
{hint && <span className="text-meta text-muted">{hint}</span>}
{children}
{error && (
<span role="alert" className="text-body-sm text-stop-500">
<span role="alert" className="text-body-sm text-danger">
{error}
</span>
)}
+56 -20
View File
@@ -1,9 +1,16 @@
'use client';
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
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({
title,
note,
@@ -14,7 +21,7 @@ export function SettingsGroup({
children: React.ReactNode;
}) {
return (
<section className="mb-6">
<section className="mb-8">
<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">
{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({
label,
value,
hint,
href,
onClick,
danger,
disabled,
@@ -36,34 +55,48 @@ export function SettingsRow({
label: string;
value?: React.ReactNode;
hint?: string;
href?: string;
onClick?: () => void;
danger?: boolean;
disabled?: boolean;
}) {
const interactive = Boolean(onClick) && !disabled;
const Tag = interactive ? 'button' : 'div';
const interactive = Boolean(href ?? onClick) && !disabled;
return (
<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',
)}
>
const inner = (
<>
<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}
</span>
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
</span>
{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 />}
</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}
onClick={() => onChange(!checked)}
className={cn(
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
rowClasses,
interactiveClasses,
disabled && 'pointer-events-none opacity-45',
)}
>
@@ -105,7 +138,10 @@ export function SettingsToggle({
className={cn(
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
'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
+4 -4
View File
@@ -2,8 +2,8 @@ import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, phoneNumber } from 'better-auth/plugins';
import { nextCookies } from 'better-auth/next-js';
import { db, schema } from '@linkder/db';
import { isE164 } from '@linkder/shared';
import { db, schema } from '@linkdr/db';
import { isE164 } from '@linkdr/shared';
import { sendVerificationSms } from '@/server/sms';
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
* written, one handset holds two "unique" accounts, a ban is escapable by
* 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
* /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
* 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
* need payout statements, tax records and dispute notices. Clients stay
* phone-only and get SMS receipts.
+1 -1
View File
@@ -12,7 +12,7 @@
* pasted into a chat, and this is nobody else's business. Not `localStorage`
* either — an intent from last Tuesday is not an intent.
*/
const KEY = 'linkder:pending-hire';
const KEY = 'linkdr:pending-hire';
export interface PendingHire {
proId: string;
+1 -1
View File
@@ -6,7 +6,7 @@ import { httpBatchLink } from '@trpc/client';
import { createTRPCReact, type CreateTRPCReact } from '@trpc/react-query';
import { deserialize, serialize } from 'superjson';
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
// type cannot be named from here (TS2742).
+1 -1
View File
@@ -1,4 +1,4 @@
import type { UploadKind } from '@linkder/storage';
import type { UploadKind } from '@linkdr/storage';
interface PresignResult {
url: string;
+2 -2
View File
@@ -1,7 +1,7 @@
import { cache } from 'react';
import { headers } from 'next/headers';
import { appRouter, createCallerFactory, createInnerContext } from '@linkder/api';
import { db } from '@linkder/db';
import { appRouter, createCallerFactory, createInnerContext } from '@linkdr/api';
import { db } from '@linkdr/db';
import { resolveSession } from './session';
const createCaller = createCallerFactory(appRouter);
+2 -2
View File
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '@linkder/db';
import { db, schema } from '@linkdr/db';
/**
* 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.
*/
const DEV_PHONE = '+34600000000';
const DEV_PHONE = '+525500000000';
const DEV_CODE = '000000';
export function isDevLoginEnabled(): boolean {
+2 -2
View File
@@ -1,6 +1,6 @@
import { and, eq, ne, sql } from 'drizzle-orm';
import { db, schema } from '@linkder/db';
import { isSyntheticEmail } from '@linkder/shared';
import { db, schema } from '@linkdr/db';
import { isSyntheticEmail } from '@linkdr/shared';
/**
* Duplicate-account detection.
+5 -5
View File
@@ -1,15 +1,15 @@
import { eq } from 'drizzle-orm';
import type { Session, SessionResolver } from '@linkder/api';
import { db, schema } from '@linkder/db';
import type { Role, VerificationStatus } from '@linkder/shared';
import type { Session, SessionResolver } from '@linkdr/api';
import { db, schema } from '@linkdr/db';
import type { Role, VerificationStatus } from '@linkdr/shared';
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
* 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.
*
* Two responsibilities beyond "who is this":
+3 -3
View File
@@ -1,9 +1,9 @@
import { sendSms } from '@linkder/notify';
import { sendSms } from '@linkdr/notify';
/**
* SMS delivery for one-time codes.
*
* 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
* code and a "somebody wants to hire you" text have no business going out
* 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> {
await sendSms(
to,
`${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`,
`${code} is your Linkdr code. It expires in 5 minutes. We will never ask you for it.`,
);
}
+13 -1
View File
@@ -1,7 +1,7 @@
@import 'tailwindcss';
/*
* 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
* in a component; change them here or add a step to the ramp.
*/
@@ -138,6 +138,14 @@
--accent: var(--color-brand-500);
--accent-hover: var(--color-brand-600);
--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) {
@@ -154,6 +162,9 @@
--accent: var(--color-brand-400);
--accent-hover: var(--color-brand-200);
--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-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-danger: var(--danger);
}
@layer base {
+3 -3
View File
@@ -2,7 +2,7 @@
* Auth integration test — runs against the live seeded database.
*
* 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
* 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' });
const { closePool, db, schema } = await import('@linkder/db');
const { closePool, db, schema } = await import('@linkdr/db');
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. */
const PHONE = '+34699000111';
+2 -2
View File
@@ -56,7 +56,7 @@ describe('beforeSend', () => {
it('drops cookies and headers entirely', () => {
const event = {
request: {
url: 'https://linkder.app/api',
url: 'https://linkdr.app/api',
cookies: { session: 'live-credential' },
headers: { authorization: 'Bearer live-credential' },
},
@@ -88,7 +88,7 @@ describe('beforeSend', () => {
it('scrubs request body data and the query string', () => {
const event = {
request: {
url: 'https://linkder.app/verify?code=123456',
url: 'https://linkdr.app/verify?code=123456',
query_string: 'code=123456',
data: { phoneNumber: '+34600111222', code: '123456' },
},