Play, Compete, and Win. Potlock is a Nigerian peer-to-peer skill-gaming platform where players stake real money (Naira) on 1v1 game challenges. Create a challenge, share a link, an opponent accepts, you both play your game of choice, a result is declared, and the winner takes the pot — with disputes, evidence submission, and a built-in wallet backing the whole flow.
- Live app: https://potlock.vercel.app
- Status: Private, active development
- License: MIT
- What Potlock does
- Tech stack
- Architecture overview
- Project structure
- Getting started
- Environment variables
- Available scripts
- The API client
- State management
- Authentication
- The wallet
- Challenge flow
- Realtime notifications
- Analytics
- PWA support
- Routing
- Styling & UI
- Deployment
- Further documentation
Potlock lets two players wager on the outcome of a game they play elsewhere (e.g. a mobile or console title). The platform handles the money and the trust layer, not the gameplay itself:
- A player funds their wallet in Naira.
- They create a challenge — choosing a game and a stake amount — which produces a shareable link (
/c/[slug]). - An opponent opens the link and accepts the challenge, matching the stake.
- Both players compete in the actual game.
- A result is declared; if both sides agree, the pot is settled to the winner.
- If they disagree, the challenge enters a dispute, where players submit evidence for resolution.
- Winnings can be withdrawn to a linked Nigerian bank account (after KYC/BVN verification).
The product events tracked in analytics map the full lifecycle: challenge_created, challenge_joined, challenge_result_declared, challenge_cancelled, challenge_dispute_evidence_submitted, wallet_funding_initiated, wallet_withdrawal_requested, bank_account_added, and kyc_bvn_submitted.
| Layer | Technology |
|---|---|
| Framework | Next.js 14.2 (App Router) |
| Language | TypeScript (~99% of the codebase) |
| Runtime / package manager | Bun (bun.lock) |
| Styling | Tailwind CSS 3.4 + tailwindcss-animate |
| UI primitives | Radix UI + shadcn/ui (components.json) |
| Icons | lucide-react, @tabler/icons-react |
| Animation | framer-motion / motion |
| State | Zustand 5 (with persist) |
| Data fetching | TanStack Query 5 |
| API client | Generated by @hey-api/openapi-ts |
| Auth | Google OAuth (@react-oauth/google) + bearer token |
| Forms & validation | react-hook-form + zod |
| Analytics | PostHog (posthog-js) |
| Realtime | Pusher (pusher-js) |
| PWA | next-pwa |
| Toasts | sonner |
Potlock is a frontend-only Next.js application that talks to a separate backend API over HTTP. It does not own its own database at runtime beyond the Prisma tooling present in dev dependencies; the source of truth is the external API defined by NEXT_PUBLIC_API_URL.
┌─────────────────────────────┐ ┌──────────────────────────┐
│ Potlock (Next.js 14 PWA) │ │ Backend API │
│ │ HTTP │ (NEXT_PUBLIC_API_URL) │
│ • App Router pages │◄──────►│ • /openapi.json │
│ • Generated hey-api client │ Bearer │ • /api/v1/challenges/* │
│ • Zustand stores │ token │ • /api/v1/wallet/* │
│ • TanStack Query │ │ • auth, kyc, banks │
└──────────┬──────────────────┘ └──────────────┬───────────┘
│ │
│ Google OAuth │ Pusher events
▼ ▼
┌───────────────┐ ┌────────────────────┐
│ Google (auth) │ │ Pusher (realtime) │
└───────────────┘ └────────────────────┘
│
▼
┌───────────────┐
│ PostHog │ product analytics
└───────────────┘
Key points:
- The TypeScript API client is generated from the backend's OpenAPI schema (
src/client). You do not hand-write API calls — you regenerate the client (see The API client). - Auth is a bearer token stored client-side and attached to every request via the generated client's config.
- Next.js middleware exists as a passthrough scaffold today (
src/middleware.ts) — route protection is enforced client-side via the auth store, not in middleware. - The challenge detail page is
force-dynamicso shared links always reflect live status.
potlockng/
├── public/ # Static assets, PWA icons, service worker output
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── c/[slug]/ # Shareable challenge page (core feature)
│ │ │ ├── page.tsx # Server component: fetches challenge, builds OG metadata
│ │ │ └── challenge-client.tsx # Client component: accept/decline/result UI
│ │ ├── notification/ # Notifications page
│ │ ├── privacy/ # Privacy policy
│ │ ├── terms/ # Terms of service
│ │ ├── layout.tsx # Root layout: Providers, Navbar, PlayerHub, Footer, Toaster
│ │ ├── manifest.ts # PWA web app manifest
│ │ ├── not-found.tsx # 404 page
│ │ └── page.tsx # Home: HeroCampaignCarousel + AvailableGamesTable
│ ├── client/ # GENERATED hey-api client (do not edit by hand)
│ ├── components/
│ │ ├── global/ # Cross-cutting components
│ │ ├── navigation/ # Navbar and nav-related UI
│ │ ├── notifications/ # Notification UI
│ │ ├── player-hub/ # The signed-in player's hub (wallet, profile, etc.)
│ │ ├── providers/ # React context providers (Query, PostHog, auth, etc.)
│ │ ├── ui/ # shadcn/ui primitives + custom UI (carousels, tables)
│ │ ├── available-games-table.tsx
│ │ ├── all-campaigns-grid.tsx
│ │ ├── campaign-search-bar.tsx
│ │ ├── featured-campaigns-row.tsx
│ │ ├── before-install-prompt.tsx # PWA install prompt
│ │ ├── install-instructions.tsx
│ │ ├── telegram-cta.tsx
│ │ └── index.ts # Barrel exports
│ ├── hooks/ # Custom React hooks
│ ├── lib/
│ │ ├── analytics.ts # PostHog event catalog + identify/reset helpers
│ │ ├── api-config.ts # Runtime config injected into the generated client
│ │ ├── auth-store.ts # Zustand auth store (persisted)
│ │ ├── wallet-store.ts # Zustand wallet balance store
│ │ ├── notifications-context.tsx
│ │ ├── posthog-client.ts
│ │ ├── pusher-client.ts
│ │ └── utils.ts # cn() and shared helpers
│ ├── styles/ # globals.css and style entry points
│ ├── utils/ # fmtNaira, fonts (aeonik/inter), buildMetadata, etc.
│ └── middleware.ts # Passthrough middleware scaffold
├── .env.example
├── components.json # shadcn/ui config
├── next.config.mjs # next-pwa + image remote patterns
├── openapi-ts.config.ts # hey-api client generation config
├── tailwind.config.ts
├── tsconfig.json
└── package.json
- Bun (the repo ships a
bun.lock) - Access to the backend API (for a working
NEXT_PUBLIC_API_URL) - A Google OAuth client ID
- PostHog and Pusher keys (optional for local UI work, required for full functionality)
# 1. Install dependencies
bun install
# 2. Create your env file
cp .env.example .env.local
# then fill in the values (see Environment variables below)
# 3. Generate the API client from the backend's OpenAPI schema
bun run generate-client
# 4. Start the dev server
bun run devThe app runs at http://localhost:3000 by default.
Note:
next-pwais disabled in development (disable: process.env.NODE_ENV === 'development'), so the service worker only activates in production builds.
Based on .env.example and the code, the following variables are used:
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_APP_DOMAIN |
Yes | Public domain of the app (used in metadata, share links) |
NEXT_PUBLIC_APP_NAME |
Yes | Display name of the app |
DATABASE_URL |
Tooling | Database connection string for Prisma tooling |
NEXT_PUBLIC_API_URL |
Yes | Base URL of the backend API; also where the OpenAPI schema is read from during client generation. Falls back to http://localhost:3000. |
Additional public keys for PostHog and Pusher are consumed by
posthog-client.tsandpusher-client.tsrespectively, and a Google OAuth client ID is required by@react-oauth/google. Confirm the exact variable names in those files and add them here — they were not present in the committed.env.exampleand should be documented before onboarding new contributors.
Since these are NEXT_PUBLIC_* variables, they are exposed to the browser. Never put secrets behind a NEXT_PUBLIC_ prefix.
| Script | Command | Description |
|---|---|---|
dev |
next dev |
Start the local dev server |
build |
next build |
Production build |
start |
next start |
Serve the production build |
lint |
next lint |
Run ESLint |
generate-client |
openapi-ts |
Regenerate the typed API client from the backend OpenAPI schema |
Run with Bun, e.g. bun run dev.
Potlock does not hand-write fetch calls. The entire typed client under src/client/ is generated by @hey-api/openapi-ts from the backend's OpenAPI document.
openapi-ts.config.ts:
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: `${process.env.NEXT_PUBLIC_API_URL}/openapi.json`,
output: {
path: 'src/client',
postProcess: ['prettier'],
},
plugins: [
'@hey-api/typescript',
'@hey-api/sdk',
{
name: '@hey-api/client-fetch',
runtimeConfigPath: './src/lib/api-config.ts',
},
],
});- Input:
${NEXT_PUBLIC_API_URL}/openapi.json— the backend must be reachable when you regenerate. - Output:
src/client/— generated SDK functions (e.g.getApiV1ChallengesCBySlug,getApiV1WalletBalance), types, and a client instance (client.gen). - Runtime config:
src/lib/api-config.tsinjects the base URL into the generated client:
export const createClientConfig = (config: any) => ({
...config,
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
});To regenerate after backend API changes: bun run generate-client. Treat src/client/ as generated output — do not edit it by hand; your changes will be overwritten.
Global client state uses Zustand. Two stores are central:
auth-store.ts— persisted (persist, keypotlockng-auth). Holds the bearertoken, theuser, andisAuthenticated. OnsetAuthand on rehydrate it configures the generated client'sAuthorizationheader and identifies the user in PostHog. Onlogoutit clears the header, trackssign_out, and resets analytics identity.wallet-store.ts— holds the wallet balance in kobo (balanceKobo), a formatteddisplaystring, and aloadingflag.fetchBalance()callsgetApiV1WalletBalance()from the generated client.
Server/remote data (challenges, games, campaigns) is fetched with TanStack Query.
Money is stored in kobo (1 Naira = 100 kobo) throughout the app, matching the backend. Use
fmtNaira()fromsrc/utilsto render amounts. Never do currency math in floating-point Naira.
- Sign-in is via Google OAuth (
@react-oauth/google), which yields a session that the backend exchanges for a bearer token. - The token + user are stored in the persisted auth store and attached to every API request through
client.setConfig({ headers: { Authorization: \Bearer ${token}` } })`. - On page reload,
onRehydrateStoragere-applies the token header and re-identifies the user in analytics, so the session survives refreshes. - Route protection is client-side.
src/middleware.tsis currently a passthrough (it matches all non-static routes but only returnsNextResponse.next()). Gating of authenticated views happens in components/providers viaisAuthenticated. If you need server-enforced protection, that logic belongs in the middleware.
- Balances are denominated in kobo; the store exposes both the raw
balanceKoboand a humandisplaystring returned by the API. - Funding, withdrawals, bank-account linking, and KYC (BVN) are first-class flows, evidenced by the analytics events
wallet_funding_initiated,wallet_withdrawal_requested,bank_account_added, andkyc_bvn_submitted. - The wallet UI lives primarily in the player hub (
src/components/player-hub/).
The core route is /c/[slug]:
page.tsxis a server component. It isforce-dynamicwithrevalidate = 0so a shared link is never served stale — the moment an opponent accepts, everyone else opening the link sees the correct "no longer open" state instead of a cached "Accept" button.- It fetches the challenge via
getApiV1ChallengesCBySlug({ path: { slug } })and builds rich OpenGraph + Twitter metadata (title, description, and the game's image) so shared links preview well in chats and social apps. challenge-client.tsxis the client component that renders the interactive accept / decline / result / dispute UI, hydrated from the server-fetchedinitialChallenge.
Confirm-before-acting dialogs guard irreversible actions (accepting, declaring results, cancelling), added in recent commits.
- Pusher (
pusher-client.ts) delivers realtime updates (e.g. challenge accepted, result declared). notifications-context.tsxprovides notification state to the tree; UI lives insrc/components/notifications/and the/notificationroute.
All product events flow through src/lib/analytics.ts, which is the single source of truth for event names (snake_case, object_verb). The catalog:
sign_in, sign_up, sign_out, challenge_created, challenge_joined, challenge_share_link_copied, challenge_result_declared, challenge_cancelled, challenge_dispute_evidence_submitted, wallet_funding_initiated, wallet_withdrawal_requested, bank_account_added, kyc_bvn_submitted, feedback_submitted, game_requested, install_prompt_shown, install_prompt_accepted, install_prompt_dismissed.
Use the helpers rather than calling PostHog directly:
import { track, identifyUser, resetAnalyticsIdentity } from '@/lib/analytics';
track('challenge_created', { stakeKobo, gameId });identifyUser is called after login/register/rehydrate to merge anonymous and identified events; resetAnalyticsIdentity is called on logout.
next-pwawraps the Next config (dest: 'public',register: true,skipWaiting: true), disabled in development.src/app/manifest.tsdefines the installable app: name Potlock,display: standalone, theme color#000000, and Android chrome icons (192/512, plus a maskable 512).- A custom in-app install experience (
before-install-prompt.tsx,install-instructions.tsx) captures thebeforeinstallpromptevent and tracksinstall_prompt_shown/accepted/dismissed.
- Tailwind CSS with a dark-first theme (
<html className="dark">), custom fonts (aeonik,inter) loaded viasrc/utils. - shadcn/ui components (configured in
components.json) built on Radix UI primitives, extended with custom pieces like carousels, bento grids, resizable navbars, and campaign tables. - Remote images are restricted to
images.unsplash.comandi.pinimg.cominnext.config.mjs; add any new image host there.
- Deployed on Vercel (Production + Preview; the repo shows 60+ deployments).
- Production URL: https://potlock.vercel.app
- Ensure all environment variables are set in the Vercel project, and that
NEXT_PUBLIC_API_URLpoints at the correct backend for each environment. The client is generated at build/dev time against that URL's/openapi.json.
CONTRIBUTING.md— branch workflow, commit conventions, PR processdocs/ARCHITECTURE.md— deeper architectural notesdocs/API_CLIENT.md— how to work with the generated client
MIT © Karaole Olayiwola Muizz
