NBA Feed
Your teams' news and games, with scores hidden until you reveal them.
Find verified facilities, book instantly, and join open games with real players across Lagos.
Sports & fitnessTypeScript · PLpgSQLGitHub @NobulPlusOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Find a facility, book a facility, join a game. Sports infrastructure for
Lagos, Nigeria — starting with football, built to expand beyond it.Read the full README ↗Find a facility, book a facility, join a game. Sports infrastructure for Lagos, Nigeria — starting with football, built to expand beyond it.
Tempo solves three real, specific problems for recreational football in Lagos: finding a verified place to play, booking it without a phone call and a "the pitch is actually double-booked" surprise, and joining a pickup game with people who reliably show up. It is not a generic sports-booking clone — every product decision below (traffic-aware "leave by" times, the Island/Mainland geography split, kobo-denominated pricing, punctuality as a tracked number) exists because it's what organising football in Lagos specifically requires.
Status: live. The app is deployed on Vercel, backed by a real Supabase
Postgres database — not the in-memory demo mode described below, which only
runs when no database is configured (e.g. a fresh local clone with no
.env.local).
Who it's for: recreational and semi-serious footballers in Lagos who currently organise games over WhatsApp and phone calls — plus the venue owners renting out pitches and the hosts running weekly games.
The three core flows:
Supporting flows: venue-owner onboarding and a dashboard (utilisation, revenue), a player reputation system (see below), and a public player profile/card.
@theme configuration, no tailwind.config.js)useState/
useActionState and a handful of small hooks.Defined once in src/lib/types.ts, mirrored exactly by the Postgres schema
(comment in the file literally says so). The core entities:
Venue — a physical location: name, area (e.g. "Lekki Phase 1"), a
Lagos-specific side: "island" | "mainland", lat/lng, amenities, photos,
and a verified/verifiedAt pair (verification is a public claim, so it
carries a timestamp — see /verification for what
that claim actually means).Pitch — one bookable surface at a venue: size (5-a-side /
7-a-side / 11-a-side), surface (astro / grass / indoor /
concrete), floodlights/covered flags, pricePerHourKobo and a
peakMultiplier for evening pricing.Slot — one bookable hour on one pitch, with a status
(open/held/booked/blocked).Booking — a paid reservation of a slot: reference code, status,
amounts in kobo (including the 5% service fee, computed once server-side —
see computeBookingTotal() in lib/data/repo.ts), payment method.Game — an open pickup game hosted on a pitch at a specific time:
capacity, minimumToGuarantee (how many players must join before it's
locked in), pricePerPlayerKobo, skill level.GameParticipant — a player's membership in a game:
confirmed/waitlist/withdrawn/no_show/played.PlayerProfile — handle, position, foot, bio, role
(player/host/venue_owner), and the entire reputation block below.
Phone number lives in a separate profiles_private table with no public
read policy — never on the public profile row.PlayerTraits — six peer-voted attributes (pace, passing, finishing,
defending, stamina, teamwork), 0–100, rendered as a hexagon radar chart.MatchState (derived, not stored) — the single computed answer to "is
this game happening?": heat (cold/warm//), ,
filled, , , , ,
.Money is always an integer number of kobo (1/100 Naira) — never a float,
never a formatted string, at the type level. formatNaira() in
lib/format.ts is the only function allowed to turn it into display text.
There is no separate backend API. The "backend" is Postgres plus a thin Next.js layer:
src/app/actions.ts, "use server") handle every
mutation: joining a game, creating a booking, hosting a game, signing up,
requesting a password reset. Client Components call them like local
functions; Zod validates input; revalidatePath() refreshes the affected
routes afterward.src/lib/data/repo.ts is the single data-access layer. Every page and
action goes through it — never straight to Supabase. Internally it
branches on demoMode(): with no Supabase credentials configured, it reads
and writes an in-memory store (lib/data/store.ts, seeded from
lib/data/seed.ts); with credentials present, it queries Postgres. Pages
don't know or care which mode they're in. That's why npm run dev works
with zero setup out of the box, and why demo mode is still a first-class
citizen even though the deployed app runs on real Postgres.src/lib/session.ts) follows the same pattern: a real Supabase
session via sb.auth.getUser(), or in demo mode a cookie
(tempo_demo_user) holding a seeded profile ID, so you can sign in as any
seeded player, host, or venue owner without a backend. A suspended account
is signed out the moment getCurrentUser() sees the flag, so every action
gating on a non-null user already blocks a suspended one for free.src/lib/supabase/{server,client}.ts wrap @supabase/ssr's
cookie-aware client creation for Server Components/Actions and Client
Components respectively.signUpAction() sends users to
/signup/verify, where verifySignupOtpAction() verifies the typed
6-digit Supabase signup code. Configure the Supabase signup email template
to include {{ .Token }} so users can see the code.src/app/auth/confirm/route.ts remains a plain Route Handler for
external email links such as password recovery. It verifies Supabase's
token_hash/type pair and redirects to a same-origin-checked next path
(safeNext() in lib/url.ts — blocks open-redirect attempts).Anything an external service needs to call, like the Flutterwave wallet webhook, is also implemented as a Route Handler rather than a Server Action.
supabase/migrations/ — the schema is where a lot of Tempo's actual
correctness guarantees live, not in application code. Run every file in
order; later migrations aren't optional extras, they fix real bugs found in
a live-readiness audit and the app assumes they're applied:
0001_init.sql — every table, RLS policies, reputation triggers, and
the double-booking exclusion constraint.0002_auth_hardening.sql — closes a signup privilege-escalation gap
and locks role/suspended behind admin-only functions instead of a
normal UPDATE.0003_fix_handle_generation.sql — a handle-generation edge case that
broke signup for long email local-parts.0004_flatten_ranges.sql — generated starts_at/ends_at columns
alongside the tstzrange columns, since every application-layer query
needs plain timestamps, not range literals.0005_host_game.sql — the atomic host_game() function.0006_create_booking.sql — the atomic create_booking() function
(real bookings didn't write to Postgres at all before this).0007_game_function_fixes.sql — fixes a bug where a full game
rejected every join instead of waitlisting late joiners, a bug where
leaving a game you were never in could wrongly promote someone else off
the waitlist, and adds a suspended check to every write function.0008_profile_column_privacy.sql / 0009_fix_profile_privacy.sql
— 0008's column-grant approach to hiding profiles.phone broke
select("*") everywhere (confirmed against the live database); 0009
reverts that and moves phone into profiles_private instead, which has
no public read policy at all.What the schema guarantees, independent of which migration added it:
bigint kobo everywhere, matching the TypeScript types exactly.slots carries a
GiST exclusion constraint (using btree_gist) on (pitch_id, during) —
two overlapping slots on the same pitch cannot exist, full stop. An
application-level "check then insert" loses this race under concurrent
requests; a database constraint cannot.SECURITY DEFINER Postgres function that locks the
relevant row, does its reads and writes inside one transaction, and
returns — so two people tapping the same button simultaneously can never
both win.profiles mirrors auth.users via a foreign key plus an
after insert on auth.users trigger — Supabase owns credentials, the app
owns everything else about a person, and a player's phone number lives
somewhere neither RLS's using (true) public-read policy nor a stray
select("*") can ever expose it to anyone but its owner.getMatchState() (lib/match.ts) is the single source of truth for
"what state is this game in?" — it derives heat, label, guaranteed,
spotsLeft from real numbers on every call, so the UI badge and the actual
roster can never disagree. Nothing about match state is a stored, cached
field that can go stale.estimateTravelMinutes() applies rough, honestly-labelled multipliers for
weekday rush hours and Island/Mainland bridge crossings, and
leaveByTime() turns that into the literal time you need to leave home —
shown prominently on both the pitch and game detail pages.formatCountdown() is deliberately coarse above an hour ("2d 4h",
not "2 days, 4 hours, 17 minutes, 3 seconds") — nobody needs
second-level precision for a game three days out."Matchday, not SaaS dashboard." Dark navy-and-green background, bold stat callouts, a pitch-marking decorative motif, live countdown/fill-bar mechanics — grounded in three concrete references reviewed directly as screenshots, not guessed from descriptions: Footy Addicts (editorial match photography, doodle icons, handwritten accent taglines), Playtomic (confident colour-blocked sections, numbered step badges), and Tempo's own original pitch-green brand hue.
The living, interactive version of the full token set is at /system —
run the app and open it.
Dark-first, with a genuine light mode on top (every token redefines under
prefers-color-scheme: light / an explicit data-theme toggle — nothing is
a hard-coded colour).
| Token | Dark value | Role |
|---|---|---|
--color-bg-primary | #0b0f0c | Base background |
--color-bg-secondary | #0f2318 | Alternating full-bleed section band |
--color-bg-card | #141a15 | Card surface |
--color-ink / --color-ink-soft / --color-ink-muted | #f3f6f1 / #b7c2b4 / #8b978a | Text, WCAG-AA checked |
--color-green / --color-green-deep | #17b95c / #0e8a44 | Brand accent |
--color-gold | #f5a623 | "Floodlight" — the match-heat warm state |
--color-orange | #ff5a45 | Coral — the match-heat hot state |
--color-blue / --color-purple | #4c8dff / #a78bfa | Cold/casual and competitive-level tags respectively |
Four faces, each with one job, loaded via next/font/google (self-hosted,
no layout shift):
tabular-nums.components/ui/reveal.tsx,
an IntersectionObserver primitive that respects prefers-reduced-motion)
instead of a single flat page of uniform card grids.src/components/ui/ — shared primitives: Reveal, OptionRow (the
one "bordered selectable row" component used by the host form, checkout,
and level pickers, instead of three hand-rolled copies), StepBadge,
TestimonialCard.components/icons.tsx, one
component each, no library) — plus a second tier of deliberately looser
"doodle" icons (DoodleFindIcon, DoodleBookIcon, DoodlePlayIcon)
reserved for the homepage's "how it works" storytelling section.prefers-reduced-motion swaps it for a static frame via pure CSS
(motion-safe:/motion-reduce:), no JS.| Route | What it is |
|---|---|
/ | Landing — video hero, honest live stats, numbered "Find/Book/Play" steps, bento feature grid, spotlight pitch, testimonials, area-based footer |
/pitches | Discovery — search, area/size filters, sort by near/cheap/rated, spotlight result |
/pitches/[slug] | Pitch detail — venue info, amenities, slot picker, games happening there |
/pitches/[slug]/book | Checkout — payment method, fee-inclusive total |
/bookings/[reference] | Booking confirmation |
/games | Open games — search, level/when/side filters, spotlight soonest game |
/games/[slug] | Game detail — live fill bar, countdown, roster + waitlist, join button, "when to leave," host card |
/host | Host a game — slot picker, pricing guidance with live-computed warnings |
/players | Player directory |
/players/[handle] | Player card — trait radar, punctuality ring, streak, upcoming games, how reputation works |
/dashboard | Games joined/hosting, bookings, reputation strip, streak nudge, notification preferences |
/venue | Venue-owner dashboard — utilisation, projected revenue, upcoming bookings per pitch |
/partner | Venue-owner onboarding — leaves an interest lead for the team to follow up |
/verification | What the "Verified by Tempo" badge actually means, step by step |
/login, /signup, /signup/verify, /reset, /reset/confirm | Auth — real Supabase password auth with typed signup codes when configured, seeded demo sign-in otherwise |
/legal/{terms,privacy,refunds,community} | Legal pages |
/setup | Live setup checklist — shows whether the deploy is running demo mode or a real database |
/system | The design-system style guide |
auth.uid() and a trigger fires on
auth.users insert, so swapping to raw RDS/Azure Postgres would mean
rebuilding auth from scratch for no functional gain.A record (apex) and CNAME (www) at the values Vercel's Domains tab
shows for your project. New domains can take longer than the DNS TTL
suggests to fully resolve everywhere, independent of any record change,
due to registrar-side onboarding holds.payments
table and checkout UI exist; the initialise call and webhook handler
don't. A booking today is confirmed as a real, durable Postgres row with
the correct fee-inclusive total, it just doesn't move real money yet — the
checkout UI says so honestly.hello@ help@ safety@ privacy@npm install
npm run dev # demo mode with zero setup, or real Postgres if .env.local is configured
npm run build # production build
npm run start # serve the build
npm run type-check # tsc --noEmit
npm run lint # eslint
npm run db:migrations # compare local migrations with the linked Supabase project
npm run db:migrations:push # apply pending migrations to the linked Supabase project
npm run seed:staging # push sample venues only to the staging project
npm run audit:production-samples # inspect known sample venues in production; changes nothing
Open http://localhost:3000. No keys, no accounts required for demo mode — sign in as any seeded player from the login page to experience the product as a player, a host, or a venue owner.
When you're ready for a real database, follow the checklist on /setup
(also mirrored there in-app): create a Supabase project, copy .env.example
to .env.local, then link the Supabase CLI once:
npx supabase login
npx supabase link --project-ref <your-project-ref>
After that, use the migration commands instead of copy-pasting SQL:
npm run db:migrations # see what has/hasn't run remotely
npm run db:migrations:push # apply only pending migrations
npm run seed:staging
Tempo should use two separate Supabase projects and two separate Vercel environments. Preview/Staging uses sandbox payment keys and may contain sample venues. Production uses live payment keys and must contain real marketplace data only.
.env.staging.example
to .env.staging.local with its credentials. Keep production maintenance
credentials in .env.production.local, based on .env.production.example.npm run db:migrations:push:staging or
npm run db:migrations:push:production. The command refuses a mismatched
linked project.npm run seed:staging. The seed script never reads
.env.local and refuses any target other than staging.npm run audit:production-samples. If it finds
only empty known sample venues, remove them with
npm run purge:production-samples -- --confirm=REMOVE_TEMPO_SAMPLE_DATA.
The purge refuses to touch sample venues that have bookings, games or
settlement records./verification describes.hotfulllabelpercentspotsLeftguaranteedisLivehasEndedmsToKickoff