Skip to content

Repository files navigation

100 Days Run Challenge

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.

Run it locally

npm install
cp .env.example .env.local   # fill in the values below
npm run dev

Open http://localhost:3000

Pricing

  • India: a fixed ₹199 entry fee, always shown as a proper Indian Rupee amount (₹ symbol, Indian digit grouping) via the en-IN locale in lib/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.js converts ₹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/geo fresh on every load (both the route and the client fetches are explicitly set to no-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, their country is 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.

Auth & payment

  • /signup, /signin — real API routes in app/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-order and app/api/paypal/capture-order do the heavy lifting.

Personal dashboard - your own data, not a manual counter

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.

Email - real, dynamic, not templated placeholders

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.js and lib/sync.js), not sent from a static template with fake data.

Fit ID - one identity per real athlete

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.

Catching fake runs

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.

Admin dashboard

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.

Data model

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.

Next steps to make it production-ready

  1. Replace lib/db.js with a real database.
  2. 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.
  3. Add a shipping-address form that appears once daysCompleted >= 100, for the free t-shirt.
  4. Tune the fake-run thresholds in lib/activities.js against real data, and consider adding manual admin review/override for flagged runs.
  5. Cache exchange rates in your database rather than in-memory, so pricing survives restarts and stays consistent across server instances.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages