Dark / orange / black / white themed running-challenge website, built with Next.js 14 (App Router). Fully dynamic: real sign up / sign in, country-based entry-fee pricing, PayPal checkout, Strava and Google Fit sync, a personal progress dashboard, basic fake-run detection, and an admin dashboard that oversees every participant.
npm install
cp .env.example .env.local # fill in the values below
npm run dev- India: a fixed ₹199 entry fee, always shown as a proper Indian
Rupee amount (₹ symbol, Indian digit grouping) via the
en-INlocale inlib/format.js- regardless of the visitor's browser locale. - Every other country: the live local-currency equivalent of ₹399.
The visitor's country is auto-detected on signup (and can be overridden).
lib/pricing.jsconverts ₹399 into that country's currency using a live INR exchange-rate feed, cached for 6 hours, with a USD fallback if the lookup fails. - Detection is live per visit, not cached. The landing page and signup
form call
/api/geofresh on every load (both the route and the client fetches are explicitly set tono-store), so if you're in India you see INR, and if you switch on a VPN to another country and reload, you'll see that country's currency instead. Once a user actually signs up, theircountryis saved on their account and their dashboard price stays tied to that - so payment doesn't change retroactively if they change networks afterward. They can still pick a different country manually in the signup dropdown before creating the account.
/signup,/signin— real API routes inapp/api/auth/*. Passwords are hashed with bcrypt; sessions are a JWT in an httpOnly cookie.- PayPal checkout on the dashboard, priced server-side from the user's
country (never trusted from the client).
app/api/paypal/create-orderandapp/api/paypal/capture-orderdo the heavy lifting.
Once a user pays and connects a fitness source, daysCompleted is
computed from real synced activities, not typed in by hand:
- Connect Strava (
/api/strava/connect→/api/strava/callback) and/or Connect Google Fit (/api/google-fit/connect→/api/google-fit/callback). A user can connect either or both. - Sync now button (or automatically right after connecting) calls
POST /api/activities/sync, which pulls recent runs from every connected source (lib/strava.js,lib/googlefit.js), merges and de-duplicates them by activity ID, and recomputes the day count (lib/activities.js). - The dashboard shows a Run history list (date, source, distance, duration, and whether it counted) so the user can see exactly what's driving their day count.
- A Challenge start date section shows the date the user first connected
Strava or Google Fit (
user.startDate), plus which day of 100 they're on.
lib/email.js sends actual emails through Resend. If
RESEND_API_KEY isn't set, emails are logged to the server console instead
of failing, so everything still works in local dev without an email account.
- On signup — every user gets a thank-you/welcome email
(
sendWelcomeEmail) confirming their account and pointing them to the dashboard. - On first Fit ID assignment — the moment a user connects Strava or
Google Fit for the first time and a Fit ID is generated, they automatically
get a second email (
sendFitIdEmail) with that Fit ID. Both are triggered from real app events (app/api/auth/signup/route.jsandlib/sync.js), not sent from a static template with fake data.
Every connected account gets a Fit ID: a short fingerprint
(lib/fitId.js) derived from the Strava athlete ID or Google Fit user ID.
It's shown on the user's own dashboard and in the admin table. Because it's
tied to the real fitness account rather than the challenge account, if
someone signs up twice to farm two free t-shirts off one real runner's data,
both challenge accounts end up with the same Fit ID — and the admin
dashboard flags it as a duplicate.
lib/activities.js runs a few simple, explainable checks on every synced
activity and flags anything implausible:
- Distance claimed in under a minute
- Sustained pace faster than ~25 km/h (not realistic for a run)
- Manually entered activities with no GPS trace
Flagged runs don't count toward the 100 days, and show up with a reason on both the user's dashboard and the admin detail view. This is a first line of defense, not bulletproof anti-fraud — treat it as a starting point to refine with real usage data.
Set ADMIN_EMAILS in .env.local (comma-separated). Anyone signed in with
one of those emails gets an "Admin dashboard" link on their own
dashboard, and can visit:
/admin— every participant: country, fee status, day count, Fit ID, connected sources, and a flagged-runs count./admin/[id]— one participant's full run history, progress bar, and any other accounts sharing their Fit ID.
Both are backed by app/api/admin/users and app/api/admin/users/[id],
protected by isAdminEmail() in lib/auth.js.
Everything lives in data/users.json for easy local testing
(lib/db.js). Each user record includes: profile + auth fields, country,
hasPaid, Strava/Google Fit connection + tokens, fitId,
duplicateAccountAlert, activities (the normalized, evaluated run list),
and the derived daysCompleted.
For production, replace lib/db.js with a real database (Postgres +
Prisma/Drizzle, Supabase, MongoDB, etc). Every function in that file is the
seam to replace.
- Replace
lib/db.jswith a real database. - Move activity syncing from a manual "Sync now" button to a scheduled job (cron / queue) plus Strava webhooks, so data updates without the user needing to click anything.
- Add a shipping-address form that appears once
daysCompleted >= 100, for the free t-shirt. - Tune the fake-run thresholds in
lib/activities.jsagainst real data, and consider adding manual admin review/override for flagged runs. - Cache exchange rates in your database rather than in-memory, so pricing survives restarts and stays consistent across server instances.