Find out where the night is happening.
An interactive map for discovering music events, venues, and DJs — and a publishing tool for the artists and hosts who run them.
![]() |
![]() |
| Event discovery — light | …and dark, driven entirely by CSS variables |
Screenshots are rendered from the Storybook fixtures in
stories/, so they show representative data rather than whatever happens to be scheduled this week.
- What it does
- Engineering highlights
- Tech stack
- Quick start
- Environment variables
- How it fits together
- Project structure
- Routes
- State management
- The map system
- Styling and design system
- Storybook
- Contributing
- Roadmap and good first issues
- Known gaps
- License
Three roles share one app (ROLES in lib/constants.ts):
| Role | What they get |
|---|---|
event-goer |
Browse the map and event list, filter by genre/date/status, view event and DJ profiles |
dj |
The above, plus an Events Manager and the ability to create and edit events |
host |
The same tooling, scoped to venue-hosted events |
Core flows:
- Discover — the map plots events as pins; nearby ones cluster and separate as you zoom.
/eventsshows the same filtered set as cards. - Filter — genre, status (
live/upcoming/done), and a date range, held in one shared store so the map and the list never disagree. - Onboard — a 4-step flow (role → profile → genres → summary) after signup.
- Publish — DJs and hosts create events with image galleries, a map location picker with address autocomplete, and an ordered performer lineup. On create, they can upload a flyer to autofill the form: the backend reads the poster with AI, the form is prefilled with whatever it found, and the poster becomes the event's cover image.
Marker clustering without giving up the UI — components/map/clusters/
MapLibre ships native GeoJSON clustering, but it renders through the GL pipeline as circles, which can't host a hover flyer card or a pulsing "live" badge. Clustering instead runs in JS via supercluster, so cluster bubbles and individual pins stay real DOM markers and keep their full design. Because move fires every animation frame, the hook keys on a rounded bbox/zoom string and bails when nothing meaningful changed.
Anonymous-by-default auth — components/providers/AuthProvider.tsx
Visitors with no session are signed in anonymously, so public pages still make authenticated API calls without forcing anyone to register. The subtlety that falls out: user is truthy for anonymous visitors, so gating real-account UI requires isAuthenticated (!!user && !user.is_anonymous).
Two backends, one client — lib/axios.ts, next.config.ts
Supabase owns identity and file storage; a separate REST API owns domain data. The REST origin is never exposed to the browser — Next rewrites /api/backend/* to it — which also means zero CORS configuration. A request interceptor attaches the Supabase JWT from the in-memory session cache, so auth costs no round-trip.
A declarative MapLibre wrapper — components/ui/map.tsx
~1,400 lines turning an imperative GL library into React components (Map, MapMarker, MarkerPopup, MapRoute, MapControls). Markers are portalled into MapLibre marker elements, so arbitrary React lives on the map. The map follows next-themes by watching the <html> class with a MutationObserver rather than taking a prop.
Theming with zero dark: variants — app/globals.css
Every colour is a CSS variable. Components written against bg-background / text-foreground / bg-card adapt to dark mode for free — no duplicated colour values, no dark: classes.
Design-system discipline — the sidebar is the shadcn primitive with a custom off-canvas mobile path, inert on the closed panel so off-screen nav isn't tabbable, focus restoration, and scroll-lock — the accessibility a Dialog normally provides, re-implemented when the Sheet was removed.
| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) + React 19 |
| Language | TypeScript, strict: true |
| Styling | Tailwind CSS v4 (CSS-first, no tailwind.config.ts) + CSS-variable theming |
| Components | shadcn/ui primitives in components/ui/ |
| Map | MapLibre GL, Carto basemaps, supercluster |
| Geocoding | Google Places, proxied through Next route handlers |
| Auth & storage | Supabase |
| Domain data | Separate REST API, proxied via rewrite |
| HTTP | axios + Supabase JWT interceptor |
| State | Zustand (feature-scoped), next-themes |
| Component workshop | Storybook 10 (@storybook/nextjs-vite) |
| Tooling | ESLint 9 (flat config), Prettier 3 |
Prerequisites: Node 20+ and npm.
git clone https://github.com/jolonarvaez/function-finder-client.git
cd function-finder-client
npm installCreate .env.local in the project root:
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
NEXT_PUBLIC_API_URL=https://<your-backend>/api
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=<key>Then:
npm run dev # http://localhost:3000
npm run storybook # http://localhost:6006You don't need Supabase or the REST API to contribute to most of the UI. Storybook runs entirely on fixtures and is the fastest way in — every page and component has a story with representative mock data. A good deal of this project's UI work can be done in Storybook alone.
The app itself fails fast with a clear error if the Supabase variables are missing (lib/supabase.ts). The others degrade rather than crash: without NEXT_PUBLIC_API_URL no rewrite is registered and API calls 404; without the Maps key the geocode routes return HTTP 500.
All four are read at build time. There's no committed .env.example — this table is the reference.
| Variable | Required | Used by | Purpose |
|---|---|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Yes | lib/supabase.ts |
Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Yes | lib/supabase.ts |
Supabase public anon key |
NEXT_PUBLIC_API_URL |
Yes | next.config.ts |
Backend origin; registers the /api/backend/* rewrite |
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY |
For address search | app/api/geocode/* |
Places autocomplete, place details, reverse geocoding |
Basemap tiles come from Carto and need no key.
┌──────────────────────────────┐
browser ────────▶│ Supabase │ auth, session, image storage
└──────────────────────────────┘
browser ──▶ /api/backend/* ──rewrite──▶ NEXT_PUBLIC_API_URL events, users, lineups,
flyer extraction (AI)
browser ──▶ /api/geocode/* ──route handler──▶ Google Places address search
docs/ARCHITECTURE.md has the full breakdown — routing, component layers, the data layer, and styling. Short version:
- Route protection is per-page, not per-layout.
AuthGuardwraps each page under/dj/*,/host/*, and/settingsindividually. Adding a new authenticated page means adding the guard yourself — the(app)layout won't do it for you. - Route groups split the two shells:
(app)gets the sidebar + top nav + footer,(legal)is standalone. - Geocoding is proxied through three route handlers so the Google key and CORS stay server-side.
app/
(app)/ authenticated shell — sidebar + top nav + footer
page.tsx map (home)
map/ events/ venues/ profile/[id]/ settings/
dj/ create-event, edit-event/[id], event-manager
host/ create-event, edit-event/[id], event-manager
(legal)/ standalone shell — privacy, terms
api/geocode/ server-side Google Places proxy
login/ signup/ onboarding/ auth/callback/
layout.tsx root: fonts, metadata, providers, Toaster
robots.ts sitemap.ts
components/
ui/ shadcn/ui primitives ONLY — never feature code
auth/ login, signup, AuthGuard, user store
providers/ AuthProvider, ThemeProvider
sidebar/ app shell: TopNav, AppSidebar, ProfileFooter
map/ MapView, markers, filters, geolocation
clusters/ supercluster hook + cluster bubble marker
event/ list, detail, cards, gallery
event-form/ create/edit form, poster autofill, location picker, performer selector
dj/ onboarding/ profile/ settings/ venues/ legal/
shared/ cross-feature widgets (Logo, GenreSelector, Footer…)
reusables/ generic helpers (PageContainer, CountrySelect…)
lib/
axios.ts API client + JWT interceptor
supabase.ts lazily-constructed Supabase client
constants.ts roles, genres, categories, countries, map defaults
utils.ts cn()
services/ auth, events, users, storage, geocode
hooks/use-mobile.ts 768px breakpoint hook
stories/ Storybook, mirroring components/
docs/ ARCHITECTURE.md, screenshots
Feature-folder rule: related components get grouped into a feature folder; components/ui/ is reserved exclusively for shadcn primitives.
| Path | Auth | Description |
|---|---|---|
/, /map |
Public | Map view |
/events |
Public | Filterable event grid |
/events/[id] |
Public | Event detail |
/venues |
Public | Venue list |
/profile/[id] |
Public | Public user profile |
/settings |
Guarded | Account, profile, genres, theme |
/dj/event-manager |
Guarded | DJ's events |
/dj/create-event, /dj/edit-event/[id] |
Guarded | DJ event form |
/host/* (same three) |
Guarded | Host equivalents |
/login, /signup |
Public | Email + Google OAuth |
/onboarding |
Post-signup | 4-step setup |
/auth/callback |
— | OAuth redirect target |
/privacy, /terms |
Public | Legal |
Zustand stores live beside the feature they belong to, not in a central directory.
| Store | Location | Holds |
|---|---|---|
useMapFilterStore |
components/map/use-map-filter-store.ts |
Genres, query, status, date range |
useUserStore |
components/auth/use-user-store.ts |
Cached profile + loading flag |
useOnboardingStore |
components/onboarding/use-onboarding-store.ts |
Step, role, display name, bio, country, genres |
Theme is next-themes, not Zustand.
useMapFilterStore is the one to know: the map and the events page read the same store, so changing a filter in one changes both. setDateRange(type, ref) derives startDate/endDate from a "day" | "week" | "month" type plus a reference date, so components express intent rather than raw dates.
components/ui/map.tsx is a self-contained MapLibre wrapper exposing Map, MapMarker, MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel, MapPopup, MapControls, MapRoute, MapClusterLayer, and useMap.
supercluster projects every event into normalised Mercator and precomputes a cluster tree for every zoom level up front — zooming just swaps which precomputed set renders, which is why it stays smooth.
Two events merge when they're within 60 screen pixels of each other. Since zooming doubles the scale, the ground distance those 60px represent halves each step:
| Zoom | Events merge within |
|---|---|
| 11 | ~2.2 km |
| 12 | ~1.1 km |
| 13 | ~550 m |
| 14 | ~280 m |
| 16 | ~70 m (maxZoom) |
A bubble sits at the count-weighted average position of the events inside it — so it generally sits where no single event actually is. Clicking one calls getClusterExpansionZoom and eases to exactly the zoom where that cluster breaks apart.
use-geolocation.ts wraps watchPosition with a granted | denied | prompt status. The map silently snaps to the user's location once on load only if permission was already granted before this visit, without showing the marker or toggle. The explicit toggle animates and shows the marker; denial surfaces a dismissible banner.
All colour comes from CSS variables in app/globals.css. No hex values, no rgb(), no Tailwind colour utilities (blue-500, indigo-600) in components. Anything blue uses bg-primary / text-primary / border-primary.
That's what makes dark mode work: both palettes are variables, so components written against bg-background, text-foreground, border-border, bg-card, bg-muted, text-muted-foreground adapt automatically. Don't use Tailwind's dark: variant to re-specify a colour the theme already handles.
cn()fromlib/utils.tsfor every conditional or merged class- CVA for multi-variant components
@/maps to the project root- PascalCase functional components, props as
Readonly<{…}>or a namedXxxProps - WCAG AA: semantic HTML, visible focus states,
aria-labelon icon-only buttons, 44×44px touch targets,prefers-reduced-motionrespected
Adding a shadcn component: npx shadcn@latest add <component>
Stories mirror the component tree: component stories in stories/<feature>/, page stories in stories/Pages/<feature>/, titles using / for hierarchy (Pages/Event/EventsView).
- Component stories wrap the decorator in
<div className="mx-auto max-w-200 bg-background p-6">(≤800px). - Page stories use
layout: "fullscreen"and wrap in<div className="mx-auto w-full max-w-107.5 overflow-hidden bg-background">to simulate a 430px mobile viewport. Don't forcedark— let the theme stay fluid.
For pages that fetch their own data, export a display-only sub-component taking data as props (e.g. EventsContent beside EventsView) and write the story against that. .storybook/main.ts forwards NEXT_PUBLIC_* vars into the Vite build.
Contributions are welcome. Bug reports, design fixes, and accessibility improvements are all useful.
- Open an issue first for anything non-trivial, so we can agree on the approach before you spend time on it.
- Fork, then branch from
main. Name itfeat/…,fix/…,docs/…,refactor/…, orchore/…. - Make the change, keeping it focused — one concern per PR.
- Run the checks below until clean.
- Open a PR describing what changed and why, with before/after screenshots for anything visual.
npm run format # Prettier, writes in place
npm run lint # ESLint
npx tsc --noEmit # type checkAll three must pass. Note that npm run lint also walks the committed storybook-static/ bundle and reports thousands of vendor warnings — for a clean signal on your own work, scope it:
npx eslint components app lib hooks stories-
format,lint, andtsc --noEmitall clean - New component or page has a Storybook story
- No hardcoded colours — every colour maps to a
globals.cssvariable - Verified in both light and dark mode
- Verified at mobile (430px) and desktop widths
- Interactive elements have hover, focus-visible, and active states
- Icon-only buttons have an
aria-label - New authenticated page is wrapped in
AuthGuard
Add a component
- Put it in its feature folder under
components/(notcomponents/ui/— that's shadcn-only). - Type props as
Readonly<{…}>or a namedXxxProps. - Use
cn()for classes; CVA if it has variants. - Add
stories/<feature>/MyComponent.stories.tsxwith the ≤800px decorator.
Add a page
- Create
app/(app)/my-page/page.tsx. - If it needs a real account, wrap it in
AuthGuard. - If it fetches its own data, split out a display-only sub-component that takes data as props.
- Add
stories/Pages/…against that sub-component, using the fullscreen + 430px decorator.
Add a nav item — components/sidebar/AppSidebar.tsx. Every SidebarMenuButton needs a tooltip, since it's the only label visible when the sidebar is collapsed to icons.
Short imperative subject lines. Conventional Commits (feat:, fix:, docs:) are welcome but not enforced.
Genuinely useful, reasonably self-contained work:
| Idea | Why it matters |
|---|---|
| Add a test suite | There's no test runner at all. Vitest + Testing Library, or merging the cypress branch |
Add .env.example |
New contributors currently have to read this README to know what to set |
Remove storybook-static/ from git |
Would cut thousands of phantom lint warnings |
Rename NEXT_PUBLIC_GOOGLE_MAPS_API_KEY |
It's only read server-side; the prefix wrongly implies it's browser-safe |
Reconcile CLAUDE.md |
It still documents Stadia Maps, which the codebase no longer uses |
| Cluster hover previews | Show which events a bubble contains before committing to a zoom |
| Empty and error states | Several views only really handle the happy path |
| i18n | Copy is hardcoded English; the settings page already has a language row stubbed out |
Stated plainly, so nobody is surprised:
- No automated tests. No unit, integration, or E2E tests run in CI — CI is
lint+buildonly. A Cypress setup exists on the unmergedcypressbranch. - Stadia Maps references are stale.
CLAUDE.mddocumentsNEXT_PUBLIC_STADIA_API_KEYand aStadiaStyleIdtype inlib/maps/types.ts; neither exists. The map uses keyless Carto basemaps. CI still passes the unused secret. storybook-static/is committed, so repo-wide lint output is noisy.- A Supabase storage hostname is hardcoded in
next.config.ts(images.remotePatterns) — pointing at a different project needs that updated too. MapClusterLayeris dead code, superseded bycomponents/map/clusters/.
No licence file is currently present, which means the work is all rights reserved by default and others have no legal right to use, modify, or redistribute it.
If you want outside contributions, add a LICENSE — MIT is the usual choice for a project like this.



