Connecting Zurich residents with certified repair shops through government-subsidized bonus codes. Repair instead of replace.
- Bonus codes: Stadt Zürich issues CHF 100 repair bonuses -- 8-character codes, one-month expiry, one per repair
- Shop directory: Searchable by category, text, postal code, or radius (haversine distance calculation)
- Three categories: Elektro/Elektronik, Kleidung, Schuhe -- only these qualify for the city's bonus program
- Admin dashboard: User management, shop approval, bonus tracking, platform statistics
- Graceful degradation: API endpoints fall back to mock data if the database is unavailable -- the shop directory keeps working
src/lib/bonus-codes.ts -- centralized logic for the core business operation:
generateBonusCode(): 8-character alphanumeric, unique validation with retry loop (up to 10 attempts)calculateBonusAmount(): Fixed CHF 100 per codegetBonusExpiryDate(): one month from creationisValidBonusCode(): Regex validation before database lookup
The bonus amount was a design decision -- percentage-based (20% of repair cost) was considered and rejected. A flat CHF 100 is simpler to communicate, simpler to implement, and eliminates disputes over repair cost valuation.
Two files prevent hardcoded strings from scattering across the codebase:
src/lib/constants/categories.ts-- shop category enum + Swiss German labelssrc/lib/constants/routes.ts-- all application routes in one place
Every shop-related API endpoint has a fallback path. If the database is down, the endpoint returns mock data from src/lib/demo/ instead of a 500 error. The shop directory continues working. Users see real data when available, demo data when not -- no error pages.
Customer --> Admin --> Super Admin
- Credentials provider with bcrypt password hashing
- JWT session strategy with role embedded in token
- Protected routes check
session?.user?.roleserver-side - Admin dashboard at
/adminis role-gated
Drizzle schema as SSOT (src/lib/db/schema.ts) -- types derived from schema
(migrated from Prisma 2026-09, keeping the exact table shapes):
User ──── has many ──> BonusCode
│ │
└── has many ──> Order ─┘ (1:1 with BonusCode)
│
Shop ── has many ────┘
Three enums enforce domain constraints at the database level:
UserRole: CUSTOMER, ADMIN, SUPER_ADMINShopCategory: ELECTRONICS, CLOTHING, SHOESOrderStatus: PENDING, CONFIRMED, IN_PROGRESS, COMPLETED, CANCELLED
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| Language | TypeScript 6 (strict mode) |
| Database | PostgreSQL + Drizzle ORM |
| Auth | NextAuth.js, JWT, bcryptjs |
| Styling | Tailwind CSS 4 |
| Deployment | Self-hosted (Hetzner, Caddy, Next.js standalone) |
Quick Start
- Node.js 20+ (CI runs on 24)
- PostgreSQL
git clone https://github.com/bitbaum/reparaturbonus-zh.git
cd reparaturbonus-zh
# create .env.local with DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL (see below)
pnpm install
pnpm run setup # apply Drizzle migrations + seed
pnpm run dev| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
NEXTAUTH_SECRET |
Session signing key |
NEXTAUTH_URL |
Application URL |
src/
app/
api/ # API routes (shops, bonus-codes, admin, auth)
admin/ # Admin dashboard (role-gated)
dashboard/ # Customer dashboard
shops/ # Shop directory + detail pages
how-it-works/ # Platform explanation
components/
admin/ # Auth guard
forms/ # Reusable form inputs
layout/ # Header, Footer
shop/ # Shop-specific components
lib/
auth.ts # NextAuth config + JWT strategy
bonus-codes.ts # Code generation, validation, expiry
db/ # Drizzle client (lazy singleton) + schema (SSOT for types)
constants/ # Routes + categories (SSOT)
demo/ # Mock data for graceful fallback
drizzle/ # Generated SQL migrations
scripts/db/ # Seed + prod-safe shop upsert
MIT
Reparaturbonus Zürich -- repair culture for a circular economy.