A web app that picks a random restaurant, cafe, or bakery near you so you never have to argue about where to eat again. Built with Next.js and Apple's MapKit JS via mapkit-react.
Live at wsigte.com
- On load,
pages/index.tsxpings${NEXT_PUBLIC_API_BASE_URL}/v1/healthfirst. If it returns non-2xx or adownstatus, theDownScreen("BACK SOON.") renders with a Try again button that retries the boot sequence. Otherwise it fetches a short-lived MapKit JS JWT from${NEXT_PUBLIC_API_BASE_URL}/v1/tokenand caches it inlocalStorageuntil ~30s before expiry (decoded from the JWT payload). A failed token request also drops the user onto theDownScreenwith the HTTP status code stamped in the header. mapkit-reactboots a hidden map to acquire the user's coordinates via browser geolocation. If it fails or times out (10s), the Not Found screen takes over: it offers a manual address input (powered bymapkit.Search.autocomplete, debounced 200ms, min 3 chars, biased to the last known region), a "Locate me" button that retries browser geolocation, and a grid of curated neighborhood tiles that submit a geocoder lookup on tap. The neighborhood list is currently a static stub (DISTRICTSincomponents/NotFoundScreen.tsx) — a real API will replace it later.- The client
POSTs to${NEXT_PUBLIC_API_BASE_URL}/v1/recommendationswith{ latitude, longitude, excludedPlaceIds }. The server returns one recommendation drawn either from a curatedtop_pickstable (probabilistic — configured server-side) or from Apple's Maps Server API for nearbyBakery | Cafe | RestaurantPOIs. The recommendation carriesname,address,latitude,longitude,appleMapsPlaceId, and optionalblurb. - The recommendation is hydrated client-side via
new mapkit.PlaceLookup().getPlace(appleMapsPlaceId, cb)to populatetelephoneandurls(the Apple Maps Server API doesn't expose those — only the browser-side MapKit JS does). The loading screen stays up until hydration completes or a 4 s timeout falls back to the slim payload, so the result card always renders with contact info populated when available. - Rejections persist in
localStorage(shared/utils/rejections.ts) with a growing TTL — 1 d, 3 d, 7 d, 14 d, 30 d as a place is rejected more times. The active list is sent on every/v1/recommendationscall asexcludedPlaceIds. "That's awful" records a rejection, bumps a query-key version to force a refetch, and holds the rejection overlay for at least 1.7 s. mapkit.Directionsdraws a route polyline from the user to the selected pick; the map auto-fits both points.- When the server flags
source: "top_pick", the result screen renders the 03b · Top Pick dark takeover: aresult-layout--toppickmodifier flips the screen to dark mode, swaps marker/route to gold, shows a gold "★ Top Pick" badge above the headline, paints the place name gold, swaps the subtext to "You unlocked a top pick. Don't waste this." and adds a "Why this is a top pick" callout that surfacesrecommendation.blurb. - The share screen
POSTs the place to${NEXT_PUBLIC_API_BASE_URL}/v1/placesand renders a short link at/p/{shortId}, which hydrates fromGET /v1/places/{shortId}.
The whole UI is driven by a STATUS state machine (types/index.ts) plus a separate screen enum (loading | notfound | result | share).
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (Pages Router, static export) |
| Language | TypeScript, React 19 |
| Data Fetching | @tanstack/react-query (all API calls — see shared/api.ts and shared/queries.ts) |
| Maps | Apple MapKit JS via mapkit-react |
| Error Tracking | Bugsnag (production only, via ErrorBoundary) |
| Analytics | Umami + Google Analytics (production only) |
| Styling | Vanilla CSS, Google Fonts (Archivo, Inter, JetBrains Mono) |
pages/
index.tsx # TokenLoader: fetches/caches MapKit JWT, renders <Map />
p/[id].tsx # Shared place page — hydrates from /v1/places/:id
_app.js # Global SEO meta, GA + Umami script tags (prod only)
_document.tsx # HTML shell, font preconnect/link tags
components/
Map.tsx # State machine, geolocation, POI search, directions, screen routing
Header.tsx # App header
LoadingScreen.tsx # Loading state with rotating witty messages
NotFoundScreen.tsx # "We lost you" — manual address autocomplete, retry geolocation, curated neighborhood grid (stubbed)
DistrictGrid.tsx # Renders a list of districts as <DistrictTile>s
DistrictTile.tsx # Single neighborhood tile (number / city / name / sub) — exports the `District` type
DownScreen.tsx # "BACK SOON." — rendered when /v1/health is down or /v1/token fails
ResultScreen.tsx # Pick details, route, "Take me there" map-app picker
ShareScreen.tsx # POSTs place to API, builds short link + share tiles
Overlay.tsx # Status overlay
ErrorBoundary.tsx # Bugsnag in prod, console in dev
shared/
api.ts # Typed fetchers for all API endpoints (health, token, feature flags, places, recommendations)
queries.ts # React Query hooks: useHealthQuery, useTokenQuery, useFeatureFlag(s)Query, useRecommendationQuery, useSharedPlaceQuery, useCreateSharedPlaceMutation
queryClient.ts # makeQueryClient() — shared QueryClient defaults
constants.ts # Loading lines and rejection messages
hooks/ # useIsDev (gates analytics + bugsnag in dev)
utils/
index.ts # Barrel: re-exports track + session helpers
track.ts # Umami event helper (no-ops if script not loaded)
session.ts # Per-visit session counters surfaced via session_summary
rejections.ts # localStorage-backed rejection store (growing TTL: 1d → 30d) sent as excludedPlaceIds
types/
index.ts # STATUS enum (state machine)
mapkit.d.ts # MapKit type declarations
styles/globals.css # All styles
public/ # manifest.json, robots.txt, sitemap.xml, favicons
server.js # Optional local HTTPS dev server (geolocation needs HTTPS)
next.config.js # output: 'export' — fully static build
Two files exist but are currently unused: services/api.ts (empty) and shared/hooks/use-addsense.ts (defined but never imported).
The frontend is fully static; all stateful behavior lives behind NEXT_PUBLIC_API_BASE_URL. The expected endpoints:
| Method | Path | Purpose |
|---|---|---|
GET |
/ready |
Boot-time readiness check (verifies the API can reach its DB). 2xx means ready; anything else renders the DownScreen. |
GET |
/v1/token |
Returns { token, expiresAt, ttl } as JSON. The client caches token in localStorage and refreshes ~30 s before expiresAt. A failed response renders the DownScreen. |
POST |
/v1/recommendations |
Body: { latitude, longitude, excludedPlaceIds }. Returns { recommendation: { appleMapsPlaceId, name, address, latitude, longitude, blurb?, source }, searchRadiusMeters } or 404 with { error: { code: "NOT_FOUND", … } } when nothing in range. Drives the main "what should I eat" pick. |
POST |
/v1/places |
Body: { appleMapsPlaceId, name, address, latitude, longitude }. Returns { shortId, created } for use in /p/:shortId. |
GET |
/v1/places/:shortId |
Returns the stored place; 404 renders the "link expired" screen. |
GET |
/v1/feature-flags |
Returns { [flagName]: string } (values are arbitrary strings — use "true"/"false" for boolean-style flags). Fetched via React Query (useFeatureFlagsQuery) and exposed via useFeatureFlag(name) (true only when the value is exactly "true") or useFeatureFlagValue(name) for variant flags. Unknown flags are undefined. |
- Node.js (v18+)
- A backend API that implements the endpoints above — at minimum
GET /v1/tokenfor MapKit auth, plus/v1/placesif you want share links to work
The app does not sign its own JWT. It expects an external API to provide the token. To obtain the credentials needed by that API, visit Apple's MapKit JS documentation:
- Apple Developer Team ID
- MapKit JS Key ID
- MapKit JS Private Key (
.p8file)
-
Clone the repo:
git clone https://github.com/juancstlm/WSIGTE.git cd WSIGTE -
Install dependencies:
npm install
-
Create a
.env.localfile with your API base URL:NEXT_PUBLIC_API_BASE_URL=https://your-token-api.example.com -
Start the dev server:
npm run dev
Then open http://localhost:3000.
Geolocation requires a secure context. If npm run dev doesn't work for location access, you can use the included HTTPS server:
-
Generate a self-signed certificate:
openssl req -x509 -newkey rsa:2048 -keyout localhost.key -out localhost.crt -days 365 -nodes
-
Run the HTTPS server:
node server.js
Then open https://localhost:3000.
The app is configured for static export (output: 'export' in next.config.js):
npm run buildThis generates a fully static site in the out/ directory, ready to be deployed to any static hosting provider (Vercel, Netlify, GitHub Pages, S3, etc.).
Privacy-friendly analytics via Umami, self-hosted at analytics.juancastillom.com. The tracker script is loaded in pages/_app.js via next/script and is gated behind useIsDev, so it only runs in production builds. The Umami website ID comes from the NEXT_PUBLIC_UMAMI_WEBSITE_ID env var, set per Amplify branch — production and staging report to separate Umami sites so production stats stay clean.
Custom events are emitted through the helper at shared/utils/track.ts, which safely no-ops if Umami hasn't loaded.
| Event | Where | Data |
|---|---|---|
session_start |
once per visit, on _app mount |
{ entry, utm_source?, utm_medium?, utm_campaign?, utm_term?, utm_content? } |
session_summary |
best-effort on pagehide/visibility hidden |
{ searches, picksShown, picksRejected, tookDirections, sharedPlaceViewed } |
geolocation_granted |
browser geolocation success | — |
geolocation_denied |
user denied permission | { code } |
geolocation_error |
other geolocation failure | { code, message } |
geolocation_timeout |
10s elapsed without resolution | — |
shared_place_viewed |
/p/[id] resolved successfully |
{ id } |
shared_place_not_found |
/p/[id] 404 / fetch error |
{ id } |
shared_place_cta_clicked |
CTA on /p/[id] clicked |
{ cta, id?, from? } |
results_found |
/v1/recommendations returned a pick |
{ source } (top_pick or mapkit) |
no_results_found |
recommendation call returned 404 or errored | — |
pick_shown |
a random pick is rendered (fires for every pick, including re-rolls) | { pickNumber } |
pick_rejected |
"That's awful" clicked | { pickNumber } |
wrong_location_clicked |
"Wrong location" clicked | — |
manual_location_lookup |
user submits an address | — |
manual_location_lookup_failed |
geocoder couldn't resolve it | — |
autocomplete_suggestion_selected |
user picks an autocomplete result on the Not Found screen | { kind } (address / transit / area) |
district_tile_clicked |
curated neighborhood tile tapped | { name, city } |
locate_me_clicked |
"Locate me" button on Not Found screen | — |
notfound_input_cleared |
× button cleared the address input | — |
notfound_selection_cleared |
"change" button cleared a staged selection | — |
vote_cta_clicked |
"Start vote" CTA on the share screen (gated by voting flag) |
— |
feature_flags_loaded |
one-shot, once /v1/feature-flags resolves per session |
the raw flags object (e.g. { voting: false, "curated-districts": true }) |
take_me_there_clicked |
opens the map app picker | — |
map_service_selected |
Apple/Google/Waze chosen | { service } |
share_link_copied |
copy button on share screen | — |
share_native |
native navigator.share invoked |
— |
share_tile_clicked |
Messages/WhatsApp/Email/X tile | { tile } |
| Variable | Description |
|---|---|
NEXT_PUBLIC_API_BASE_URL |
Base URL of the API that serves the MapKit JS JWT token and shared place endpoints |
NEXT_PUBLIC_UMAMI_WEBSITE_ID |
Umami website ID for the current environment. Set per-branch in Amplify (production branch → prod site ID; staging branch → staging site ID) so each environment reports to its own Umami site. |
Set these in the AWS Amplify console under App settings → Environment variables. Because they're prefixed
NEXT_PUBLIC_, Next.js inlines them at build time. IfNEXT_PUBLIC_UMAMI_WEBSITE_IDis missing the Umami<Script>simply doesn't render.