GitHub - Olayiwolaaa/potlock: Peer-to-peer skill-gaming platform for staking real money on 1v1 challenges, with wallet, payouts, and dispute resolution. · GitHub
Skip to content
OlayiwolaaaPublic

About

Peer-to-peer skill-gaming platform for staking real money on 1v1 challenges, with wallet, payouts, and dispute resolution.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Potlock

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.


Table of contents


What Potlock does

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:

  1. A player funds their wallet in Naira.
  2. They create a challenge — choosing a game and a stake amount — which produces a shareable link (/c/[slug]).
  3. An opponent opens the link and accepts the challenge, matching the stake.
  4. Both players compete in the actual game.
  5. A result is declared; if both sides agree, the pot is settled to the winner.
  6. If they disagree, the challenge enters a dispute, where players submit evidence for resolution.
  7. 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.


Tech stack

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

Architecture overview

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-dynamic so shared links always reflect live status.

Project structure

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

Getting started

Prerequisites

  • 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)

Install & run

# 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 dev

The app runs at http://localhost:3000 by default.

Note: next-pwa is disabled in development (disable: process.env.NODE_ENV === 'development'), so the service worker only activates in production builds.


Environment variables

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.ts and pusher-client.ts respectively, 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.example and 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.


Available scripts

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.


The API client

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.ts injects 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.


State management

Global client state uses Zustand. Two stores are central:

  • auth-store.ts — persisted (persist, key potlockng-auth). Holds the bearer token, the user, and isAuthenticated. On setAuth and on rehydrate it configures the generated client's Authorization header and identifies the user in PostHog. On logout it clears the header, tracks sign_out, and resets analytics identity.
  • wallet-store.ts — holds the wallet balance in kobo (balanceKobo), a formatted display string, and a loading flag. fetchBalance() calls getApiV1WalletBalance() 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() from src/utils to render amounts. Never do currency math in floating-point Naira.


Authentication

  • 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, onRehydrateStorage re-applies the token header and re-identifies the user in analytics, so the session survives refreshes.
  • Route protection is client-side. src/middleware.ts is currently a passthrough (it matches all non-static routes but only returns NextResponse.next()). Gating of authenticated views happens in components/providers via isAuthenticated. If you need server-enforced protection, that logic belongs in the middleware.

The wallet

  • Balances are denominated in kobo; the store exposes both the raw balanceKobo and a human display string 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, and kyc_bvn_submitted.
  • The wallet UI lives primarily in the player hub (src/components/player-hub/).

Challenge flow

The core route is /c/[slug]:

  • page.tsx is a server component. It is force-dynamic with revalidate = 0 so 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.tsx is the client component that renders the interactive accept / decline / result / dispute UI, hydrated from the server-fetched initialChallenge.

Confirm-before-acting dialogs guard irreversible actions (accepting, declaring results, cancelling), added in recent commits.


Realtime notifications

  • Pusher (pusher-client.ts) delivers realtime updates (e.g. challenge accepted, result declared).
  • notifications-context.tsx provides notification state to the tree; UI lives in src/components/notifications/ and the /notification route.

Analytics

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.


PWA support

  • next-pwa wraps the Next config (dest: 'public', register: true, skipWaiting: true), disabled in development.
  • src/app/manifest.ts defines 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 the beforeinstallprompt event and tracks install_prompt_shown/accepted/dismissed.

Routing

Route File Purpose
/ app/page.tsx Home — hero campaign carousel + available games table
/c/[slug] app/c/[slug]/page.tsx Shareable challenge page (dynamic)
/notification app/notification/ Notifications
/privacy app/privacy/ Privacy policy
/terms app/terms/ Terms of service
* app/not-found.tsx 404

Styling & UI

  • Tailwind CSS with a dark-first theme (<html className="dark">), custom fonts (aeonik, inter) loaded via src/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.com and i.pinimg.com in next.config.mjs; add any new image host there.

Deployment

  • 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_URL points at the correct backend for each environment. The client is generated at build/dev time against that URL's /openapi.json.

Further documentation


License

MIT © Karaole Olayiwola Muizz

About

Peer-to-peer skill-gaming platform for staking real money on 1v1 challenges, with wallet, payouts, and dispute resolution.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages