A two-person "what should we do tonight?" picker. Each person swipes through activity ideas on their own phone, in real time. When you both swipe right on the same thing it shows up as a match. When you can't agree, spin a wheel across the mutual likes — or long-press one to commit. Either path ends on a celebratory lock-in screen with nearby spots pulled from Foursquare.
No accounts. A 4-letter room code is the only credential.
- Real-time two-player swiping. Both phones stay in sync over Firestore; no refresh, no polling.
- Mobile-first PWA. Locked viewport, safe-area-aware, swipes never get confused with scrolling. Add to home screen for the full app feel.
- 130+ curated default activities plus a
+button to drop custom ideas into the deck. Custom adds land a few cards deep so the image has time to preload before either partner reaches it. - One-shot top pick. Each user gets exactly one star — locks in immediately and is guaranteed to surface for the partner regardless of how they swipe.
- Undo the last pass. Likes are off-limits (rewinding could nullify a match the partner already saw), but the most recent discard is one tap away.
- Match grid with preview / commit. Tap a match thumbnail for a full preview; press and hold for 3 seconds (with a building shake) to lock that match in directly without spinning.
- Tiebreaker wheel. When two or more matches are tied, both partners opt in and spin a weighted wheel that lands on one of the mutual likes.
- Lock-in celebration. The winning activity gets a full-screen victory screen: hero photo, confetti, sparkles, and a Foursquare-powered list of nearby spots matching the activity.
- Global image cache. Every photo ever fetched for an activity is stored in a top-level Firestore collection. After the first ~130 hits across the app's lifetime, default activities serve from cache.
- Photo fallback chain. Unsplash primary, Pexels fallback, with a module-level throttle that backs off for an hour after a 429/403 from either vendor.
- Built-in feedback. A "give feedback" button on the home and lock-in screens opens a small rating + text form. Submissions are posted as tagged issues on the project's GitHub repo via a server-side token, so no third-party form service is involved.
- Hardened for public release. Firestore rules enforce a 30-day write TTL
and per-doc size caps; both
/apiroutes are origin-locked and IP rate-limited (feedback 3/min, places 30/min). Vercel Analytics tracks funnel events (room creation, partner-joined, matches, lock-ins) without any third-party trackers.
Vite + React 19 + React Router 7 + Framer Motion 12. Firestore for realtime sync. Unsplash + Pexels for activity photos. Foursquare for nearby places (proxied through a Vercel serverless function so the key stays server-side and CORS doesn't matter). Pure CSS — no Tailwind, no styled-components.
- https://console.firebase.google.com/ → Add project.
- Skip Google Analytics.
- In the project, click the Web (
</>) icon to register a web app. Copy thefirebaseConfigvalues — you'll paste them in the env file below. - Sidebar → Build → Firestore Database → Create database. Pick a region. Start in production mode.
- Rules tab → paste the contents of
firestore.rules→ Publish.
- https://unsplash.com/developers → sign up.
- New Application → accept the API guidelines → name it
swipe-date. - Copy the Access Key. Demo tier is 50 req/hr; the global image cache makes that more than enough.
- https://www.pexels.com/api/ → sign up.
- Copy the API key. Free tier is 200 req/hr + 20k/month.
- https://foursquare.com/developers/ → create a project.
- Generate a Service API Key under Service API keys. Don't fill in the OAuth section — that's only for end-user "log in with Foursquare" flows we don't use.
- Copy the key.
The in-app "give feedback" button files a tagged issue on a GitHub repo. If
you don't configure this, the button still renders but submissions fail with
no-token — completely optional for a personal/local install.
- https://github.com/settings/tokens?type=beta → Generate new token (fine-grained).
- Limit to the single repo you want feedback issues filed to.
- Permissions → Issues: read and write. That's all it needs.
- Copy the token. Set
GITHUB_TOKENin your env. Optionally setGITHUB_REPO=owner/repoif you're forking and want issues to land in your own fork instead of upstream.
cp .env.example .envFill in:
VITE_FIREBASE_*(six keys, from Firebase web-app config)VITE_UNSPLASH_ACCESS_KEYVITE_PEXELS_API_KEY(optional fallback)VITE_FOURSQUARE_API_KEY(for local dev; production usesFOURSQUARE_API_KEY)GITHUB_TOKEN(optional — enables the feedback button)GITHUB_REPO(optional — defaults to upstream, set to your fork'sowner/repoif forking)ALLOWED_ORIGINS(optional — comma-separated origin allowlist for the/api/*routes; if unset, only same-origin requests are accepted)
Vite only reads .env at process start — restart npm run dev after editing.
npm install
npm run devOpen the printed URL on your phone (same Wi-Fi as your laptop — start with
npm run dev -- --host so Vite binds to your network IP).
- Person A taps Start a new session, enters their name → gets a 4-letter code.
- Person B opens the app, taps Join session, types the code.
- Both swipe — right to like, left to pass. Tap the corner star on a card to mark it as your one top pick.
- Tap the heart in the top-right (or See matches when you're done) to see mutual likes and each other's stars.
- Tap the + floating button to add custom activities mid-session.
- From the matches screen, tap a match to peek at nearby spots, or press and hold for 3 seconds to lock it in directly. For ties, tap Decision Maker — once both partners opt in, the wheel spins.
- The lock-in screen shows the winner with confetti and nearby places. Tap we're out → to terminate the session for both partners.
- Push to GitHub.
- https://vercel.com → Add New → Project → import the repo.
- Framework preset: Vite.
- Environment Variables:
- All
VITE_FIREBASE_*keys (six) VITE_UNSPLASH_ACCESS_KEYVITE_PEXELS_API_KEY(optional)FOURSQUARE_API_KEY(noVITE_prefix — kept server-side only)GITHUB_TOKEN(optional, noVITE_prefix — enables the feedback button)GITHUB_REPO(optional, noVITE_prefix —owner/repofor feedback issues)ALLOWED_ORIGINS(optional, noVITE_prefix — set this if you serve the app from a custom domain different to the Vercel deployment URL)
- All
- Deploy. Both
/api/placesand/api/feedbackroutes are automatically served by Vercel's serverless functions fromapi/places.jsandapi/feedback.js. Both routes are origin-locked (same-origin only by default) and IP-rate-limited at the function entry. To enable Vercel's first-party analytics dashboard, toggle Analytics on for the project in the Vercel dashboard — the<Analytics />component is already wired up insrc/App.jsx.
If you previously set VITE_FOURSQUARE_API_KEY on Vercel, swap it for the
non-prefixed FOURSQUARE_API_KEY so the key never ships in the client bundle.
The same rule applies to GITHUB_TOKEN — never prefix it with VITE_.
api/
├── places.js Vercel function — Foursquare proxy (CORS workaround)
└── feedback.js Vercel function — files GitHub issues from the feedback modal
src/
├── firebase.js Firestore client init
├── data/defaultActivities.js 130+ curated activity defaults
├── hooks/
│ ├── useRoom.js Room CRUD, realtime subscription, swipe/star/tiebreaker writes
│ ├── useUnsplash.js Vendor-agnostic photo fetch (Unsplash → Pexels → global cache)
│ └── usePlaces.js Geolocation + /api/places client
├── components/
│ ├── SwipeCard.jsx One draggable card
│ ├── SwipeDeck.jsx Top-3 stack + prefetcher
│ ├── MatchesView.jsx Match grid + star row + hold-to-commit gesture
│ ├── ActivityPreviewModal.jsx Tap-to-peek sheet
│ ├── Tiebreaker.jsx Spin / result / lock-in three-phase overlay
│ ├── FeedbackDialog.jsx Rating + free-text feedback modal
│ ├── Hint.jsx Retro tap-to-dismiss contextual tips
│ └── PlacesList.jsx Foursquare results renderer
├── screens/
│ ├── Home.jsx Create / join
│ ├── Room.jsx Main swiping + celebration burst
│ ├── Matches.jsx Standalone matches screen
│ └── AddActivity.jsx Modal for custom ideas
├── App.jsx Router
└── index.css / App.css Styles
vite.config.js Dev middleware that mounts api/*.js at /api/*
firestore.rules No-auth design — anyone with a code can read/write the room
Photos are looked up in this order:
- In-memory promise dedupe — concurrent callers share one round trip.
imageCache/{activityId}— global Firestore collection, persists across every room ever created.- Unsplash search, with a 1-hour throttle that engages after any 429/403 to keep the demo tier alive.
- Pexels search as fallback, same throttle mechanism.
- On a successful fetch, the photo is written back to
imageCache/{activityId}and intorooms/{code}.images.{id}so the partner sees it through the existing room snapshot without a separategetDoc.
SwipeDeck prefetches metadata + bytes for the next 6 cards. The top card's
<img> uses fetchPriority="high"; the stack behind uses "low". All use
decoding="async".
Unsplash URLs are tuned at w=600&h=800&fit=crop&fm=jpg&q=75&auto=format&dpr=2
for ~80–150 KB WebP/AVIF. Pexels uses its src.portrait (800×1200 pre-cropped).
- Anyone with the 4-letter code can read & write the room — that's by design.
Treat the code like a shared secret. Room writes are capped at a 30-day TTL
in
firestore.rulesso abandoned codes can't be hijacked indefinitely. - Photos are credited inline per Unsplash's API guidelines.
- Foursquare and GitHub keys never ship to the browser when deployed (both
/apiroutes hold them server-side). - The serverless API routes (
/api/feedback,/api/places) are origin-locked (same-origin only by default; configurable viaALLOWED_ORIGINS) and IP-rate-limited (3/min and 30/min respectively). - Feedback submissions go directly to the configured GitHub repo as tagged issues. The contact field is optional and only stored where the issue is filed — no third-party form service is involved.
- Vercel Analytics is enabled if you toggle it on in the Vercel dashboard. It collects anonymous funnel events (room created, partner joined, match found, lock-in) with no personally identifying information. No third-party trackers are loaded.
In the Firestore console, delete the doc under rooms/{CODE}. To wipe the
global image cache, delete the imageCache collection.
MIT — see LICENSE.