A full-stack B2B lead-capture and pipeline management tool built as a timed take-home.
Public visitors submit bulk-order enquiries via a polished form. Admin users log in to view, search, filter, and update lead statuses.
Built for the Digital Heroes Full Stack Development internship qualification task β digitalheroesco.com
- Public Site: https://lead-desk-cyan.vercel.app
- Admin Dashboard: https://lead-desk-cyan.vercel.app/admin
- Demo Login:
admin@leaddesk.com/Demo@LeadDesk1
π Visiting
/adminwithout an active session automatically redirects to/admin/loginβ the dashboard is never reachable unauthenticated.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) + TypeScript |
| Styling | Tailwind CSS v4 |
| Forms | React Hook Form + Zod |
| Database | PostgreSQL on Neon (serverless) |
| ORM | Prisma 7 (with @prisma/adapter-neon) |
| Auth | Auth.js v5 (next-auth@beta) β credentials + JWT-in-cookie |
| Tests | Vitest 4 |
| Deploy | Vercel |
- Node.js >= 20
- A Neon project with two connection strings:
DATABASE_URLβ pooled (used by the app at runtime via the Neon adapter)DIRECT_URLβ direct / non-pooled (used by Prisma migrations to bypass pgBouncer)
cd leaddesk-mini
npm installCopy the example file and fill in your values:
cp .env.example .env.localEdit .env.local:
# Neon pooled connection (used by the app at runtime via the Neon adapter)
DATABASE_URL="postgresql://user:pass@ep-xxx-pooler.neon.tech/neondb?sslmode=require&pgbouncer=true&connect_timeout=15"
# Neon direct connection (used by prisma migrate, bypasses pgBouncer)
DIRECT_URL="postgresql://user:pass@ep-xxx.neon.tech/neondb?sslmode=require"
# Auth.js β generate with: openssl rand -base64 32
NEXTAUTH_SECRET="your-secret-here"
NEXTAUTH_URL="http://localhost:3000"npx prisma migrate devnpx tsx prisma/seed.tsThis creates:
| Field | Value |
|---|---|
admin@leaddesk.com |
|
| Password | Demo@LeadDesk1 |
β οΈ Change this password before treating any deployment as production.
npm run dev- Public Form: http://localhost:3000
- Admin Dashboard: http://localhost:3000/admin
# Run all 41 tests once
npm test
# Watch mode
npm run test:watch
# Coverage report
npm run test:coverage- 22 Zod schema unit tests: all validation rules, email lowercasing, whitespace trim
- 9 POST /api/leads handler tests: happy path, validation failures, duplicate 24h logic, DB error, malformed JSON
- 10 GET /api/leads + PATCH /api/leads/[id] tests: auth guards, search, status filter, 404, 503
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/leads |
Public | Submit a lead enquiry |
GET |
/api/leads |
Admin | List leads with search + filter + pagination |
GET |
/api/leads/stats |
Admin | Dashboard counts (total, new, contacted, closed) |
GET |
/api/leads/activity |
Admin | Recent activity feed |
PATCH |
/api/leads/[id] |
Admin | Update lead status |
GET/POST |
/api/auth/* |
β | Auth.js handlers |
{
"name": "Jane Smith",
"email": "jane@company.com",
"budgetRange": "RANGE_1K_5K",
"message": "Need 500 units for our warehouse project."
}Field budgetRange is labelled "Order Value" in the UI. Accepted values:
UNDER_1K | RANGE_1K_5K | RANGE_5K_10K | OVER_10K
Duplicate handling: If the same email submits within 24 hours, the lead is stored with isDuplicate: true and the response includes a warning string. No submission is rejected.
{ "status": "CONTACTED" }Accepted status values: NEW | CONTACTED | CLOSED
leaddesk-mini/
βββ prisma/
β βββ schema.prisma # Lead + AdminUser models
β βββ seed.ts # Admin user seeder
β βββ migrations/ # SQL migration history
βββ prisma.config.ts # Prisma 7 datasource config
βββ middleware.ts # Edge-safe route protection
βββ src/
β βββ auth.config.ts # Edge-safe Auth.js config (used by middleware)
β βββ auth.ts # Full Auth.js config (Prisma + bcrypt, Node runtime only)
β βββ app/
β β βββ page.tsx # Public lead form (/)
β β βββ admin/
β β β βββ page.tsx # Admin dashboard (/admin)
β β β βββ login/
β β β βββ page.tsx # Login page (/admin/login)
β β βββ api/
β β βββ leads/
β β β βββ route.ts # GET + POST
β β β βββ stats/route.ts # GET stats
β β β βββ activity/route.ts # GET activity
β β β βββ [id]/route.ts # PATCH
β β βββ auth/[...nextauth]/
β β βββ route.ts # Auth.js handlers
β βββ components/
β β βββ LeadForm.tsx # Public form (client)
β β βββ Footer.tsx # Digital Heroes credit footer
β β βββ admin/
β β βββ AdminDashboard.tsx # Dashboard (client)
β β βββ SignOutButton.tsx # Sign-out (client)
β βββ lib/
β β βββ prisma.ts # PrismaClient singleton (Neon adapter)
β β βββ validations/
β β βββ lead.ts # Shared Zod schemas
β βββ types/
β β βββ lead.ts # TS types + label maps
β βββ __tests__/
β βββ setup.ts # Vitest global mocks
β βββ api/
β β βββ leads.post.test.ts
β β βββ leads.get.test.ts
β βββ lib/
β βββ validations.test.ts
βββ vitest.config.ts
βββ CRITIQUE.md # Staff-engineer self-critique
βββ README.md # Project documentation
Vercel Edge Middleware runs on the Edge Runtime, which cannot execute Prisma Client or bcryptjs (both Node.js-only). auth.config.ts holds the route-authorization logic with zero database dependencies and is the only auth file middleware.ts imports. auth.ts extends it with the full Credentials provider (Prisma + bcrypt) for use in Route Handlers and Server Components. This split is what keeps the Edge Function bundle under Vercel's size limit.
- Push the repository to GitHub
- Import into Vercel (framework auto-detected as Next.js)
- Add environment variables in the Vercel dashboard
- Vercel runs
next buildautomatically on every push - Run migrations against production once:
npx prisma migrate deploy
-
Why JWT-in-cookie, not bearer tokens?
This is a single-admin internal tool. ThehttpOnly,Secure,SameSite=Laxcookie attributes mean the session token is inaccessible to JavaScript (XSS-resistant) and sent automatically with every browser request (no client-side token management). A multi-service bearer-JWT setup would add a token-refresh flow and revocation mechanism for zero benefit in a single-admin dashboard. -
Why allow duplicate submissions?
Rejecting duplicate emails silently would confuse genuine re-submissions (e.g. a buyer who forgot they already submitted). Storing the duplicate withisDuplicate: truemeans the sales team sees the full picture, and the buyer still gets a friendly confirmation regardless. -
Why Zod on both client and server?
A single shared schema (src/lib/validations/lead.ts) powers React Hook Form on the client andsafeParseon the server. This eliminates validation drift β if a server-side rule changes, the client immediately reflects it with zero code duplication. -
Why split
auth.config.tsfromauth.ts?
Initially,middleware.tsimported the full Auth.js config directly, which pulled Prisma Client and bcryptjs into the Edge Function bundle β this both failed to resolve (Module not found: .prisma/client/default, since Prisma's generated client isn't Edge-compatible) and pushed the bundle past Vercel's 1 MB Edge Function limit. Splitting the config so middleware only imports the lightweight, database-freeauthConfigfixed both issues and is the standard pattern recommended for Auth.js v5 + Prisma on Vercel.
See CRITIQUE.md for the full self-critique and production-readiness gap analysis.
I used AI throughout this build for: scaffolding the Prisma schema and initial Route Handlers, generating the Vitest test suite, and diagnosing two production deploy errors (a Prisma-in-Edge-Runtime module resolution failure and an Edge Function bundle-size limit breach).
What I changed afterward: I renamed and reframed the generic "lead capture" brief into a B2B wholesale-order niche (relabeling "Budget" to "Order Value," rewriting form copy), decided the duplicate-lead handling behavior (flag rather than reject) based on what made sense for a real sales team's workflow rather than the model's first suggestion, and made the explicit auth-architecture call (JWT-in-cookie over bearer tokens) after weighing it against this being a single-admin tool rather than a multi-service system.