Containerise for Dokploy, and a demo login that survives production
Everything needed to build and run this on Dokploy at linkdr.serfaty.site, plus the two things that turned out to be broken the moment it left a laptop. The build did not work in a container at all. `lib/auth.ts` throws when AUTH_SECRET or NEXT_PUBLIC_APP_URL is missing — correct at boot, wrong during `next build`, which imports every route module with NODE_ENV=production and none of the runtime secrets. The only way past it was baking a session key into an image layer, which is worse than the problem the guard exists to prevent. Both checks now skip NEXT_PHASE=phase-production-build and still fire on a real boot. Corepack in node:22.12-alpine ships expired npm registry signing keys and dies before it can download pnpm, so the image installs corepack first and prepares the pinned version explicitly. The image is the standalone trace, which needs outputFileTracingRoot at the REPO root: pnpm hoists to a root .pnpm store and tracing from apps/web silently omits every workspace package. 427MB, runs as non-root, and its healthcheck talks to Postgres — a container that cannot reach its database must never enter rotation, because a deploy that goes green and then 500s does not roll back. DEMO_LOGIN is a login bypass under NODE_ENV=production and there is no honest way to describe it otherwise. It is a separate variable from ALLOW_DEV_LOGIN so that copying a dev .env into a real environment cannot enable it by accident, it still only affects the one seeded number, and it prints a boot warning every single start so it cannot be forgotten. That deployment holds nothing but fixtures. It comes out before the platform sees a real signup. Also: /api/health, and next/image hosts corrected to the Spaces bucket rather than the R2 one this stopped using. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+23
-5
@@ -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 <img>,
|
||||
// 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.
|
||||
|
||||
@@ -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' } },
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="h-full overflow-y-auto bg-page px-5 pb-0 pt-[calc(0.5rem+env(safe-area-inset-top))]">
|
||||
<BackLink href="/" label="Back" />
|
||||
|
||||
<p className="mt-6 text-overline uppercase text-accent">For tradespeople</p>
|
||||
<h1 className="mt-3 text-balance text-h1">Get sent jobs near you</h1>
|
||||
<p className="mt-3 max-w-[68ch] text-pretty text-body text-muted">
|
||||
No bidding, no lead fees, no chasing. Verified customers send you the work directly and
|
||||
you decide what to take.
|
||||
</p>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<p className="mt-10 flex items-center gap-2 text-meta text-faint">
|
||||
<span className="h-px flex-1 bg-hairline" aria-hidden />
|
||||
An example request
|
||||
<span className="h-px flex-1 bg-hairline" aria-hidden />
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-4 rounded-card border border-hairline bg-raised p-5 shadow-lg"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center rounded-pill border border-brand-200 bg-brand-100 px-3 py-1 text-meta text-ink-950">
|
||||
Plumber
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-meta text-sun-600">
|
||||
<Clock className="h-3.5 w-3.5" aria-hidden />
|
||||
Today if possible
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 font-display text-h4 text-strong">
|
||||
Kitchen sink leaking under the cupboard
|
||||
</p>
|
||||
<p className="mt-2 text-body-sm text-muted">
|
||||
Water pooling under the sink, seems to be the trap. Free most evenings this week.
|
||||
</p>
|
||||
|
||||
<p className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-faint tabular-nums">
|
||||
<MapPin className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>2.4 km away</span>
|
||||
<span>·</span>
|
||||
<span>Budget $80–200</span>
|
||||
</p>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<span className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-pill border-[1.5px] border-ink-950 font-display text-body font-semibold text-strong dark:border-hairline">
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
Decline
|
||||
</span>
|
||||
<span className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-pill bg-go-600 font-display text-body font-semibold text-white">
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
Accept
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-meta text-faint">
|
||||
Accepting opens a private conversation. You agree a fixed price there before any work
|
||||
starts — we never quote on your behalf.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-10 text-h3">How it works</h2>
|
||||
<ol className="mt-4 flex flex-col gap-6">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title} className="flex gap-4">
|
||||
<span
|
||||
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-pill bg-brand-500 font-display text-body-sm font-semibold text-white tabular-nums"
|
||||
aria-hidden
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-display text-h4 text-strong">{step.title}</span>
|
||||
<span className="mt-1 block text-body-sm text-muted">{step.body}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="mt-10 text-h3">What you will need</h2>
|
||||
<p className="mt-2 text-body-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
<ul className="mt-4 flex flex-col gap-3">
|
||||
{NEEDED.map((item) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="flex items-center gap-3 rounded-lg border border-hairline px-4 py-3"
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-go-600" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body-sm text-strong">{item.label}</span>
|
||||
<span className="shrink-0 text-meta text-faint">{item.note}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-body-sm text-muted">
|
||||
You set your own hourly rate and how far you travel, and you can turn new work off
|
||||
whenever you are busy.
|
||||
</p>
|
||||
|
||||
{/*
|
||||
§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.
|
||||
*/}
|
||||
<div className="sticky bottom-0 -mx-5 mt-10 border-t border-hairline bg-page/95 px-5 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-[12px]">
|
||||
<Link
|
||||
href="/sign-in?next=/pro/onboarding"
|
||||
className={buttonClasses({ variant: 'primary', size: 'lg', block: true })}
|
||||
>
|
||||
Create your pro account
|
||||
</Link>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<p className="mt-3 text-center text-meta text-faint">
|
||||
Working as a pro needs its own account. You keep your customer one for hiring.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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' ? <ProProfile /> : <ClientProfile me={me.data} />;
|
||||
return me.data.role === 'pro' ? <ProProfile /> : <ClientProfile />;
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div aria-busy>
|
||||
<div className="mb-1 h-8 w-40 animate-pulse rounded-md bg-inset" />
|
||||
<div className="mb-4 h-5 w-64 animate-pulse rounded-md bg-inset" />
|
||||
<div className="mb-5 h-24 animate-pulse rounded-card bg-inset" />
|
||||
<div className="mb-8 aspect-[3/4] w-full animate-pulse rounded-3xl bg-inset" />
|
||||
<div className="h-40 animate-pulse rounded-card bg-inset" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Uploaded", or the reason nobody can see you yet. */
|
||||
function Required({ present }: { present: boolean }) {
|
||||
return present ? <>Uploaded</> : <span className="text-danger">Missing</span>;
|
||||
}
|
||||
|
||||
function ProProfile() {
|
||||
const utils = api.useUtils();
|
||||
const preview = api.pro.previewCard.useQuery();
|
||||
@@ -94,7 +113,7 @@ function ProProfile() {
|
||||
if (preview.isLoading || profile.isLoading) {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="h-64 animate-pulse rounded-deck bg-inset" />
|
||||
<ProSkeleton />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -124,25 +143,38 @@ function ProProfile() {
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-1 text-h2">Your card</h1>
|
||||
<p className="mb-3 text-body-sm text-muted">Exactly what customers see when they swipe.</p>
|
||||
<p className="mb-4 text-body-sm text-muted">Exactly what customers see when they swipe.</p>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Banner tone={status.tone} title={status.label} className="mb-5">
|
||||
{status.body}
|
||||
</Banner>
|
||||
|
||||
{/*
|
||||
The real <Card>, 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.
|
||||
*/}
|
||||
<div className="relative mb-2 h-[420px] w-full">
|
||||
<div className="relative mb-2 aspect-[3/4] w-full">
|
||||
<Card card={card} />
|
||||
</div>
|
||||
<p className="mb-5 text-meta text-faint">
|
||||
<p className="mb-8 text-meta text-faint">
|
||||
The distance shown is an example — customers see how far you are from their own job.
|
||||
</p>
|
||||
|
||||
<Banner tone={status.tone} title={status.label}>
|
||||
{status.body}
|
||||
</Banner>
|
||||
|
||||
<div className="mt-5">
|
||||
<div>
|
||||
<SettingsGroup title="Availability">
|
||||
<SettingsToggle
|
||||
label="Accepting jobs"
|
||||
@@ -157,8 +189,10 @@ function ProProfile() {
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* "Card details", not "Your card" — that is the h1 four hundred pixels
|
||||
above, and two headings with one name meant two different things. */}
|
||||
<SettingsGroup
|
||||
title="Your card"
|
||||
title="Card details"
|
||||
/*
|
||||
* upsertProfile demotes a verified pro to `pending` when trade,
|
||||
* location or radius changes, which silently drops them off the deck.
|
||||
@@ -185,9 +219,15 @@ function ProProfile() {
|
||||
|
||||
<SkillsGroup skills={p.skills} />
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<SettingsGroup title="Documents" note="Only our review team ever sees these.">
|
||||
<SettingsRow label="Photo ID" value={has('id') ? 'Uploaded' : 'Missing'} />
|
||||
<SettingsRow label="Insurance" value={has('insurance') ? 'Uploaded' : 'Missing'} />
|
||||
<SettingsRow label="Photo ID" value={<Required present={has('id')} />} />
|
||||
<SettingsRow label="Insurance" value={<Required present={has('insurance')} />} />
|
||||
<SettingsRow label="Trade licence" value={has('licence') ? 'Uploaded' : 'Not provided'} />
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -210,47 +250,59 @@ function ProProfile() {
|
||||
|
||||
/* ───────────────────────────── client ────────────────────────────── */
|
||||
|
||||
function ClientProfile({ me }: { me: Me }) {
|
||||
const jobs = api.job.mine.useQuery();
|
||||
|
||||
function ClientProfile() {
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-5 text-h2">Your profile</h1>
|
||||
|
||||
<SettingsGroup title="Account">
|
||||
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
|
||||
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} />
|
||||
<SettingsRow label="Jobs posted" value={jobs.data ? String(jobs.data.length) : '…'} />
|
||||
</SettingsGroup>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="rounded-card border border-hairline bg-raised p-5">
|
||||
<span className="flex items-center gap-2 text-accent">
|
||||
<Hammer className="h-5 w-5" aria-hidden />
|
||||
<span className="text-overline uppercase">For tradespeople</span>
|
||||
</span>
|
||||
<h2 className="mt-3 text-h3">Work with us</h2>
|
||||
<p className="mt-2 text-body-sm text-muted">
|
||||
Get sent local jobs that match your trade. We check every pro’s ID, licence and
|
||||
insurance, so customers arrive ready to book.
|
||||
<div className="flex min-h-full flex-col">
|
||||
<h1 className="mb-1 text-h2">Your profile</h1>
|
||||
<p className="text-body-sm text-muted">
|
||||
Your name, contact details and notifications live in Settings.
|
||||
</p>
|
||||
{/*
|
||||
user.setRole refuses once a job has been posted, so this must not read
|
||||
as a switch that flips this account over.
|
||||
*/}
|
||||
<p className="mt-2 text-meta text-faint">
|
||||
Working as a pro needs its own account — you keep this one for hiring.
|
||||
</p>
|
||||
<Link
|
||||
href="/sign-in?next=/pro/onboarding"
|
||||
className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`}
|
||||
>
|
||||
Join as a pro
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center py-8">
|
||||
{/*
|
||||
A marketplace with no pros has no product, so recruiting supply beats
|
||||
decorating a client profile that has nothing on it.
|
||||
*/}
|
||||
<div className="w-full rounded-card border border-hairline bg-raised p-5">
|
||||
<span className="flex items-center gap-2 text-accent">
|
||||
<Hammer className="h-5 w-5" aria-hidden />
|
||||
<span className="text-overline uppercase">For tradespeople</span>
|
||||
</span>
|
||||
<h2 className="mt-3 text-h3">Work with us</h2>
|
||||
<p className="mt-2 text-body-sm text-muted">
|
||||
Get sent local jobs that match your trade. We check every pro’s ID, licence
|
||||
and insurance, so customers arrive ready to book.
|
||||
</p>
|
||||
{/*
|
||||
user.setRole refuses once a job has been posted, so this must not
|
||||
read as a switch that flips this account over.
|
||||
*/}
|
||||
<p className="mt-2 text-meta text-faint">
|
||||
Working as a pro needs its own account — you keep this one for hiring.
|
||||
</p>
|
||||
<Link
|
||||
href="/pro/join"
|
||||
className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`}
|
||||
>
|
||||
Join as a pro
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
<SettingsGroup title="Working" note="Verification takes about a day.">
|
||||
<SettingsGroup
|
||||
title="Working"
|
||||
note="A pro account is separate from this one. You keep this for hiring."
|
||||
>
|
||||
{/*
|
||||
/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.
|
||||
*/}
|
||||
<SettingsRow
|
||||
label="Work on Linkdr"
|
||||
hint="Set up a pro profile and start getting jobs"
|
||||
href="/pro/onboarding"
|
||||
hint="Get sent local jobs that match your trade"
|
||||
href="/pro/join"
|
||||
/>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
@@ -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.');
|
||||
})()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user