diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..fb12c61
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,54 @@
+# The build stage does `COPY . .`, so anything not excluded here ends up in an
+# image layer — and a layer is readable by anyone who can pull the image, even
+# if a later stage deletes the file.
+
+# ---- Secrets. Non-negotiable. ----
+# The platform injects the environment; a baked .env would ship live database
+# and Spaces credentials inside the image.
+.env
+.env.*
+!.env.example
+
+# ---- Build inputs that must be produced inside the image ----
+# A host node_modules is the wrong platform (linux/musl vs win32/darwin) and
+# would silently shadow the one `pnpm install` builds in the deps stage.
+node_modules
+**/node_modules
+.next
+**/.next
+.turbo
+**/.turbo
+dist
+**/dist
+out
+**/out
+
+# ---- Never needed at runtime ----
+.git
+.gitignore
+.github
+.vscode
+.idea
+**/test
+**/tests
+**/*.test.ts
+**/*.test.tsx
+**/vitest.config.ts
+playwright-report
+test-results
+coverage
+**/*.log
+.DS_Store
+Thumbs.db
+
+# Docs and local tooling. Keeping them out is about layer size and churn: a
+# README edit should not invalidate the build cache.
+*.md
+!README.md
+docker-compose.yml
+docker-compose.*.yml
+Dockerfile
+.dockerignore
+
+# NOTE: ca-certificate.crt is deliberately NOT ignored. DATABASE_CA_CERT may
+# point at it, and it is a public certificate — no private key. See db/client.ts.
diff --git a/.env.example b/.env.example
index cf1374a..7073ec9 100644
--- a/.env.example
+++ b/.env.example
@@ -97,9 +97,19 @@ NEXT_PUBLIC_CITY_LAT=19.4326
NEXT_PUBLIC_CITY_LNG=-99.1332
TWILIO_FROM_NUMBER=
-# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production.
+# Dev-only fixed login (+52 55 0000 0000 / code 000000). Ignored entirely when
+# NODE_ENV=production, so this cannot leak a bypass into a real deployment.
ALLOW_DEV_LOGIN=false
+# The SAME fixed login, deliberately allowed in a production build, for the
+# client-demo deployment only. Separate from ALLOW_DEV_LOGIN so that copying a
+# developer's .env into a real environment cannot switch it on by accident.
+#
+# Only +52 55 0000 0000 is affected; every other number still goes through
+# Twilio. Prints a loud warning on every boot. MUST be unset before this
+# platform accepts a real signup — see server/dev-login.ts.
+DEMO_LOGIN=false
+
# Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the
# client bundle. Leave blank to disable reporting entirely.
NEXT_PUBLIC_SENTRY_DSN=
diff --git a/DESIGN.md b/DESIGN.md
index f9fcc2c..7645bbe 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -458,6 +458,35 @@ A "use my current location" control sits below, and on success **must** fill the
reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to
check.
+### 6.17 Join as a pro
+
+The one screen in this product that has to sell something, written under a system that bans
+marketing heroes and feature grids (§9). The resolution is that it does not describe the
+offer — it **shows the thing being offered**, then asks for an account.
+
+Order is fixed:
+
+1. `overline` kicker, `h1`, one sentence. No stat bar, no logo wall.
+2. **The preview.** A non-interactive replica of an incoming job request, exactly as a pro
+ would receive it: trade, title, distance, urgency, budget, and the accept/decline pair.
+ It is the single raised element on the screen (§5) and it carries `aria-hidden`, because a
+ sample is not a control — a screen reader offering a fake Accept button is a trap.
+ Label it in visible text as an example; a mock that reads as live data is a lie.
+3. **How it works** — an ordered list, three items, numeral in a `brand-500` pill. An ordered
+ list, not a grid: these are sequential, and a grid would both break §4 and imply they are not.
+4. **What you will need** — the credentials `pro.submitForReview` actually gates on, with the
+ same required/optional split the wizard uses. Softening it here to raise sign-ups only moves
+ the drop-off to step four, where the person has already spent their time.
+5. The primary action, sticky at the bottom (§9).
+
+Every claim on this screen is load-bearing and must be traceable to behaviour that exists.
+"We verify ID, insurance and licence" is true; a response time or an earnings figure is not,
+and neither is a setup duration nobody has measured. Invent nothing here.
+
+The account note is required, not decorative: `user.setRole` refuses once a job has been
+posted, so a customer tapping this is creating a *second* account. A screen that lets them
+believe otherwise produces a support ticket at the worst possible moment.
+
---
## 7. Motion
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bda559c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,131 @@
+# syntax=docker/dockerfile:1.7
+
+###############################################################################
+# Linkdr — production image
+#
+# Four stages so that a code change does not reinstall the dependency tree:
+# `deps` is keyed on the lockfile alone, and Docker reuses it until that file
+# changes. Installing inside the same layer as the source would rebuild ~1GB of
+# node_modules on every commit.
+#
+# Node 22 rather than 23: 22 is the active LTS line, 23 is not and stops getting
+# fixes. package.json says >=20; this pins the version we actually ship.
+###############################################################################
+
+ARG NODE_VERSION=22.12.0-alpine
+
+# ─────────────────────────────── base ───────────────────────────────
+FROM node:${NODE_VERSION} AS base
+# libc6-compat: several native-ish npm packages ship glibc builds and fail on
+# musl without it. Cheap, and the failure it prevents is an obscure one.
+RUN apk add --no-cache libc6-compat
+
+# pnpm, pinned to the version in `packageManager` so the image builds with the
+# same resolver the lockfile was written by.
+#
+# `corepack enable` alone is not enough: the corepack bundled with Node 22.12
+# carries expired npm registry signing keys and dies with "Cannot find matching
+# keyid" before it ever downloads pnpm. Updating corepack first refreshes those
+# keys, and `prepare --activate` fetches the exact version rather than asking
+# the registry what "latest" is at build time.
+ARG PNPM_VERSION=9.15.4
+RUN npm install -g corepack@latest && corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
+WORKDIR /app
+
+# ─────────────────────────────── deps ───────────────────────────────
+# Every package.json in the workspace, and nothing else. Adding a source file
+# here would defeat the layer cache this stage exists for.
+FROM base AS deps
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./
+COPY apps/web/package.json apps/web/
+COPY packages/api/package.json packages/api/
+COPY packages/db/package.json packages/db/
+COPY packages/geocode/package.json packages/geocode/
+COPY packages/notify/package.json packages/notify/
+COPY packages/shared/package.json packages/shared/
+COPY packages/storage/package.json packages/storage/
+
+# --frozen-lockfile: a lockfile that does not match package.json is a build
+# failure, not something to silently resolve differently than developers did.
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
+ pnpm config set store-dir /pnpm/store && \
+ pnpm install --frozen-lockfile
+
+# ─────────────────────────────── build ──────────────────────────────
+FROM base AS build
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
+COPY --from=deps /app/packages ./packages
+COPY . .
+
+# NEXT_PUBLIC_* is inlined into the client bundle at BUILD time — reading it
+# from the container's environment at runtime is too late. Anything the browser
+# must know therefore has to arrive here as a build argument.
+ARG NEXT_PUBLIC_APP_URL
+ARG NEXT_PUBLIC_CITY_NAME
+ARG NEXT_PUBLIC_CITY_LAT
+ARG NEXT_PUBLIC_CITY_LNG
+ARG NEXT_PUBLIC_SENTRY_DSN
+ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
+ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \
+ NEXT_PUBLIC_CITY_NAME=$NEXT_PUBLIC_CITY_NAME \
+ NEXT_PUBLIC_CITY_LAT=$NEXT_PUBLIC_CITY_LAT \
+ NEXT_PUBLIC_CITY_LNG=$NEXT_PUBLIC_CITY_LNG \
+ NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
+
+ENV NEXT_TELEMETRY_DISABLED=1 \
+ NODE_ENV=production
+
+# Every page that touches the database is `force-dynamic`, so no database is
+# needed to build. If that ever stops being true this line is where it breaks,
+# loudly, rather than at deploy time.
+RUN pnpm --filter @linkdr/web build
+
+# ─────────────────────────────── tools ──────────────────────────────
+# Migrations, seeding and the asset migration. These need tsx, drizzle-kit and
+# the drizzle/ SQL folder, none of which belong in the image that serves
+# traffic — so they get their own target rather than bloating the runtime.
+#
+# Run as a one-shot alongside the app (see docker-compose.dokploy.yml), not as
+# a long-lived service:
+# docker compose run --rm migrate
+FROM base AS tools
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/packages ./packages
+COPY . .
+ENV NODE_ENV=production
+# Idempotent: drizzle records applied migrations, so re-running is a no-op and
+# a restarted container cannot double-apply anything.
+CMD ["pnpm", "--filter", "@linkdr/db", "migrate"]
+
+# ────────────────────────────── runtime ─────────────────────────────
+FROM base AS runtime
+WORKDIR /app
+
+ENV NODE_ENV=production \
+ NEXT_TELEMETRY_DISABLED=1 \
+ PORT=3000 \
+ HOSTNAME=0.0.0.0
+
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 nextjs
+
+# The standalone bundle ships its own minimal node_modules and server.js.
+# `public` and `.next/static` are NOT included in it and must be copied
+# separately, or the site renders with no CSS and no images.
+COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
+COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
+COPY --from=build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
+
+USER nextjs
+EXPOSE 3000
+
+# Talks to Postgres, so a container that cannot reach its database never enters
+# rotation. start-period covers first boot; see app/api/health/route.ts.
+HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+
+CMD ["node", "apps/web/server.js"]
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index e4cbb09..1dfb61c 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -1,21 +1,39 @@
+import path from 'node:path';
import { config as loadEnv } from 'dotenv';
import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next';
// The monorepo keeps one .env at the root; Next only looks in the app directory.
+// In a container the file does not exist and the platform supplies the
+// environment instead — dotenv never overwrites an already-set variable, so
+// this line is a no-op there rather than a conflict.
loadEnv({ path: '../../.env' });
const config: NextConfig = {
reactStrictMode: true,
+ /**
+ * Traces the server build and its used dependencies into
+ * `.next/standalone`, so the runtime image carries a node_modules with only
+ * what actually runs. Without it a Docker image for this monorepo has to ship
+ * every workspace's dev dependencies — drizzle-kit, vitest, eslint, the whole
+ * toolchain — to start one server.
+ *
+ * `outputFileTracingRoot` must point at the REPO root, not the app: pnpm
+ * hoists to a root `node_modules/.pnpm` store, and tracing from apps/web
+ * silently omits every symlinked workspace package.
+ */
+ output: 'standalone',
+ outputFileTracingRoot: path.join(__dirname, '../..'),
// The workspace packages ship TypeScript source, not build output.
transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'],
images: {
remotePatterns: [
- // Seed data only — real pros upload to R2. The deck renders a plain ,
- // so these matter only where next/image is used.
- { protocol: 'https', hostname: 'i.pravatar.cc' },
- { protocol: 'https', hostname: 'picsum.photos' },
- { protocol: 'https', hostname: '**.r2.dev' },
+ // Where everything is served from once `pnpm assets:migrate` has run.
+ { protocol: 'https', hostname: '**.digitaloceanspaces.com' },
+ { protocol: 'https', hostname: '**.cdn.digitaloceanspaces.com' },
+ // The seed writes source urls and the migration rewrites them, so a
+ // freshly seeded environment points here until that job has run.
+ { protocol: 'https', hostname: 'images.unsplash.com' },
],
},
// postgres-js opens raw sockets; it must not be bundled into the server chunk.
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
new file mode 100644
index 0000000..10b83e0
--- /dev/null
+++ b/apps/web/src/app/api/health/route.ts
@@ -0,0 +1,43 @@
+import { sql } from 'drizzle-orm';
+import { db } from '@linkdr/db';
+
+/**
+ * Liveness and readiness for the deployment platform.
+ *
+ * Dokploy's health check decides whether a new container replaces the running
+ * one. A check that only proves Node is listening will happily promote a
+ * container that cannot reach its database — the deploy goes green and every
+ * request 500s, which is strictly worse than a failed deploy because nothing
+ * rolls back.
+ *
+ * So this touches Postgres. It is one trivial round trip and it is the single
+ * dependency without which no page on this site renders.
+ *
+ * Not checked here on purpose:
+ * - Redis. Nothing in the request path needs it yet (see routers/message.ts,
+ * where the throttle is deliberately in-process until M4).
+ * - Spaces, Mapbox, Twilio. Third-party outages must not take our own
+ * container out of rotation and trigger a rollback loop.
+ */
+export const dynamic = 'force-dynamic';
+export const runtime = 'nodejs';
+
+export async function GET() {
+ const startedAt = Date.now();
+
+ try {
+ await db.execute(sql`select 1`);
+ } catch (error) {
+ // The message can carry a connection string. Log it, never return it.
+ console.error('[health] database unreachable', error);
+ return Response.json(
+ { status: 'error', database: 'unreachable' },
+ { status: 503, headers: { 'cache-control': 'no-store' } },
+ );
+ }
+
+ return Response.json(
+ { status: 'ok', database: 'ok', latencyMs: Date.now() - startedAt },
+ { status: 200, headers: { 'cache-control': 'no-store' } },
+ );
+}
diff --git a/apps/web/src/app/pro/join/page.tsx b/apps/web/src/app/pro/join/page.tsx
new file mode 100644
index 0000000..aafe447
--- /dev/null
+++ b/apps/web/src/app/pro/join/page.tsx
@@ -0,0 +1,191 @@
+import Link from 'next/link';
+import { Check, Clock, MapPin, ShieldCheck, X } from 'lucide-react';
+import { BackLink } from '@/components/chrome/back-link';
+import { buttonClasses } from '@/components/ui';
+
+export const metadata = { title: 'Join as a pro' };
+
+/**
+ * DESIGN.md §6.17.
+ *
+ * The old "Join as a pro" button went straight to /sign-in, which asks a
+ * tradesperson to create an account before telling them what for. This screen
+ * is what sits in between: it shows the job request they would receive, states
+ * what we check and what we will need from them, and only then asks.
+ *
+ * A route rather than a panel. The rest of the app is one screen with no routes
+ * inside it (see showcase-deck.tsx), but this is a one-way door out of the
+ * customer product into a separate account — the same journey /sign-in already
+ * takes, and it should be linkable and back-able like one.
+ *
+ * Public on purpose: sign-in comes AFTER, and a pitch you have to log in to
+ * read is not a pitch.
+ */
+
+/** Three sequential steps, not three features. §6.17 — an ordered list. */
+const STEPS = [
+ {
+ title: 'Tell us your trade',
+ body: 'What you do, where you are based, and how far you are willing to travel.',
+ },
+ {
+ title: 'We check you out',
+ body: 'A person reads your ID, insurance and licence by hand. It usually takes a day.',
+ },
+ {
+ title: 'Jobs start arriving',
+ body: 'Customers near you send work that matches your trade. Take the ones you want.',
+ },
+];
+
+/**
+ * What `pro.submitForReview` actually gates on, with the wizard's own
+ * required/optional split. Softening it here only moves the drop-off to step
+ * four, after the person has already spent their evening on it.
+ */
+const NEEDED = [
+ { label: 'Photo ID', note: 'Required' },
+ { label: 'Public liability insurance', note: 'Required' },
+ { label: 'Trade licence', note: 'If your trade needs one' },
+];
+
+export default function ProJoinPage() {
+ return (
+
+
+
+
For tradespeople
+
Get sent jobs near you
+
+ No bidding, no lead fees, no chasing. Verified customers send you the work directly and
+ you decide what to take.
+
+
+ {/*
+ The preview. The whole reason this screen exists: showing the thing beats
+ describing it, and it is the honest answer to "what would I actually get?"
+
+ aria-hidden and inert — a screen reader offering a fake Accept button is a
+ trap, and so is a sighted user tapping one. The caption above it says it is
+ an example in visible text, because a mock that reads as live data is a lie.
+ */}
+
+
+ An example request
+
+
+
+
+
+
+ Plumber
+
+
+
+ Today if possible
+
+
+
+
+ Kitchen sink leaking under the cupboard
+
+
+ Water pooling under the sink, seems to be the trap. Free most evenings this week.
+
+
+
+
+ 2.4 km away
+ ·
+ Budget $80–200
+
+
+ {/*
+ Shown as the shapes of the two buttons, not as buttons. §6.17 — the
+ point is recognition, and a real control here would be tappable.
+ */}
+
+
+
+ Decline
+
+
+
+ Accept
+
+
+
+
+
+ Accepting opens a private conversation. You agree a fixed price there before any work
+ starts — we never quote on your behalf.
+
+
+
How it works
+
+ {STEPS.map((step, i) => (
+
+
+ {i + 1}
+
+
+ {step.title}
+ {step.body}
+
+
+ ))}
+
+
+
What you will need
+
+ We check every pro before a single customer sees them. It is the only thing this
+ marketplace actually sells, so there is no way around this part.
+
+
+ {NEEDED.map((item) => (
+
+
+ {item.label}
+ {item.note}
+
+ ))}
+
+
+
+ You set your own hourly rate and how far you travel, and you can turn new work off
+ whenever you are busy.
+
+
+ {/*
+ §9 — the primary action sits low and stays in thumb reach. Sticky rather
+ than placed after the copy, because this screen is longer than the fold
+ and a CTA below three sections is a CTA nobody sees.
+ */}
+
+
+ Create your pro account
+
+ {/*
+ Not decorative. user.setRole refuses once a job has been posted, so a
+ customer tapping this is opening a SECOND account — finding that out
+ later is a support ticket at the worst possible moment.
+ */}
+
+ Working as a pro needs its own account. You keep your customer one for hiring.
+
+
+
+ );
+}
diff --git a/apps/web/src/app/profile-panel.tsx b/apps/web/src/app/profile-panel.tsx
index 6bbb8ba..9c8cc85 100644
--- a/apps/web/src/app/profile-panel.tsx
+++ b/apps/web/src/app/profile-panel.tsx
@@ -6,7 +6,7 @@ import { Card } from '@/components/deck';
import { SignedOut } from '@/components/chrome/signed-out';
import { SkillsGroup } from '@/components/profile/skills-group';
import { Banner, SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
-import { api, type RouterOutputs } from '@/lib/trpc';
+import { api } from '@/lib/trpc';
/**
* The Profile tab.
@@ -35,15 +35,13 @@ export function ProfilePanel() {
);
}
- return me.data.role === 'pro' ? : ;
+ return me.data.role === 'pro' ? : ;
}
function Shell({ children }: { children: React.ReactNode }) {
return
{children}
;
}
-type Me = RouterOutputs['user']['me'];
-
/* ─────────────────────────────── pro ─────────────────────────────── */
/** What each verification status means commercially — this is the row that decides
@@ -79,6 +77,27 @@ const STATUS: Record<
},
};
+/**
+ * §6.11. The banner, the card and a group — in that order and at those sizes,
+ * so nothing jumps sideways when the two queries land.
+ */
+function ProSkeleton() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+/** "Uploaded", or the reason nobody can see you yet. */
+function Required({ present }: { present: boolean }) {
+ return present ? <>Uploaded> : Missing;
+}
+
function ProProfile() {
const utils = api.useUtils();
const preview = api.pro.previewCard.useQuery();
@@ -94,7 +113,7 @@ function ProProfile() {
if (preview.isLoading || profile.isLoading) {
return (
-
+
);
}
@@ -124,25 +143,38 @@ function ProProfile() {
return (
Your card
-
Exactly what customers see when they swipe.
+
Exactly what customers see when they swipe.
+
+ {/*
+ Above the card, not below it.
+ Whether the card is on the deck at all outranks what is printed on it —
+ a rejected pro reading this in order used to meet a polished preview of
+ something nobody can see, and only then the sentence explaining why.
+ */}
+
+ {status.body}
+
{/*
The real , not a lookalike — a copy would drift the moment either
side changed, and the whole point is that a pro can trust this preview.
No onDecide, so it renders static and non-draggable.
+
+ A ratio rather than the 420px it used to be pinned at. Card is
+ `absolute inset-0`, so it needs a definite height from its parent, and a
+ fixed one made the preview a different shape from the real thing on
+ every screen that was not the one it was measured on — which for a
+ preview sold as "exactly what customers see" is the one thing it must
+ not do.
*/}
-
+
-
+
The distance shown is an example — customers see how far you are from their own job.
-
- {status.body}
-
-
-
+
+ {/* "Card details", not "Your card" — that is the h1 four hundred pixels
+ above, and two headings with one name meant two different things. */}
+ {/*
+ A missing ID or insurance certificate is the whole reason a draft pro
+ is not earning, and it used to render in the same grey as the optional
+ licence line. The colour goes on the STATE, not the label: "Photo ID"
+ is not the problem, "Missing" is.
+ */}
-
-
+ } />
+ } />
@@ -210,47 +250,59 @@ function ProProfile() {
/* ───────────────────────────── client ────────────────────────────── */
-function ClientProfile({ me }: { me: Me }) {
- const jobs = api.job.mine.useQuery();
-
+function ClientProfile() {
return (
-
Your profile
-
-
-
-
-
-
-
{/*
- The most valuable thing on an otherwise empty screen. A marketplace with
- no pros has no product, so recruiting supply beats decorating a client
- profile that has nothing on it.
+ Full height, with the card taking whatever the title leaves. Three rows
+ went from here — Name, Phone and Jobs posted — because the first two are
+ editable in Settings and were dead facts here, and a customer with two
+ screens showing their name, one of which does nothing when tapped, learns
+ that tapping things on this screen does nothing. The third is the Jobs
+ tab's entire subject.
+
+ Removing them left the card stranded at the top above six hundred pixels
+ of nothing, which reads as a screen that failed to load rather than one
+ with little to say. Centring it in the space makes the emptiness look
+ chosen, because it is: a customer profile genuinely has nothing on it,
+ and the recruitment card is the screen's real job.
*/}
-
-
-
- For tradespeople
-
-
Work with us
-
- Get sent local jobs that match your trade. We check every pro’s ID, licence and
- insurance, so customers arrive ready to book.
+
+
Your profile
+
+ Your name, contact details and notifications live in Settings.
- {/*
- user.setRole refuses once a job has been posted, so this must not read
- as a switch that flips this account over.
- */}
-
- Working as a pro needs its own account — you keep this one for hiring.
-
-
- Join as a pro
-
+
+
+ {/*
+ A marketplace with no pros has no product, so recruiting supply beats
+ decorating a client profile that has nothing on it.
+ */}
+
+
+
+ For tradespeople
+
+
Work with us
+
+ Get sent local jobs that match your trade. We check every pro’s ID, licence
+ and insurance, so customers arrive ready to book.
+
+ {/*
+ user.setRole refuses once a job has been posted, so this must not
+ read as a switch that flips this account over.
+ */}
+
+ Working as a pro needs its own account — you keep this one for hiring.
+
+
+ Join as a pro
+
+
+
);
diff --git a/apps/web/src/app/settings-panel.tsx b/apps/web/src/app/settings-panel.tsx
index 3510ff5..4d51159 100644
--- a/apps/web/src/app/settings-panel.tsx
+++ b/apps/web/src/app/settings-panel.tsx
@@ -151,11 +151,21 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) =>
offer, and settings is where somebody goes when they are looking for
something they have not found.
*/
-
+
+ {/*
+ /pro/join, not /pro/onboarding. Onboarding redirects a client role
+ straight back out to the role chooser, so the row pointed at a page
+ this reader can never reach — and it implied their account would
+ become a pro account, which `setRole` refuses once they have posted
+ a job. /pro/join is the recruitment page that explains both.
+ */}
)}
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
index d1d6a57..18d634b 100644
--- a/apps/web/src/lib/auth.ts
+++ b/apps/web/src/lib/auth.ts
@@ -29,13 +29,26 @@ import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login';
* unvalidated. The only real alternative is delegating the whole flow to Twilio
* Verify, which we chose not to do.
*/
+/**
+ * `next build` imports this module to collect route metadata, and it does so
+ * with NODE_ENV=production but none of the runtime secrets — a build machine
+ * has no business holding a session key. Without this distinction the two
+ * guards below turn every containerised build into a failure, and the only way
+ * out is baking AUTH_SECRET into an image layer, which is worse than the
+ * problem they exist to prevent.
+ *
+ * Next sets NEXT_PHASE for the duration of the build and never at runtime, so
+ * the checks still fire on a real boot.
+ */
+const isBuildPhase = process.env.NEXT_PHASE === 'phase-production-build';
+
/**
* Without a secret, better-auth silently falls back to a built-in default —
* which would mean every deployment signs sessions with the same publicly known
* key. Fail the boot instead.
*/
const secret = process.env.AUTH_SECRET;
-if (!secret && process.env.NODE_ENV === 'production') {
+if (!secret && process.env.NODE_ENV === 'production' && !isBuildPhase) {
throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32');
}
@@ -48,7 +61,7 @@ if (!secret && process.env.NODE_ENV === 'production') {
*/
const appUrl =
process.env.NEXT_PUBLIC_APP_URL ??
- (process.env.NODE_ENV === 'production'
+ (process.env.NODE_ENV === 'production' && !isBuildPhase
? (() => {
throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.');
})()
diff --git a/apps/web/src/server/dev-login.ts b/apps/web/src/server/dev-login.ts
index a0cb34d..065f973 100644
--- a/apps/web/src/server/dev-login.ts
+++ b/apps/web/src/server/dev-login.ts
@@ -20,8 +20,51 @@ import { db, schema } from '@linkdr/db';
const DEV_PHONE = '+525500000000';
const DEV_CODE = '000000';
+/**
+ * DEMO_LOGIN — the same fixed login, deliberately permitted in a production
+ * BUILD, for the client-demo deployment at linkdr.serfaty.site.
+ *
+ * This is a login bypass running under NODE_ENV=production and there is no way
+ * to dress that up. It is a separate variable from ALLOW_DEV_LOGIN on purpose:
+ * the two say different things, and someone copying a dev `.env` into a real
+ * environment must not be able to enable this by accident. Guard 3 still holds
+ * — only DEV_PHONE is affected, every other number goes through Twilio.
+ *
+ * What makes it acceptable HERE and nowhere else: that deployment contains
+ * nothing but seeded fixtures, and the account it opens is a seeded customer.
+ * There is no real person's data behind it.
+ *
+ * Before this platform takes a real signup, DEMO_LOGIN must be unset and this
+ * block deleted. The boot warning below exists so that is impossible to
+ * forget: it prints on every single start.
+ */
+const demoLogin = process.env.DEMO_LOGIN === 'true';
+
+if (demoLogin && process.env.NODE_ENV === 'production') {
+ console.warn(
+ `
+ ############################################################
+` +
+ ` # DEMO_LOGIN IS ON IN A PRODUCTION BUILD. #
+` +
+ ` # ${DEV_PHONE} signs in with a fixed code and NO SMS. #
+` +
+ ` # This is for the client demo only. Unset DEMO_LOGIN #
+` +
+ ` # before this platform accepts a real signup. #
+` +
+ ` ############################################################
+`,
+ );
+}
+
export function isDevLoginEnabled(): boolean {
- return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true';
+ // Local development: as before.
+ if (process.env.NODE_ENV !== 'production') {
+ return process.env.ALLOW_DEV_LOGIN === 'true';
+ }
+ // Production: only the explicit demo flag, never ALLOW_DEV_LOGIN.
+ return demoLogin;
}
export function isDevLoginPhone(phone: string): boolean {
diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml
new file mode 100644
index 0000000..7e824dd
--- /dev/null
+++ b/docker-compose.dokploy.yml
@@ -0,0 +1,180 @@
+# Linkdr — Dokploy deployment stack.
+#
+# Separate from docker-compose.yml, which exists only to give a developer a
+# Postgres and a Redis on their laptop. Merging the two would mean one file
+# that is wrong in both places.
+#
+# In Dokploy: create a **Compose** service, point it at this file, paste the
+# variables from DEPLOY.md into the Environment tab, then Deploy.
+#
+# Traefik is Dokploy's ingress. It routes by the labels on `app` below and
+# joins containers on the shared `dokploy-network`, which is why that network
+# is declared external — Dokploy created it, this stack only attaches to it.
+
+services:
+ # ─────────────────────────────── database ───────────────────────────────
+ # PostGIS, not plain Postgres. Every distance in this product is
+ # `ST_Distance` over a `geography(Point,4326)` column, and the first
+ # migration declares one — vanilla postgres:17 fails on migration 0001.
+ postgres:
+ image: postgis/postgis:17-3.5
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-linkdr}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
+ POSTGRES_DB: ${POSTGRES_DB:-linkdr}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ # No `ports:` on purpose. The database is reachable on the compose network
+ # by every service that needs it; publishing 5432 puts it on the public
+ # internet of the droplet.
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-linkdr} -d ${POSTGRES_DB:-linkdr}']
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+ networks: [internal]
+
+ # ──────────────────────────────── redis ─────────────────────────────────
+ # Not on the request path yet (routers/message.ts throttles in-process until
+ # M4). Here so the SSE fan-out and BullMQ queues have somewhere to land
+ # without a second deploy.
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ command: redis-server --appendonly yes
+ volumes:
+ - redisdata:/data
+ healthcheck:
+ test: ['CMD', 'redis-cli', 'ping']
+ interval: 10s
+ timeout: 3s
+ retries: 10
+ networks: [internal]
+
+ # ────────────────────────────── migrations ──────────────────────────────
+ # Runs to completion and exits. `app` waits for it, so a container can never
+ # serve traffic against a schema older than the code inside it.
+ #
+ # Idempotent — drizzle records what it has applied, so a redeploy re-runs
+ # this and it does nothing.
+ migrate:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: tools
+ restart: 'no'
+ environment:
+ DATABASE_URL: ${DATABASE_URL}
+ DATABASE_CA_CERT: ${DATABASE_CA_CERT:-}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks: [internal]
+
+ # ──────────────────────────────── the app ───────────────────────────────
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: runtime
+ # NEXT_PUBLIC_* is inlined into the browser bundle when `next build`
+ # runs, so these MUST be build args. Setting them only under
+ # `environment:` below leaves the client bundle holding whatever was
+ # baked in — usually localhost — and sign-in breaks in a way that looks
+ # like a cookie bug.
+ args:
+ NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
+ NEXT_PUBLIC_CITY_NAME: ${NEXT_PUBLIC_CITY_NAME}
+ NEXT_PUBLIC_CITY_LAT: ${NEXT_PUBLIC_CITY_LAT}
+ NEXT_PUBLIC_CITY_LNG: ${NEXT_PUBLIC_CITY_LNG}
+ NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ migrate:
+ condition: service_completed_successfully
+ environment:
+ NODE_ENV: production
+ PORT: '3000'
+ HOSTNAME: 0.0.0.0
+
+ DATABASE_URL: ${DATABASE_URL}
+ DATABASE_CA_CERT: ${DATABASE_CA_CERT:-}
+ REDIS_URL: ${REDIS_URL:-redis://redis:6379}
+
+ # better-auth derives cookie domain and Secure flag from this. An http://
+ # value here on an https:// site produces a login that appears to succeed
+ # and then has no session — see lib/auth.ts.
+ AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
+ AUTH_URL: ${AUTH_URL}
+ NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
+
+ AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-}
+ AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-}
+ AUTH_MICROSOFT_ID: ${AUTH_MICROSOFT_ID:-}
+ AUTH_MICROSOFT_SECRET: ${AUTH_MICROSOFT_SECRET:-}
+ AUTH_MICROSOFT_TENANT_ID: ${AUTH_MICROSOFT_TENANT_ID:-common}
+ AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-}
+ AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-}
+
+ TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-}
+ TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-}
+ TWILIO_VERIFY_SERVICE_SID: ${TWILIO_VERIFY_SERVICE_SID:-}
+ TWILIO_FROM_NUMBER: ${TWILIO_FROM_NUMBER:-}
+
+ MAPBOX_TOKEN: ${MAPBOX_TOKEN:-}
+ MAPBOX_COUNTRY: ${MAPBOX_COUNTRY:-mx}
+
+ SPACES_REGION: ${SPACES_REGION:-nyc3}
+ SPACES_BUCKET: ${SPACES_BUCKET:-}
+ SPACES_KEY: ${SPACES_KEY:-}
+ SPACES_SECRET: ${SPACES_SECRET:-}
+ SPACES_CDN_URL: ${SPACES_CDN_URL:-}
+
+ RESEND_API_KEY: ${RESEND_API_KEY:-}
+ EMAIL_FROM: ${EMAIL_FROM:-noreply@linkdr.serfaty.site}
+
+ STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
+ STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
+ PLATFORM_FEE_BPS: ${PLATFORM_FEE_BPS:-1500}
+
+ NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
+
+ # Read by dev-login.ts, which ALSO requires NODE_ENV !== 'production'.
+ # With NODE_ENV=production above, the fixed +52 55 0000 0000 / 000000
+ # login is off no matter what this says. See DEPLOY.md → "Signing in".
+ ALLOW_DEV_LOGIN: 'false'
+ networks: [internal, dokploy-network]
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=dokploy-network
+ # Dokploy's Traefik terminates TLS; the container speaks plain HTTP.
+ - traefik.http.services.linkdr.loadbalancer.server.port=3000
+ - traefik.http.routers.linkdr.rule=Host(`linkdr.serfaty.site`)
+ - traefik.http.routers.linkdr.entrypoints=websecure
+ - traefik.http.routers.linkdr.tls=true
+ - traefik.http.routers.linkdr.tls.certresolver=letsencrypt
+ # Send :80 to :443 rather than serving the app on both. Auth cookies are
+ # Secure, so the http origin cannot hold a session anyway.
+ - traefik.http.routers.linkdr-web.rule=Host(`linkdr.serfaty.site`)
+ - traefik.http.routers.linkdr-web.entrypoints=web
+ - traefik.http.routers.linkdr-web.middlewares=linkdr-https
+ - traefik.http.middlewares.linkdr-https.redirectscheme.scheme=https
+ - traefik.http.middlewares.linkdr-https.redirectscheme.permanent=true
+
+volumes:
+ pgdata:
+ redisdata:
+
+networks:
+ # Private to this stack. Postgres and Redis are reachable here and nowhere else.
+ internal:
+ # Created by Dokploy for Traefik. Only `app` joins it.
+ dokploy-network:
+ external: true