diff --git a/.gitignore b/.gitignore index c2041ac1..8469a369 100644 --- a/.gitignore +++ b/.gitignore @@ -38,14 +38,10 @@ yarn-error.log* # secrets secrets.md -notes/ # one-off scripts scripts/migrate-itinerary-ids.ts -# generated reports -ga4-dashboard.html - # typescript *.tsbuildinfo next-env.d.ts diff --git a/handoff.md b/handoff.md index 49fde15e..36494657 100644 --- a/handoff.md +++ b/handoff.md @@ -1,9 +1,11 @@ # plan.wtf - Multi-Conference Side Event Guide ## Project Overview -A standalone Next.js app that displays crypto/tech conference side events with interactive map, list, table, and gallery views. Landing page at `plan.wtf` shows active conference cards with event counts, days-away badges, social links, and upcoming conferences with "Notify Me" email signups. Users can filter events by conference, date range, time range, tags, and more. Add events to your plan and build a personal itinerary with PNG export and shareable links. Social features include friend connections, check-ins, emoji reactions, and event comments. RSVP flow for Luma events with in-app registration overlay. +A standalone Next.js app that displays crypto/tech conference side events with interactive map, list, and table views. Users can filter events by conference, date range, time range, tags, and more. Star favorites and build a personal itinerary with PNG export and shareable links. Social features include friend connections, check-ins, emoji reactions, and event comments. **Live**: https://plan.wtf +**Legacy**: https://sheeets.xyz (301 redirects to plan.wtf via .htaccess) +**Vercel**: https://sheeets.vercel.app (redirects to plan.wtf) **Repo**: https://github.com/snackman/sheeets **Data Source**: Google Sheet `1xWmIHyEyOmPHfkYuZkucPRlLGWbb9CF6Oqvfl8FUV6k` (https://plan.wtf/data) @@ -12,155 +14,523 @@ A standalone Next.js app that displays crypto/tech conference side events with i ## Tech Stack - **Next.js 16** (App Router, Turbopack) - **TypeScript** -- **Tailwind CSS** (CSS custom properties theme system with `data-theme` attribute) +- **Tailwind CSS** (dark theme, warm-dark color scheme: stone backgrounds, amber accents) - **Mapbox GL JS** via `react-map-gl` (individual markers, zoom-aware labels) -- **Supabase** (auth via email OTP, itinerary sync, friends, check-ins, reactions, comments, POIs, image caching, ad tracking, profile pictures via Storage, RSVPs) -- **OpenAI** (GPT-4o-mini for sponsor extraction in crawl script) +- **Supabase** (auth via email OTP, itinerary sync, friends, check-ins, reactions, comments, POIs, image caching) - **@tanstack/react-virtual** (list virtualization for scroll performance) - **Google Sheets API** (service account auth for event submission) -- **Google Analytics** (GA4, custom event tracking) +- **Google Analytics** (GA4, measurement ID: `G-2WB3SFJ13V`, custom event tracking) - **html-to-image** for itinerary PNG export -- **localStorage + Supabase** for user state (plan items, itinerary, hidden events — dual storage with sync) +- **localStorage + Supabase** for user state (stars, itinerary — dual storage with sync) ---- +## Getting Started + +```bash +cd sheeets +npm install +npm run dev # http://localhost:3000 +``` + +### Environment Variables (`.env.local`) +``` +NEXT_PUBLIC_MAPBOX_TOKEN=pk.xxx # Required for map view +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxx # Supabase anon key +SUPABASE_SERVICE_ROLE_KEY=eyJxxx # For server-side operations +MAPBOX_TOKEN=pk.xxx # Server-side geocoding +GOOGLE_SERVICE_ACCOUNT_EMAIL=sheeets-writer@planwtf.iam.gserviceaccount.com +GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..." +``` + +### Geocoding (for map view) +Events have addresses but no lat/lng. Two systems: -## Recent Changes (This Session — May 3, 2026) - -All changes on branch `pineapple-22344-itinerary-overhaul` (PR #123). - -### Share Card Flyer Gallery -- **New file**: `src/app/api/image-proxy/route.ts` — server-side image proxy to bypass CORS for html-to-image canvas rendering -- **ShareCardModal** (`src/components/ShareCardModal.tsx`): - - Pre-fetches flyer images as data URLs: checks `imageCache` → batch POST `/api/og` → proxy via `/api/image-proxy` → FileReader → data URL map - - Flyers/Text Only toggle (`ShareCardMode` type) — skips image pre-fetching in text mode - - Passes `flyerImages` map and `mode` prop to template -- **ShareCardTemplate** (`src/components/ShareCardTemplate.tsx`): - - **Gallery mode** (default): 2-column grid of 486px square flyer tiles with event name (22px bold) and time (19px) above each image. Dark placeholder for events without flyers. - - **Text mode**: Classic layout with time + event name + address in a list - - End times now shown (e.g., "6:00p - 9:00p") - - Exported `ShareCardMode` type - -### /plan Page Improvements -- **Friend avatars**: Added `useFriends()` + `useFriendsItineraries()` hooks, computes `friendsByEvent` map, passes `friendsGoing` prop to EventCard (blue left border + avatar stack) -- **Google Calendar export**: Added `GoogleCalendarButton` (icon-only, 18px CalendarPlus) to header with `conferenceTimezone` and `exportableEvents` memos -- **View mode sync**: /plan page reads `STORAGE_KEYS.VIEW_MODE` from localStorage on mount via useEffect + `viewModeRestored` gate to prevent flash -- **Header cleanup**: - - Removed "My Plan (N events)" heading - - Conference dropdown moved next to back arrow (same flex group) - - Back button navigates to `/` instead of `/` - - Logo replaces calendar emoji, heading says "My Plan" not "plan.wtf" - - CalendarX icon replaced with plan.wtf logo in empty state -- **Schedule conflicts removed**: Removed conflict banner, per-card indicators, border styling, `detectConflicts` import -- **Larger link/copy icons**: Bumped from `w-3.5 h-3.5` to `w-4 h-4` in EventCard to match StarButton +1. **Build-time script** (populates static cache): +```bash +MAPBOX_SECRET_TOKEN=pk.xxx npx tsx scripts/geocode.ts +``` +Creates/updates `src/data/geocoded-addresses.json`. Matched to events via `normalizeAddress()` in `fetch-events.ts`. **Auto-runs on every deploy** via the `prebuild` npm script — skips gracefully if `MAPBOX_SECRET_TOKEN` isn't set. Set the token in Vercel env vars to auto-geocode new addresses on each deploy. + +2. **Runtime API** (`/api/geocode`): Events not in the static cache get geocoded on-the-fly via Mapbox. The `useEvents` hook detects un-geocoded addresses and POSTs to this endpoint. Uses proximity bias based on conference city. + +The geocode cache also stores a `matchedAddress` field (full street address from Mapbox). This is passed to `AddressLink` via the `navAddress` prop so Lyft/Uber/Google Maps receive the complete address even when the spreadsheet has a shorthand like "The 1up Arcade Bar". + +### POI Address Backfill +Existing user-added POIs may have incomplete addresses. Run to fix: +```bash +NEXT_PUBLIC_SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... MAPBOX_SECRET_TOKEN=... npx tsx scripts/backfill-poi-addresses.ts +``` --- -## Open PRs -| PR | Branch | Description | Preview | -|----|--------|-------------|---------| -| #126 | `calzone-34833-gallery-view` | Gallery view (flyer grid) | https://sheeets-git-calzone-34833-gallery-view-pizza-dao.vercel.app | -| #125 | `onion-98806-admin-submissions` | Admin submissions approval queue | https://sheeets-git-onion-98806-admin-submissions-pizza-dao.vercel.app | -| #123 | `pineapple-22344-itinerary-overhaul` | Itinerary page overhaul + share card flyer gallery | https://sheeets-git-pineapple-22344-itinerary-overhaul-pizza-dao.vercel.app | -| #122 | `worktree-agent-ab7637fb` | Luma RSVP flow + profile fields | https://sheeets-git-worktree-agent-ab7637fb-pizza-dao.vercel.app | -| #89 | `image-ad-column` | Image ad column (stale) | — | -| #78 | `luma-gmail-importer` | Gmail Luma importer (stale) | — | +## Architecture + +### Data Flow +``` +Google Sheet (GViz API, multiple tabs) + → fetch-events.ts (paginated, header detection, tag parsing, geocode lookup, synthetic tags) + → useEvents hook (lazy-geocodes missing addresses via /api/geocode) + → EventApp + ├── MapView (Mapbox GL, auto-centers on events, friend location markers) + ├── ListView (virtualized cards via @tanstack/react-virtual, grouped by date, OG images, reactions, comments) + └── TableView (compact rows with icon-only tags) +``` + +### Extracted Hooks (Architecture Refactor) +| Hook | File | Purpose | +|------|------|---------| +| useViewMode | src/hooks/useViewMode.ts | View mode state (list/map/table) with localStorage | +| useAuthGatedActions | src/hooks/useAuthGatedActions.ts | Auth-gated starring with pending action after login | +| useConferenceData | src/hooks/useConferenceData.ts | Conference-scoped memos (types, vibes, counts, friends) | +| useNowMode | src/hooks/useNowMode.ts | Auto-refresh tick for "Now" mode filtering | +| useAdminConfig | src/hooks/useAdminConfig.ts | Admin config key-value store | + +### Extracted Modules +| Module | File | Purpose | +|--------|------|---------| +| time-parse | src/lib/time-parse.ts | Time parsing and sorting helpers | +| tags | src/lib/tags.ts | TYPE_TAGS, VIBE_TAGS constants | +| conferences | src/lib/conferences.ts | Conference timezone helpers | +| storage-keys | src/lib/storage-keys.ts | localStorage key constants | +| user-display | src/lib/user-display.ts | Display name formatting | +| pois | src/lib/pois.ts | Points of interest types | +| json-ld | src/lib/json-ld.ts | JSON-LD structured data generation | +| api-validation | src/lib/api-validation.ts | Zod schemas for API routes | + +### Conference Tab System +Each conference tab in `EVENT_TABS` (in `src/lib/constants.ts`) defines: +- `gid` — Google Sheet tab ID +- `name` — Display name (used as filter key) +- `timezone` — IANA timezone for "Now" filter and date defaults +- `dates` — Array of ISO date strings for the date picker +- `center` — `{lat, lng}` for default map center and geocoding proximity + +**Current tabs:** + +| Tab | GID | Timezone | Dates | +|-----|-----|----------|-------| +| SXSW 2026 | 1543768695 | America/Chicago | Mar 5-18, 2026 | +| ETHCC 2026 | 437576609 | Europe/Paris | Mar 27-Apr 2, 2026 | + +Switching conferences resets the date range filter to that conference's dates. If the current time is before or after the conference date range, all events are shown. + +**Previously active tabs** (removed, GIDs for reference): +- ETHDenver: 356217373 +- Consensus Hong Kong 2026: 377806756 + +### Auth & User Data +``` +Supabase Auth (email OTP via Resend SMTP) + → AuthContext (signIn, verifyOtp, signOut) + → useItinerary (dual storage: localStorage + Supabase itineraries table) + → useCheckIns (fetches check-in counts + user IDs per event for friend indicators) + → useEventReactions (batch-fetches all reactions, optimistic toggle) + → useEventComments (per-event comments with profile joins) + → useEventCommentCounts (batch comment counts) + → useFriendLocations (upserts own location on mount, fetches friends via RPC) + → AuthModal (two-step: email → 6-digit code) + → UserMenu (profile, friends, check-in, submit event) +``` + +**Auth guards**: +- Starring an event requires auth — pending star completes after login + Supabase sync (race-condition safe via `ready` flag in useItinerary) +- Itinerary filter button requires auth — if user signs out or auth is dismissed, `itineraryOnly` is automatically deselected +- Toggling reactions requires auth — shows auth modal, no pending action stored + +**Email config**: +- SMTP via **Resend** (`smtp.resend.com:465`) +- Sender: `noreply@sheeets.xyz` +- DNS records (DKIM, SPF, DMARC) configured on Namecheap cPanel for `sheeets.xyz` +- Rate limit: 30 emails/hour (auto-increased with custom SMTP) + +### Event Submission +Users can submit events via the **SubmitEventModal** (accessible from UserMenu): +1. **Step 1**: Paste a Luma URL to auto-fill event details (via `/api/luma`), or enter manually +2. **Step 2**: Editable form with conference selector, all event fields, tag picker +3. **Step 3**: Success confirmation + +The submission writes to the **"Add Events Here"** section of the Google Sheet tab. The `findNextEmptyRow()` function in `google-sheets.ts` scans for the marker cell containing "Add Events Here", finds the header row below it, and appends to the first empty row after that. + +**URL parsing**: Submit form auto-detects platform from pasted URLs: +- **Luma** (lu.ma/*): Fetches event data via Luma API +- **Posh.vip**: Extracts event details via posh.vip page scraping +- **Eventbrite**: Standard og:image scraping with JSON-LD fallback + +**Service account**: `sheeets-writer@planwtf.iam.gserviceaccount.com` (GCP project: `planwtf`) +- Google Sheets API enabled +- Shared as Editor on the spreadsheet +- Env vars set in Vercel: `GOOGLE_SERVICE_ACCOUNT_EMAIL`, `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` + +### Social Features + +#### Emoji Reactions +- Predefined set: 🔥 ❤️ 💯 👍 🎉 👀 (in `REACTION_EMOJIS` constant) +- Rendered as pills `[🔥 3] [❤️ 1] [+]` via `EmojiReactions` component +- Highlighted border (orange) when user has reacted +- `compact` prop for smaller rendering in map popups +- Optimistic add/remove with revert on error +- Public reactions visible to all users (including anonymous); friends-only requires auth + friendship + +#### Event Comments +- `CommentSection` component: expandable, collapsed shows count link +- Scrollable list with avatar initial, name, time-ago, text +- Public/friends visibility toggle on input +- Delete own comments on hover +- `useEventComments(eventId)` hook fetches per-event, joins profiles for display names +- `useEventCommentCounts()` hook batch-fetches counts for all events + +#### Friend Location Sharing +- `useFriendLocations` hook: upserts own location via `navigator.geolocation.getCurrentPosition()` on mount (any view, not just map), fetches friends via `get_friends_locations()` RPC, re-fetches every 5 min +- `FriendMarker` component: 32px circular avatar (`unavatar.io/x/{handle}`), green/gray border based on recency, green pulse if <5min ago, 50% opacity if >1h stale, name + time-ago label visible at zoom >= 13 + +#### Green Check-in Friend Indicators +- `useCheckIns` returns `checkInUsersByEvent: Map` (user IDs per event) +- EventApp computes `checkedInFriendsByEvent` from check-in user IDs + friends list +- Green MapPin row on EventCard: "Sam, Alex checked in" +- `FriendsGoingModal` supports `accentColor` param ('blue' for itinerary, 'green' for check-in) + +### Multi-Tab Sheet Structure +The Google Sheet has multiple tabs. Each event tab has: +- **Header rows** (rows 1-12): Sponsor info, social links, promo content +- **Data header** (row 13): `"", "Start Time", "End Time", "Organizer", "Event Name", "Address", "Cost", "Tags", "Link", "", "", "Note"` +- **Event rows**: Below the header until the first empty row +- **"Add Events Here" section**: Below the main events, with its own header row for user submissions + +The parser finds the header row by looking for `col B = "Start Time"`, then reads events until the first empty row. + +### File Structure +``` +src/ +├── app/ +│ ├── page.tsx # Renders EventApp +│ ├── layout.tsx # Inter font, dark theme, viewport config, GA4 script, JSON-LD +│ ├── globals.css # Tailwind + Mapbox CSS + warm-dark theme vars + safe-area insets +│ ├── sitemap.ts # Dynamic sitemap generation +│ ├── robots.ts # Robots.txt configuration +│ ├── itinerary/ +│ │ ├── page.tsx # Standalone itinerary page +│ │ └── s/[code]/page.tsx # Shared itinerary viewer +│ ├── admin/page.tsx # Admin panel (sponsors, ticker, ad inventory, drag-reorder) +│ └── api/ +│ ├── page.tsx # Agent API documentation page +│ ├── CodeBlock.tsx # Tabbed code examples with copy button +│ ├── geocode/route.ts # Runtime geocoding via Mapbox (POST, batch up to 25) +│ ├── og/route.ts # OG image resolution (Luma API + HTML scraping + JSON-LD, Supabase cache) +│ ├── fetch-event/route.ts # Fetch single event details for posh.vip/other platforms +│ ├── luma/route.ts # Luma event scraper for submit form auto-fill +│ └── submit-event/route.ts # Event submission to Google Sheet via service account +├── components/ +│ ├── EventApp.tsx # Main orchestrator - hooks, view routing, auth guards +│ ├── Header.tsx # Logo, view toggle, itinerary badge, auth (sticky) +│ ├── AuthModal.tsx # Email OTP auth, UserMenu with profile, friends, check-in, submit event +│ ├── SubmitEventModal.tsx # 3-step event submission: URL paste → form → success +│ ├── ViewToggle.tsx # Map | List | Table toggle +│ ├── FilterBar.tsx # Conference tabs, Now toggle, Filters (conference-aware date bounds) +│ ├── DateTimePicker.tsx # Custom date dropdown + 30-min time dropdown (accepts dates prop) +│ ├── AddressLink.tsx # Address tap → Google Maps / Uber / Lyft (mobile sheet, desktop direct) +│ ├── SearchBar.tsx # Debounced text search (300ms) +│ ├── TagBadge.tsx # Tag icon with color (supports iconOnly mode, custom SVG crypto icons) +│ ├── EventCard.tsx # Event card: OG image, details, friends going/checked-in, reactions, comments +│ ├── ListView.tsx # Virtualized cards via @tanstack/react-virtual, grouped by date, sticky headers +│ ├── TableView.tsx # table-fixed layout, fills viewport, CSS truncate +│ ├── MapView.tsx # Mapbox map, auto-centers, friend markers, conference-aware center +│ ├── MapViewWrapper.tsx # Dynamic import wrapper (ssr: false) +│ ├── MapMarker.tsx # Clock-face SVG pin (white wedge, 19px), opaque amber featured labels +│ ├── EventPopup.tsx # Map popup with OG image, star, reactions, checked-in friends +│ ├── EmojiReactions.tsx # Row of emoji pills with picker dropdown +│ ├── CommentSection.tsx # Expandable comment list with input +│ ├── FriendMarker.tsx # Map marker for friend locations (avatar, pulse, time-ago) +│ ├── NativeAdCard.tsx # Native ad card interleaved in list view +│ ├── POIPopup.tsx # POI map popup with AddressLink, share toggle, delete +│ ├── POIMarker.tsx # POI map pin +│ ├── POISearchBar.tsx # Mapbox Search Box for adding POIs +│ ├── StarButton.tsx # Star toggle (yellow), friends count badge (orange) +│ ├── ItineraryPanel.tsx # Slide-over panel with conflict detection, share, PNG export +│ ├── FriendsPanel.tsx # Friends list slide-over with remove friend +│ ├── OGImage.tsx # Lazy-loaded OG image thumbnails (flexible height for non-square images) +│ ├── SponsorsTicker.tsx # Scrolling sponsor/announcement ticker below header +│ ├── Providers.tsx # AuthProvider wrapper +│ └── Loading.tsx # Spinner +├── contexts/ +│ └── AuthContext.tsx # Supabase auth context (email OTP) +├── hooks/ +│ ├── useEvents.ts # Fetch + parse events from GViz, lazy-geocode via /api/geocode +│ ├── useFilters.ts # Filter state (conference-aware dates, resets on conference switch) +│ ├── useItinerary.ts # Dual storage: localStorage + Supabase sync +│ ├── useFriends.ts # Friend list (bidirectional query + profiles) +│ ├── useFriendRequests.ts # Friend search, send/accept/reject/cancel requests +│ ├── useFriendsItineraries.ts # Friends' event lists (RPC-based) +│ ├── useCheckIns.ts # Check-in counts + user IDs per event (Map) +│ ├── useEventReactions.ts # Batch reactions with optimistic toggle +│ ├── useEventComments.ts # Per-event comments with profile joins +│ ├── useEventCommentCounts.ts # Batch comment counts +│ ├── useFriendLocations.ts # Location sharing: upsert own, fetch friends via RPC +│ ├── useProfile.ts # Current user's profile +│ ├── usePOIs.ts # Points of interest CRUD +│ ├── useGeocoder.ts # Mapbox Search Box API for POI search +│ ├── useViewMode.ts # View mode state with localStorage persistence +│ ├── useAuthGatedActions.ts # Auth-gated starring with pending action +│ ├── useConferenceData.ts # Conference-scoped memos (types, vibes, counts, friends) +│ ├── useNowMode.ts # Auto-refresh tick for "Now" mode +│ └── useAdminConfig.ts # Admin config key-value store +├── lib/ +│ ├── geo.ts # distanceMeters() — haversine distance +│ ├── gviz.ts # GViz response parser +│ ├── types.ts # TypeScript types (ETHDenverEvent, FilterState, ReactionEmoji, NativeAd, etc.) +│ ├── constants.ts # EVENT_TABS (with dates/tz/center), tag colors, REACTION_EMOJIS +│ ├── analytics.ts # GA4 custom event tracking (gtag wrapper) +│ ├── supabase.ts # Supabase client (singleton, anon key) +│ ├── google-sheets.ts # Service account auth, findNextEmptyRow, appendEventRow +│ ├── fetch-events.ts # GViz fetch + synthetic tag generation + geocode lookup +│ ├── fetch-events-cached.ts # Cached event fetching for server-side use +│ ├── filters.ts # Filter logic (conference-aware timezone in getConferenceNow) +│ ├── utils.ts # Date formatting, normalizeAddress, time parsing +│ ├── calendar.ts # ICS export for itinerary +│ ├── json-ld.ts # JSON-LD structured data generation (Schema.org Event, CollectionPage) +│ ├── api-validation.ts # Zod schemas for API route validation +│ ├── time-parse.ts # Time parsing and sorting helpers +│ ├── tags.ts # TYPE_TAGS, VIBE_TAGS constants +│ ├── conferences.ts # Conference timezone helpers +│ ├── storage-keys.ts # localStorage key constants +│ ├── user-display.ts # Display name formatting +│ └── pois.ts # Points of interest types +├── data/ +│ └── geocoded-addresses.json # Cached geocoded addresses +scripts/ +├── geocode.ts # Build-time geocoding script (multi-tab, proximity-aware) +└── backfill-poi-addresses.ts # One-time script to fix incomplete POI addresses +packages/ +└── mcp-server/ # MCP server package for AI agent integration +supabase/ +├── migrations/ # SQL migrations (run via supabase db push --linked) +└── functions/ + └── agent-api/index.ts # RESTful API edge function for AI agents +public/ +└── logo.png # plan.wtf brand logo (black on transparent, inverted to white in header) +``` + +### Key Types (`src/lib/types.ts`) +```typescript +interface ETHDenverEvent { + id, date, dateISO, startTime, endTime, isAllDay, + organizer, name, address, cost, isFree, + vibe, // Primary tag (first tag) + tags: string[], // All tags + synthetic tags ($$, Food, Bar) + conference, // Which tab this event belongs to + link, hasFood, hasBar, note, + lat?, lng?, matchedAddress?, + timeOfDay, isDuplicate? +} + +type ReactionEmoji = '🔥' | '❤️' | '💯' | '👍' | '🎉' | '👀' + +interface EventComment { + id, event_id, user_id, text, + visibility: 'public' | 'friends', + created_at, display_name?, x_handle? +} + +interface FriendLocation { + user_id, lat, lng, updated_at, + display_name?, x_handle? +} + +interface NativeAd { + id, title, description, ctaText, ctaUrl, + imageUrl?, sponsor, active, position? +} + +type ViewMode = 'map' | 'list' | 'table' + +interface FilterState { + conference, // Single-select: which conference to show + startDateTime, // ISO local: "2026-03-12T14:00" (conference-aware default) + endDateTime, // ISO local: "2026-03-18T23:30" + vibes, // Selected tags (includes $$, Food, Bar) + selectedFriends, // Filter to events where selected friends are going + itineraryOnly, // Show itinerary only + searchQuery, // Text search + nowMode // Show happening-now events +} +``` + +### Supabase Tables + +| Table | Purpose | Key Columns | +|-------|---------|-------------| +| `profiles` | User metadata | user_id, display_name, x_handle, email, rsvp_name | +| `itineraries` | User's starred events | user_id, event_ids (text[]), updated_at | +| `check_ins` | User check-ins at events | user_id, event_id, lat, lng; UNIQUE(user_id, event_id) | +| `friendships` | Bidirectional friends (user_a < user_b) | user_a, user_b | +| `friend_requests` | Friend request flow | sender_id, receiver_id, status | +| `friend_codes` | Shareable friend invite codes | user_id, code (unique) | +| `shared_itineraries` | Shareable itinerary links | short_code (unique), event_ids, created_by | +| `event_images` | OG image cache | event_id (PK), source_url, image_url | +| `event_reactions` | Emoji reactions | event_id, user_id, emoji, visibility; UNIQUE(event_id, user_id, emoji) | +| `event_comments` | Event comments | event_id, user_id, text (1-500 chars), visibility | +| `user_locations` | Friend location sharing | user_id (PK), lat, lng, updated_at | +| `rsvps` | RSVP tracking | user_id, event_id, luma_api_id, status, method | +| `pois` | User points of interest | id (uuid), user_id, name, lat, lng, address, category, note, is_public | +| `api_keys` | Agent API keys | user_id, key_hash (SHA-256), key_prefix, scopes, revoked_at | +| `api_rate_limits` | Rate limiting | key_hash, window_start, request_count | +| `events_cache` | 15-min TTL events cache for API | id, conference, date_iso, tags, link, cached_at | +| `admin_config` | Key-value config store for admin settings | key (PK), value (jsonb), updated_at | + +**Supabase project**: `qsiukfwuwbpwyujfahtz` (sheeets) + +### Server-Side Aggregation RPCs +| RPC | Purpose | +|-----|---------| +| `get_event_reaction_summaries` | Batch reaction counts for all events | +| `get_event_comment_counts` | Batch comment counts for all events | +| `get_event_checkin_counts` | Batch check-in counts for all events | + +### RLS Policies (Social Features) + +**Reactions & Comments visibility:** +- "Anyone can read public reactions/comments" — no auth required, `visibility = 'public'` +- "Friends can read friends-only reactions/comments" — requires auth + `are_friends()` check +- Authenticated users can insert/delete their own + +**`are_friends()` helper** (SECURITY DEFINER): checks `friendships` table for bidirectional friendship. + +**`get_friends_locations()` RPC** (SECURITY DEFINER): returns `{user_id, lat, lng, updated_at}[]` for calling user's friends by joining `user_locations` with `friendships`. + +### SECURITY DEFINER Functions +- `get_friends_itineraries()` — Returns friends' event_ids +- `get_friends_locations()` — Returns friends' last known locations +- `respond_to_friend_request(request_id, accept)` — Accept/reject + create friendship +- `send_friend_request(receiver)` — Send or auto-accept mutual requests +- `search_users(search_query, search_type)` — Find users by exact email/display_name/x_handle +- `check_rate_limit(key_hash, window_min, max_req)` — Atomic rate limiting counter +- `are_friends(uid1, uid2)` — Check if two users are friends + +### Edge Functions + +| Function | Auth | Purpose | +|----------|------|---------| +| `agent-api` | API key (`shts_` prefix) | RESTful API for AI agents — events, itinerary, friends, RSVPs, recommendations | --- -## Pending Work +## Hosting & DNS -### Not yet pushed -- **Blue header theme changes** — on local `calzone-34833-luma-rsvp` branch (globals.css, Header.tsx, ViewToggle.tsx, AuthModal.tsx) +### plan.wtf (primary domain) +- **Registrar**: Namecheap (API user: `snackman`) +- **DNS**: Namecheap BasicDNS (`dns1.registrar-servers.com`) +- **A Record**: `@` → `76.76.21.21` (Vercel) +- **CNAME**: `www` → `cname.vercel-dns.com` +- **SSL**: Vercel auto-provisioned -### Gallery view card redesign -- Updated plan at `plans/flyer-gallery-view.md` — overlay bar on image instead of text below, StarButton (not stars) -- Not yet implemented +### sheeets.xyz (legacy, redirects to plan.wtf) +- **Hosting**: Namecheap cPanel (`premium266.web-hosting.com`) +- **DNS**: `dns1.namecheaphosting.com` (hosting DNS, NOT registrar DNS) +- **Redirects**: via `.htaccess` — all paths redirect to `plan.wtf` except `/admin` → Google Sheet +- **Email DNS**: DKIM, SPF, DMARC configured for Resend -### DB migration needed -- `supabase/migrations/20260502_add_profile_fields.sql` — `ALTER TABLE profiles ADD COLUMN telegram_handle text; ADD COLUMN company text;` -- Must be applied to Supabase production before merging PR #122 +### Vercel +- **Team**: `pizza-dao` +- **Project**: `sheeets` +- **Production URL**: https://plan.wtf +- **Auto-deploy**: from `master` branch -### Luma pre-fill research (concluded) -- Luma embed iframe does NOT support pre-filling name/email via URL params or postMessage -- Only supported params: `coupon`, `utm_source` -- Server-side API registration possible but requires organizer Luma Plus API keys (not viable for arbitrary events) -- Current copy-fields approach is the best available UX +### GCP (Google Cloud) +- **Project**: `planwtf` (ID: 352068458659) +- **Service account**: `sheeets-writer@planwtf.iam.gserviceaccount.com` +- **APIs enabled**: Google Sheets API, Google Drive API +- **CLI**: `gcloud` installed via Homebrew, authenticated as `snax.ynot@gmail.com` + +### Redirects +| From | To | Method | +|------|----|--------| +| `sheeets.vercel.app/*` | `plan.wtf/*` | Vercel auto-redirect | +| `sheeets.xyz/*` | `plan.wtf` | .htaccess 301 | +| `sheeets.xyz/admin` | Google Sheet (admin) | .htaccess 301 | +| `plan.wtf/data` | Google Sheet (data) | Next.js redirect | --- -## Plans - -### Active -- `plans/pineapple-22344-itinerary-overhaul.md` — Itinerary overhaul + share card flyer gallery (PR #123, ready to merge) -- `plans/flyer-gallery-view.md` — Gallery view (implemented, card redesign pending) - -### Pending (not yet implemented) -- `plans/admin-conference-management.md` -- `plans/automated-testing.md` -- `plans/calendar-export.md` -- `plans/event-click-tracking.md` -- `plans/featured-events.md` -- `plans/google-calendar-export.md` -- `plans/image-ad-column.md` -- `plans/instant-geocoding.md` -- `plans/itinerary-privacy.md` -- `plans/luma-gmail-importer.md` -- `plans/pineapple-49198-ai-sponsor-extraction.md` -- `plans/profile-picture.md` -- `plans/salami-77082-landing-page.md` -- `plans/sponsor-admin-ui.md` -- `plans/sponsor-crawling.md` - -### Done -- `plans/done/ad-placements.md` -- `plans/done/olive-29895-checkin-buttons.md` -- `plans/done/per-conference-ad-inventory.md` -- `plans/done/submit-form-improvements.md` -- `plans/done/sxsw-theme.md` -- `plans/done/sxsw-theme-v2.md` -- `plans/done/tomato-46571-theme-toggle.md` +## Adding a New Conference Tab + +1. Find the tab's `gid` from the spreadsheet HTML: `curl -sL "https://docs.google.com/spreadsheets/d/SHEET_ID/edit" | grep -oE '.{0,50}TAB_NAME.{0,50}'` — look for the number in quotes before the tab name +2. Add to `EVENT_TABS` in `src/lib/constants.ts` with `gid`, `name`, `timezone`, `dates`, and `center` +3. Add a proximity center in `scripts/geocode.ts` → `PROXIMITY_CENTERS` and in `src/app/api/geocode/route.ts` → `PROXIMITY` if the conference is in a new city +4. Run the geocoding script to cache new addresses --- -## Key Architecture Notes - -### Share Card Image Pipeline -- **Image proxy**: `/api/image-proxy?url=` — server-side fetch bypasses CORS for html-to-image canvas rendering -- **Pre-fetch flow**: `imageCache` (OGImage.tsx) → batch POST `/api/og` → proxy fetch → FileReader → data URL map -- **Two modes**: `ShareCardMode` = `'gallery'` (flyer grid) or `'text'` (classic list). Text mode skips image fetching entirely. -- **Rendering**: html-to-image `toPng()` at 2x pixel ratio, 1080px card width. Uses explicit pixel sizes (not CSS grid/aspectRatio) for html-to-image compatibility. - -### RSVP System -- `isLumaUrl()` / `getLumaSlug()` in `src/lib/luma.ts` — matches `lu.ma`, `luma.com`, `www.luma.com` -- `useRsvp` hook — loads confirmed RSVPs from `rsvps` table, manages overlay state -- `RsvpButton` — only renders for Luma events (inline `isLumaUrl` check) -- `RsvpOverlay` — self-contained portal with inline `getLumaSlug`, copy fields, Luma iframe -- Embed URL: `https://lu.ma/embed/event/{slug}/simple` (also works with `luma.com`) -- DB table: `rsvps` (id, user_id, event_id, luma_api_id, status, method, created_at) - -### Performance Caching -- **Edge caching**: `/api/events` (5min), `/api/geocoded-addresses` (1hr) via `Cache-Control` headers -- **Server cache**: `fetchEventsCached()` uses `unstable_cache` with 5min revalidation -- **SSR hydration**: `[slug]/page.tsx` passes `initialEvents` to `EventApp` — zero loading spinner -- **Client cache**: sessionStorage with 5min staleness in `useEvents` hook -- **OG image cache**: Module-level `imageCache` Map in `OGImage.tsx`, batch POST `/api/og` for gallery - -### Friend Data Pipeline -- `Friend` type (`src/lib/types.ts`) — raw Supabase profile fields (`user_id`, `avatar_url`, `x_handle`, etc.) -- `FriendInfo` type — lightweight view type (`userId`, `displayName`, `avatarUrl`, `xHandle`) -- `useFriendsItineraries` hook — fetches friend itineraries, merges display names + avatars -- `useConferenceData` hook — builds `friendsByEvent` and `checkedInFriendsByEvent` maps (both `Map`) -- `UserAvatar` component — fallback chain: uploaded avatar → X/Twitter photo via unavatar.io → deterministic color initials - -### Theme System -- Themes defined as CSS custom properties in `src/app/globals.css` using `[data-theme="name"]` selectors -- Theme metadata in `src/lib/themes.ts` — `ThemeId` union type + `THEME_OPTIONS` array -- **Header variables**: `--theme-header-bg`, `--theme-header-border`, `--theme-header-logo-filter`, `--theme-header-text`, `--theme-header-control-*`, `--theme-header-accent-*` -- Per-conference theme selection via admin config (`theme:{conference}` key in Supabase) -- 8 themes: dark, paper, light, light-blue, sxsw, sxsw2, gdc, ethcc - -### Icon System -- Most icons from `lucide-react` -- Custom planwtf calendar SVG at `src/components/icons/CalendarIcon.tsx` -- Event add-to-plan: `Plus`/`Check` in circular container (`StarButton.tsx`) -- View toggle: Map, List, Table, LayoutGrid (gallery) +## Recent Changes (2026-03-07) + +### Merged This Session +| PR | Description | +|----|-------------| +| #45 | Drag-to-reorder sponsors in admin panel | +| #47 | Friends filter empty state with invite link | +| #54 | JSON-LD structured data, SEO metadata, sitemap, robots.txt | +| #55 | Server-side aggregation RPCs for batch data fetching | +| #56 | List virtualization with @tanstack/react-virtual | +| #57 | Architecture refactor: extract hooks, split modules, Zod validation | +| #58 | Fix Eventbrite image fetching via JSON-LD extraction | +| #59 | Posh.vip link parsing for submit + image display | + +### Direct Master Commits +- Opaque featured labels on map (solid amber instead of transparent) +- Food/Bar priority in tags filter (sort to front) +- Smaller emoji reaction pills (match comments button height) +- Remove submit button pop-out icon + +### Open PRs (not yet merged) +| PR | Branch | Description | Status | +|----|--------|-------------|--------| +| #60 | advertiser-page | /advertise page + admin Ad Inventory tab | Draft | +| #53 | tag-count-badges | Tag count badges on filter pills | Has merge conflicts | +| #46 | worktree-agent-a678de75 | Onboarding wizard for first-time users | Draft | +| #40 | luma-rsvp-v2 | Luma RSVP with in-page checkout | Draft | + +--- + +## Development Notes + +### tsconfig Excludes +`packages/` and `supabase/` are excluded from the root tsconfig. They have their own TypeScript configs and dependencies. Next.js will fail to build if these are included. + +### Key Patterns +- **Warm-dark color scheme**: stone backgrounds (stone-950, stone-900, stone-800), amber accents. All slate references replaced with stone. +- **Conference-aware defaults**: `useFilters` resets date range when switching conferences via `getDateTimeRangeForConference()`. `getConferenceNow(conference)` uses per-conference timezone. Map centers on conference city. +- **Auth-gated actions**: Star and RSVP use the same pattern — `pendingRef` stores the action, shows AuthModal, completes after login via useEffect +- **Profile fields**: Column is `rsvp_name` (NOT `farcaster_username`) +- **Address links**: Use `` component everywhere — desktop opens Google Maps, mobile shows Maps/Uber/Lyft drawer. Pass `navAddress` prop with the full Mapbox address. +- **Profile auto-save**: Profile fields debounce-save after 800ms of inactivity +- **Friends refresh**: `refreshFriends` is shared from EventApp → Header → UserMenu +- **Optimistic updates**: Reactions, comments, friend requests all use optimistic UI with revert on error +- **Scroll-based filter bar**: Both table and list views hide the filter bar on scroll down and show it on scroll up. Both have an `overflowAmount > 80` guard to prevent jitter. +- **Contained scroll**: All three views use `h-dvh flex flex-col overflow-hidden` layout +- **Check-in badges**: Green numbered badges on the time field. Green friend rows on event cards. +- **Check-in logic**: Gets GPS, finds itinerary events within 150m that pass "now" filter, upserts to `check_ins` table +- **OG images**: Lazy-loaded via IntersectionObserver. Luma uses API for clean cover images. Eventbrite uses JSON-LD extraction with og:image fallback. Other sites use HTML og:image scraping with relative URL resolution. Cached in Supabase `event_images` table. +- **Event submission**: Writes to "Add Events Here" section of the sheet. Finds marker cell, then header row below, appends to first empty row. +- **List virtualization**: @tanstack/react-virtual with variable-height items via measureElement. Flat list interleaves date headers, event cards, and native ads. +- **Food/Bar tag priority**: 🍕 Food and 🍺 Bar always sort to front of vibes filter list. +- **Prop threading pattern**: Social data (reactions, comments, check-ins, friend locations) flows from hooks in EventApp through view components (ListView/TableView/MapViewWrapper/MapView) to card/popup components. + +### Supabase Migrations +Migrations are in `supabase/migrations/`. Push with: +```bash +supabase db push --linked +``` +If remote has migrations not found locally, repair with: +```bash +supabase migration repair --status reverted +``` + +**Pending migrations:** +- `20260306120000_add_admin_config.sql` — admin_config table for admin settings +- `20260307120000_add_aggregation_rpcs.sql` — server-side aggregation RPCs + +### Known Issues +- `search_users` has two overloaded versions in Supabase — the old one `(text, uuid)` is unused and should be dropped +- `check_ins` RLS policies may block friends' check-ins — if so, create a `get_friends_check_ins()` SECURITY DEFINER RPC +- Check-in/reaction/comment data is fetched once on mount — no realtime subscriptions +- Eventbrite og:image URLs that were cached before the relative-URL fix may still be broken in `event_images` table — clear stale entries or wait for TTL +- PR #53 (tag count badges) has merge conflicts from architecture refactor — needs resolution +- The `lightningcss.darwin-arm64.node` module causes build issues locally — use `npx tsc --noEmit` for type checking instead of `npm run build` diff --git a/package-lock.json b/package-lock.json index 143b12b9..6e452ef7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "mapbox-gl": "^3.18.1", "next": "^16.1.7", "openai": "^6.34.0", - "qrcode.react": "^4.2.0", "react": "19.2.3", "react-dom": "19.2.3", "react-map-gl": "^8.1.0", @@ -7408,15 +7407,6 @@ "node": ">=6" } }, - "node_modules/qrcode.react": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", - "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index c3a41837..7d12e469 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "mapbox-gl": "^3.18.1", "next": "^16.1.7", "openai": "^6.34.0", - "qrcode.react": "^4.2.0", "react": "19.2.3", "react-dom": "19.2.3", "react-map-gl": "^8.1.0", diff --git a/plans/ad-placements.md b/plans/ad-placements.md new file mode 100644 index 00000000..ff7e9b29 --- /dev/null +++ b/plans/ad-placements.md @@ -0,0 +1,236 @@ +# Ad Placement Plan for plan.wtf + +## Top 5 Placements by ROI + +| Rank | Placement | Revenue | Disruption | Effort | +|------|-----------|---------|------------|--------| +| 1 | **Sponsored Event Cards (ListView)** | HIGH | LOW | LOW-MED | +| 2 | **Enhanced SponsorsTicker** | MEDIUM | VERY LOW | VERY LOW | +| 3 | **Featured Event Upsell (Submit Modal)** | HIGH | LOW | MEDIUM | +| 4 | **Sponsored Map Pins** | HIGH | LOW-MED | MEDIUM | +| 5 | **Itinerary Page Sponsor Banner** | MED-HIGH | LOW | LOW | + +--- + +## All Placement Opportunities + +### 1. Sponsored Event Cards in ListView (Native Ad) + +**Location:** `src/components/ListView.tsx`, injected between real `EventCard` components within each date group. Insert after every 5th-8th card inside the `{group.events.map(...)}` block (~line 113). + +**Format:** Native ad identical to `EventCard` layout (OG image, name, time, address, tags) with a subtle "Sponsored" badge. Highest-value placement — blends naturally with real content. + +**Size:** Same as EventCard (~120px height mobile, full content width ~768px). + +**UX Disruption:** LOW. Users are already scanning cards; a labeled "Sponsored" card is minimally disruptive. Limit to 1 per date group or 1 per 5-8 events. + +**Revenue Potential:** HIGH. Conference organizers and sponsors will pay premium rates for this exact audience. + +**Implementation:** Create `SponsoredEventCard` wrapping `EventCard` with "Sponsored" label. Data source: Supabase table or constants. Insert into ListView map loop with index-based gating. + +--- + +### 2. Enhanced SponsorsTicker (Existing Component) + +**Location:** `src/components/SponsorsTicker.tsx` — already rendered between Header and FilterBar in EventApp.tsx (line 379). + +**Currently:** Simple scrolling text ticker with one sponsor ("Stand With Crypto"). Hardcoded array. + +**Enhancement:** Expand to support multiple sponsors with logos, clickable CTAs, rotation. Support per-conference sponsors (different for SXSW vs ETHCC). + +**Size:** Current ~28px height, could expand to ~36px with small logos. + +**UX Disruption:** VERY LOW. Already exists and accepted by users. + +**Revenue Potential:** MEDIUM. Good for "presented by" brand awareness. + +**Implementation:** VERY LOW effort — just expand the sponsors array and optionally add image support. + +--- + +### 3. Featured Event Upsell (Submit Modal) + +**Location:** `src/components/SubmitEventModal.tsx`, on the success step (step 3, ~line 504). + +**Format:** After success confirmation: "Want to feature your event? Promote it to the top of listings for $X" with CTA. Could also be a checkbox in step 2: "Feature this event ($X)". + +**Size:** Small card ~80-100px within modal. + +**UX Disruption:** LOW. Only shown to event submitters (organizers), not browsers. + +**Revenue Potential:** HIGH. Direct revenue from organizers. Proven model (Eventbrite, Meetup). Even $10-50/event adds up with hundreds of events per conference. + +**Implementation:** MEDIUM. Requires payment flow (Stripe), "featured" flag in data model, rendering logic to boost featured events. + +--- + +### 4. Sponsored Map Pins + +**Location:** `src/components/MapView.tsx`, rendered as additional `Marker` components after the event markers block (~line 473). + +**Format:** Branded map pin with sponsor logo or distinct color + "AD" indicator. Clicking opens styled `EventPopup` with sponsor branding + CTA. + +**Size:** Same as regular markers (19px pin) with branded label. + +**UX Disruption:** LOW-MEDIUM. Users expect pins on maps. Must limit to 3-5 sponsored pins max to avoid clutter. + +**Revenue Potential:** HIGH. Location-based ads during conferences are extremely valuable for venue sponsors, afterparties, nearby businesses. + +**Implementation:** MEDIUM. New `SponsoredMapMarker` component, data source with lat/lng, popup integration. + +--- + +### 5. Itinerary Page Sponsor Banner + +**Location:** `src/app/itinerary/page.tsx` (~line 364) and shared itinerary page `src/app/itinerary/s/[code]/page.tsx` (~line 233). + +**Format:** "Your itinerary is powered by [Sponsor]" or "Conference shuttle sponsored by [Sponsor] — book a ride" with CTA. + +**Size:** Full content width, 60-80px tall. + +**UX Disruption:** LOW. Low-traffic page; single banner feels natural. + +**Revenue Potential:** MEDIUM-HIGH for shared itineraries — when users share links, recipients see the sponsor (viral distribution). + +**Implementation:** LOW. Simple banner component. + +--- + +### 6. Interstitial Between Date Groups in ListView + +**Location:** `src/components/ListView.tsx`, between `
` elements for date groups (~line 97). Insert after every 2nd-3rd date group. + +**Format:** Horizontal leaderboard-style banner with sponsor branding, message, CTA. Dark theme styled. + +**Size:** Full content width, 80-120px tall. + +**UX Disruption:** LOW-MEDIUM. Natural transition point between date groups. + +**Revenue Potential:** MEDIUM. + +**Implementation:** LOW. + +--- + +### 7. Sponsored Rows in TableView + +**Location:** `src/components/TableView.tsx`, within `DateGroup` component (~line 401). Insert sponsored row at top of each date section. + +**Format:** Table row styled like regular rows but with subtle background highlight (`bg-orange-500/5 border-l-2 border-orange-500`) and "Sponsored" tag. + +**Size:** Same as regular table row (~40px). + +**UX Disruption:** LOW. TableView users are power users who scan quickly. + +**Revenue Potential:** MEDIUM. + +**Implementation:** LOW. + +--- + +### 8. Event Popup Contextual Ad + +**Location:** `src/components/EventPopup.tsx`, after the note block (~line 206) in `SingleEventContent`. + +**Format:** Small contextual line: "Nearby: [Sponsor Venue]" or "Getting there? Book a ride with [Sponsor]". + +**Size:** Single line, ~20-24px tall. + +**UX Disruption:** LOW-MEDIUM. Popup is compact (300px); must be very minimal. + +**Revenue Potential:** MEDIUM. Contextual/location-aware ads command higher CPCs. + +**Implementation:** MEDIUM. Requires location-aware ad matching. + +--- + +### 9. Desktop Sidebar (Map View) + +**Location:** `src/components/EventApp.tsx`, alongside MapView (~line 411). Add 300px sidebar on `lg:` breakpoint. + +**Format:** Vertical tower (300x600) or stacked sponsor cards. "Featured Events" or sponsor info. + +**Size:** 300px wide, desktop only. + +**UX Disruption:** MEDIUM. Reduces map width on desktop. + +**Revenue Potential:** MEDIUM. + +**Implementation:** MEDIUM. + +--- + +### 10. API Docs Page Sponsorship + +**Location:** `src/app/api/page.tsx` hero section (~line 205) or footer (~line 1131). + +**Format:** "Powered by [Sponsor]" or "Build with [Sponsor's API]" banner. + +**UX Disruption:** VERY LOW. **Revenue Potential:** LOW. **Implementation:** VERY LOW. + +--- + +## Things to Avoid + +1. **Full-screen interstitials/modals** — utility app used actively during conferences on slow mobile. Blocking modals = abandonment. +2. **Ads in FilterBar or header** — primary navigation. Ads here feel like malware. +3. **Auto-playing video** — drains battery/data on mobile conference networks. +4. **Ads in AuthModal or profile** — trust-critical flows. Ads feel predatory. +5. **Excessive sponsored map pins** — more than 3-5 clutters the map (primary value prop). +6. **Ads in FriendsPanel/social features** — social features drive engagement; monetizing them kills stickiness. +7. **Pop-unders or redirects** — crypto audience is ad-averse and technically savvy. Deceptive patterns get called out on Twitter. + +--- + +## Monetization Models + +### Per-Conference Sponsorship Tiers + +| Tier | Price Range | Includes | +|------|-------------|----------| +| **Presenting** | $5,000-15,000 | Ticker (top billing), 5 sponsored cards, 3 map pins, itinerary banner, logo on shared itineraries | +| **Gold** | $2,000-5,000 | Ticker, 3 sponsored cards, 1 map pin | +| **Silver** | $500-2,000 | Ticker listing, 1 sponsored card | +| **Featured Event** | $25-100/event | Highlighted card, boosted position | + +### Self-Serve (Future) + +| Model | Placement | Pricing | +|-------|-----------|---------| +| CPC | Sponsored cards, map pins | $0.50-2.00/click | +| CPM | Banners, ticker | $5-15 CPM | +| Flat Rate | Featured event listing | $25-100/event/conference | +| Affiliate | Ride-share links in popups | Revenue share | + +**Recommended start:** Per-conference sponsorship tiers (manual sales). Simplest to implement, most lucrative for niche captive audience during conference windows. + +--- + +## Implementation Phases + +**Phase 1 — Immediate (0 effort):** +- Expand SponsorsTicker with more paying sponsors +- Add per-conference sponsor filtering + +**Phase 2 — Sprint 1 (1-2 days):** +- `SponsoredEventCard` component (EventCard wrapper + "Sponsored" badge) +- Sponsored event data source (Supabase table or constants) +- Inject into ListView +- GA4 tracking for impressions/clicks + +**Phase 3 — Sprint 2 (2-3 days):** +- "Feature this event" upsell in SubmitEventModal +- Itinerary page sponsor banner +- Sponsored row support in TableView + +**Phase 4 — Sprint 3 (3-5 days):** +- `SponsoredMapMarker` component +- Sponsored pins in MapView +- Contextual sponsor link in EventPopup +- Admin interface for managing sponsor data + +**Phase 5 — Future:** +- Self-serve ad submission portal +- Stripe integration for featured event payments +- Desktop sidebar ads +- Sponsor analytics dashboard (impressions, clicks, CTR) diff --git a/plans/submit-form-improvements.md b/plans/submit-form-improvements.md new file mode 100644 index 00000000..ec268c0e --- /dev/null +++ b/plans/submit-form-improvements.md @@ -0,0 +1,58 @@ +# Submit Event Form Improvements + +## 4 Changes + +### 1. Date/time fields → use DateTimePicker dropdowns +**Current**: Plain text inputs (`"Feb 16"`, `"7:00 PM"`) +**Target**: Reuse the `DateTimePicker` component from FilterBar (date dropdown + 30-min time dropdown) + +The submit form needs a slightly different setup than the filter: +- **Date**: Single date dropdown (not a datetime range), scoped to selected conference dates +- **Start Time**: Standalone time dropdown (30-min intervals) +- **End Time**: Standalone time dropdown (30-min intervals) + +We need to extract the `Dropdown` sub-component and `TIME_OPTIONS`/format helpers from `DateTimePicker.tsx` so the submit form can use date-only and time-only dropdowns independently. + +**Changes**: +- `src/components/DateTimePicker.tsx` — Export `Dropdown`, `TIME_OPTIONS`, `formatDateShort`, `format12Hour` +- `src/components/SubmitEventModal.tsx` — Replace text inputs with Dropdown components. Store date as ISO `"2026-03-10"` and times as 24h `"19:00"`. Convert to display format `"Mar 10"` / `"7:00 PM"` for the sheet on submit. + +### 2. Address → Google Places autocomplete (from rsvpizza) +**Current**: Plain text input +**Target**: Google Places autocomplete dropdown + +Create `src/components/AddressAutocomplete.tsx` adapted from rsvpizza's `LocationAutocomplete.tsx`: +- Simplified for sheeets (no timezone, no city data, no venue name) +- Uses `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` (Next.js convention) +- Falls back to plain text input if no API key +- Styled to match sheeets dark theme (slate-900 bg, slate-600 border, orange focus) +- Types: `['geocode', 'establishment']` + +**Also need**: `npm install -D @types/google.maps` for TypeScript support + +**Env var**: Add `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` to Vercel (can reuse same GCP project as rsvpizza). Component works as plain input until key is added. + +### 3. Auto-fetch on Luma URL paste +**Current**: User pastes URL, clicks "Fetch Event" button +**Target**: Auto-fetch when a valid Luma URL is pasted or typed + +In `SubmitEventModal.tsx`: +- Add a `useEffect` watching `lumaUrl` — when it matches a Luma URL pattern (`lu.ma/` or `luma.com/`), auto-trigger `handleFetchLuma()` after a short debounce (300ms) +- Keep the "Fetch Event" button as fallback +- Show loading state immediately on paste + +### 4. Luma API: fall back to `geo_address_info` for guests-only addresses +**Current**: Only checks `geo_address_json` (null for guests-only events) +**Target**: Fall back to `geo_address_info.city_state` or `geo_address_info.city` + +In `src/app/api/luma/route.ts`: +- After checking `geo_address_json`, also check `geo_address_info` +- Use `geo_address_info.full_address` → `geo_address_info.city_state` → `geo_address_info.city` as fallbacks +- This gives at least city-level location for guests-only events + +## Files to modify/create +1. `src/components/DateTimePicker.tsx` — Export internals +2. `src/components/AddressAutocomplete.tsx` — NEW (adapted from rsvpizza) +3. `src/components/SubmitEventModal.tsx` — All 4 changes converge here +4. `src/app/api/luma/route.ts` — geo_address_info fallback +5. `package.json` — Add `@types/google.maps` dev dependency diff --git a/plans/tomato-46571-theme-toggle.md b/plans/tomato-46571-theme-toggle.md new file mode 100644 index 00000000..8ed200b2 --- /dev/null +++ b/plans/tomato-46571-theme-toggle.md @@ -0,0 +1,935 @@ +# tomato-46571: Light/Dark Theme Toggle + +**Task ID**: tomato-46571 +**Priority**: P2 +**Feature**: Full light/dark theme toggle with Google-Sheets-inspired light palette + +--- + +## Overview + +The Sheeets app is currently dark-mode-only with hardcoded dark Tailwind classes everywhere. This plan adds: +1. A CSS-variable-driven dual-theme system (light default, dark via `.dark` class on ``) +2. A `ThemeProvider` context with system-preference detection and localStorage persistence +3. A sun/moon toggle button in the Header +4. Component-by-component class updates for every file in the app +5. Mapbox style switching (light vs dark map tiles) +6. Proper contrast adjustments for tag colors on light backgrounds + +--- + +## 1. Tailwind v4 Dark Mode Configuration + +Sheeets uses **Tailwind CSS v4** (no `tailwind.config.js` -- config is in `globals.css` via `@theme`). + +In Tailwind v4, `dark:` variant works by default with the CSS `prefers-color-scheme: dark` media query. To switch to **class-based** dark mode (so we control it via JS), add this to `globals.css`: + +```css +@custom-variant dark (&:where(.dark, .dark *)); +``` + +This tells Tailwind v4 that `dark:` variants should activate when the `.dark` class is on an ancestor element (i.e., ``). + +**File**: `src/app/globals.css` +**Action**: Add the `@custom-variant` directive after the `@import` lines. + +--- + +## 2. CSS Variables -- Complete Updated Block + +**File**: `src/app/globals.css` + +Replace the current `:root` variables and add `.dark` overrides. The light theme uses the Google Sheets palette; the dark theme preserves the current look. + +```css +@import 'mapbox-gl/dist/mapbox-gl.css'; +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* Safe area insets for notched devices */ +:root { + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); +} + +/* ====== LIGHT THEME (default) ====== */ +:root { + --background: #FFFFFF; + --foreground: #1D1D1D; + --card: #F0F4F8; + --card-hover: #E2E8F0; + --border: #C8D0D8; + --border-light: #E2E8F0; + --header-bg: #1F3864; + --header-text: #FFFFFF; + --surface: #FFFFFF; + --surface-secondary: #F7F9FB; + --text-primary: #1D1D1D; + --text-secondary: #4A5568; + --text-muted: #718096; + --text-faint: #A0AEC0; + --accent: #f97316; + --accent-hover: #fb923c; + --input-bg: #FFFFFF; + --input-border: #C8D0D8; + --input-focus-border: #f97316; + --overlay-bg: rgba(0, 0, 0, 0.3); + --popup-bg: #FFFFFF; + --popup-border: #C8D0D8; + --popup-shadow: rgba(0, 0, 0, 0.15); + --scrollbar-track: #F0F4F8; + --scrollbar-thumb: #C8D0D8; + --scrollbar-thumb-hover: #A0AEC0; + --table-row-alt: #F0F6FF; + --table-header-bg: #1F3864; + --table-header-text: #FFFFFF; + --slider-track: #C8D0D8; + --map-label-bg: rgba(255, 255, 255, 0.9); + --map-label-text: #1D1D1D; + --badge-inactive-bg: #E2E8F0; + --badge-inactive-text: #4A5568; +} + +/* ====== DARK THEME ====== */ +.dark { + --background: #0f172a; + --foreground: #f1f5f9; + --card: #1e293b; + --card-hover: #334155; + --border: #334155; + --border-light: #1e293b; + --header-bg: rgba(15, 23, 42, 0.95); + --header-text: #FFFFFF; + --surface: #0f172a; + --surface-secondary: #1e293b; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-faint: #475569; + --accent: #f97316; + --accent-hover: #fb923c; + --input-bg: #1e293b; + --input-border: #475569; + --input-focus-border: #f97316; + --overlay-bg: rgba(0, 0, 0, 0.5); + --popup-bg: #1e293b; + --popup-border: #334155; + --popup-shadow: rgba(0, 0, 0, 0.5); + --scrollbar-track: #0f172a; + --scrollbar-thumb: #334155; + --scrollbar-thumb-hover: #475569; + --table-row-alt: transparent; + --table-header-bg: #1e293b; + --table-header-text: #94a3b8; + --slider-track: #334155; + --map-label-bg: rgba(30, 41, 59, 0.9); + --map-label-text: #FFFFFF; + --badge-inactive-bg: #334155; + --badge-inactive-text: #cbd5e1; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-inter); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-inter), system-ui, -apple-system, sans-serif; + padding-top: var(--safe-area-top); + padding-bottom: var(--safe-area-bottom); +} + +/* Prevent iOS auto-zoom on input focus */ +input, select, textarea { + font-size: 16px; +} + +/* Custom scrollbar -- theme-aware */ +::-webkit-scrollbar { + width: 8px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* Card hover custom shade */ +.hover\:bg-slate-750:hover { + background-color: #283548; +} + +/* Mapbox popup bounds */ +.mapboxgl-popup { + max-width: calc(100vw - 1rem) !important; +} + +/* Mapbox popup overrides -- theme-aware */ +.map-popup .mapboxgl-popup-content { + background-color: var(--popup-bg); + border: 1px solid var(--popup-border); + border-radius: 8px; + padding: 12px; + box-shadow: 0 4px 24px var(--popup-shadow); + max-width: calc(100vw - 2rem); +} +.map-popup .mapboxgl-popup-tip { + border-top-color: var(--popup-bg); +} +.map-popup .mapboxgl-popup-close-btn { + display: none; +} + +/* Scrollbar styling for multi-event popup */ +.map-popup .mapboxgl-popup-content ::-webkit-scrollbar { + width: 4px; +} +.map-popup .mapboxgl-popup-content ::-webkit-scrollbar-track { + background: transparent; +} +.map-popup .mapboxgl-popup-content ::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + border-radius: 2px; +} + +/* Hide scrollbar for filter rows */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} +``` + +--- + +## 3. Theme Provider + +**New file**: `src/contexts/ThemeContext.tsx` + +```tsx +'use client'; + +import { createContext, useContext, useEffect, useState, useCallback } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(undefined); + +const STORAGE_KEY = 'sheeets-theme'; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setThemeState] = useState('dark'); // SSR-safe default + const [mounted, setMounted] = useState(false); + + // On mount: read localStorage or system preference + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === 'light' || stored === 'dark') { + setThemeState(stored); + } else { + // Detect system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + setThemeState(prefersDark ? 'dark' : 'light'); + } + setMounted(true); + }, []); + + // Apply class to whenever theme changes + useEffect(() => { + if (!mounted) return; + const root = document.documentElement; + if (theme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + localStorage.setItem(STORAGE_KEY, theme); + }, [theme, mounted]); + + // Listen for system preference changes (only when no explicit user choice) + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => { + if (!localStorage.getItem(STORAGE_KEY)) { + setThemeState(e.matches ? 'dark' : 'light'); + } + }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + const setTheme = useCallback((t: Theme) => { + setThemeState(t); + }, []); + + const toggleTheme = useCallback(() => { + setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + return ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} +``` + +### Flash prevention script + +To prevent the flash of wrong theme on page load (FOUC), add an inline script to `` that reads localStorage and applies the `.dark` class **before** React hydrates. + +**File**: `src/app/layout.tsx` + +Update the `` tag and add a ` - - - -

plan.wtf Analytics Dashboard

-

Generated ${escapeHtml(now)} — Last 30 days

- - -
-
-
${totals ? fmt(totals.sessions) : '--'}
-
Total Sessions
-
-
-
${totals ? fmt(totals.users) : '--'}
-
Total Users
-
-
-
${totals ? fmt(totals.newUsers) : '--'}
-
New Users
-
-
-
${totals ? fmtPct(totals.bounceRate) : '--'}
-
Bounce Rate
-
-
- - -
-
-

Sessions Over Time

-
-
-
-

Traffic Sources (Top 10)

-
-
-
- - -
- ${onboardingFunnelHtml} - ${authFunnelHtml} - ${engagementFunnelHtml} -
- - -
-
-

Top Events

- ${ - eventsTableRows - ? `
- - - ${eventsTableRows} -
Event NameCountUsers
-
` - : '

No data

' - } -
-
-

Top Pages

- ${ - pagesTableRows - ? `
- - - ${pagesTableRows} -
Page PathViews
-
` - : '

No data

' - } -
-
- - -
-
-

Conversions

-
-
-
-

Device Split

-
-
-
- - -
-
-

Audiences

- ${ - audiencesTableRows - ? ` - - ${audiencesTableRows} -
NameDescriptionMembership Duration
` - : '

No custom audiences found

' - } -
-
- - - -`; -} - -// ---------- Main ---------- - -async function main() { - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - console.log('Fetching GA4 data (all requests in parallel)...'); - - // Fetch all data in parallel with error resilience - const [ - totals, - traffic, - events, - onboardingFunnel, - authFunnel, - engagementFunnel, - conversions, - timeSeries, - pages, - devices, - audiences, - ] = await Promise.all([ - fetchTotals(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch totals:', e.message); - return null; - }), - fetchTraffic(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch traffic:', e.message); - return []; - }), - fetchEvents(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch events:', e.message); - return []; - }), - fetchFunnelData(accessToken, [ - 'session_start', - 'onboarding_start', - 'onboarding_step', - 'onboarding_complete', - ]).catch((e) => { - console.warn(' Warning: failed to fetch onboarding funnel:', e.message); - return []; - }), - fetchFunnelData(accessToken, ['auth_prompt', 'auth_success']).catch((e) => { - console.warn(' Warning: failed to fetch auth funnel:', e.message); - return []; - }), - fetchFunnelData(accessToken, [ - 'session_start', - 'event_click', - 'itinerary', - 'check_in', - ]).catch((e) => { - console.warn(' Warning: failed to fetch engagement funnel:', e.message); - return []; - }), - fetchConversions(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch conversions:', e.message); - return []; - }), - fetchTimeSeries(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch time series:', e.message); - return []; - }), - fetchPages(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch pages:', e.message); - return []; - }), - fetchDevices(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch devices:', e.message); - return []; - }), - fetchAudiences(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch audiences:', e.message); - return []; - }), - ]); - - console.log('Data fetched successfully.\n'); - - console.log('Generating HTML dashboard...'); - const html = buildHtml({ - totals, - traffic, - events, - onboardingFunnel, - authFunnel, - engagementFunnel, - conversions, - timeSeries, - pages, - devices, - audiences, - }); - - const outPath = join(process.cwd(), 'ga4-dashboard.html'); - writeFileSync(outPath, html); - console.log(`Dashboard written to ${outPath}`); - - // Open in browser - try { - const platform = process.platform; - if (platform === 'win32') { - execSync(`start "" "${outPath}"`, { shell: true }); - } else if (platform === 'darwin') { - execSync(`open "${outPath}"`); - } else { - execSync(`xdg-open "${outPath}"`); - } - console.log('Opened in browser.'); - } catch { - console.log('Could not open browser automatically. Open the file manually.'); - } -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/scripts/ga4-looker-setup.md b/scripts/ga4-looker-setup.md deleted file mode 100644 index e7bba145..00000000 --- a/scripts/ga4-looker-setup.md +++ /dev/null @@ -1,50 +0,0 @@ -# Looker Studio Dashboard Setup for plan.wtf - -## Quick Start - -1. Open [Looker Studio](https://lookerstudio.google.com/) -2. Click **Create → Report** -3. Add data source: **Google Analytics** → select **Sheeets.xyz** account → **property 524531628** -4. Click **Add to Report** - -## Recommended Dashboard Pages - -### Page 1: Traffic Overview -- **Scorecard**: Sessions, Users, New Users, Bounce Rate (date range: last 30 days) -- **Time series chart**: Sessions over time (daily) -- **Table**: Sessions by Source/Medium (top 10) -- **Pie chart**: Sessions by Device Category -- **Bar chart**: Top 10 Landing Pages (dimension: pagePath) - -### Page 2: Conference Performance -- **Scorecard**: Total Page Views -- **Table**: Page views by `pagePath` filtered to `/{conference-slug}` pages -- **Bar chart**: Conference select events by `conference` custom dimension -- **Time series**: Conference page views over time - -### Page 3: User Engagement -- **Funnel chart**: session_start → onboarding_start → onboarding_complete → itinerary → check_in -- **Scorecard**: Onboarding completion rate (onboarding_complete / onboarding_start) -- **Scorecard**: Auth conversion rate (auth_success / auth_prompt) -- **Table**: Top events by eventCount -- **Bar chart**: Tag toggles by `tag` custom dimension - -### Page 4: Conversions & Monetization -- **Table**: Key conversion events (auth_success, itinerary, check_in, submit_event_success, ad_click, rsvp_confirm) -- **Time series**: Conversion events over time -- **Scorecard**: Total ad clicks -- **Table**: Ad clicks by `placement` custom dimension - -### Page 5: Audiences -- **Scorecard**: Power Users count -- **Scorecard**: Onboarding Dropoffs count -- **Scorecard**: Engaged Unauthenticated count -- **Table**: User properties breakdown (conference_slug, is_authenticated) - -## Custom Dimensions Available -All 19 custom dimensions registered in GA4 are available as dimensions in Looker Studio: -- view_mode, conference, tag, action, search_term, event_name, trigger, placement, step, modal, provider, visibility, category, emoji, test_id, variant_id -- User-scoped: conference_slug, has_itinerary, is_authenticated - -## Filters -Add a **date range control** and **conference dropdown** (using `conference` dimension) to every page. diff --git a/scripts/ga4-metrics.mjs b/scripts/ga4-metrics.mjs deleted file mode 100644 index b91a81a3..00000000 --- a/scripts/ga4-metrics.mjs +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node - -/** - * GA4 Custom Metrics Script - * - * Registers custom metrics in GA4 via the Admin API. - * Uses OAuth2 refresh token stored at ~/.claude/ga4-tokens.json. - * - * Usage: node scripts/ga4-metrics.mjs - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const PROPERTY_ID = '524531628'; -const TOKEN_PATH = join(homedir(), '.claude', 'ga4-tokens.json'); - -const ADMIN_BASE = `https://analyticsadmin.googleapis.com/v1beta/properties/${PROPERTY_ID}`; - -// ---------- Custom metrics to register ---------- - -const CUSTOM_METRICS = [ - { - parameterName: 'itinerary_count', - displayName: 'Itinerary Count', - measurementUnit: 'STANDARD', - scope: 'EVENT', - }, - { - parameterName: 'tag_count', - displayName: 'Tag Count', - measurementUnit: 'STANDARD', - scope: 'EVENT', - }, - { - parameterName: 'search_count', - displayName: 'Search Count', - measurementUnit: 'STANDARD', - scope: 'EVENT', - }, -]; - -// ---------- Token refresh ---------- - -async function refreshAccessToken() { - const tokens = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')); - - const clientId = process.env.GA4_CLIENT_ID || tokens.client_id; - const clientSecret = process.env.GA4_CLIENT_SECRET || tokens.client_secret; - - if (!clientId || !clientSecret) { - throw new Error( - 'Missing client credentials. Set GA4_CLIENT_ID/GA4_CLIENT_SECRET env vars ' + - 'or add client_id/client_secret to ' + TOKEN_PATH - ); - } - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: tokens.refresh_token, - grant_type: 'refresh_token', - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${err}`); - } - - const data = await res.json(); - const updated = { ...tokens, ...data }; - writeFileSync(TOKEN_PATH, JSON.stringify(updated, null, 2)); - return data.access_token; -} - -// ---------- API helper ---------- - -async function createCustomMetric(accessToken, metric) { - const res = await fetch(`${ADMIN_BASE}/customMetrics`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(metric), - }); - - if (res.status === 409) { - console.log(` [skip] ${metric.parameterName} (already exists)`); - return; - } - - if (!res.ok) { - const err = await res.text(); - console.error(` [error] ${metric.parameterName}: ${res.status} ${err}`); - return; - } - - const result = await res.json(); - console.log(` [created] ${metric.parameterName} (${result.name})`); -} - -// ---------- Main ---------- - -async function main() { - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - console.log(`Registering ${CUSTOM_METRICS.length} custom metrics...\n`); - for (const metric of CUSTOM_METRICS) { - await createCustomMetric(accessToken, metric); - } - - console.log('\nDone.'); -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/scripts/ga4-report.mjs b/scripts/ga4-report.mjs deleted file mode 100644 index fb418128..00000000 --- a/scripts/ga4-report.mjs +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env node - -/** - * GA4 Report CLI - * - * Query GA4 data via the Data API and Admin API. - * - * Usage: node scripts/ga4-report.mjs [traffic|events|funnels|funnel-detail|conversions|dimensions] - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const PROPERTY_ID = '524531628'; -const TOKEN_PATH = join(homedir(), '.claude', 'ga4-tokens.json'); - -// Client credentials are read from the tokens file (client_id, client_secret fields) -// or from GA4_CLIENT_ID / GA4_CLIENT_SECRET env vars. -// To keep secrets out of version control, add to ~/.claude/ga4-tokens.json: -// "client_id": "", -// "client_secret": "" - -const DATA_BASE = `https://analyticsdata.googleapis.com/v1beta/properties/${PROPERTY_ID}`; -const ADMIN_BASE = `https://analyticsadmin.googleapis.com/v1beta/properties/${PROPERTY_ID}`; - -// ---------- Token refresh ---------- - -async function refreshAccessToken() { - const tokens = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')); - - const clientId = process.env.GA4_CLIENT_ID || tokens.client_id; - const clientSecret = process.env.GA4_CLIENT_SECRET || tokens.client_secret; - - if (!clientId || !clientSecret) { - throw new Error( - 'Missing client credentials. Set GA4_CLIENT_ID/GA4_CLIENT_SECRET env vars ' + - 'or add client_id/client_secret to ' + TOKEN_PATH - ); - } - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: tokens.refresh_token, - grant_type: 'refresh_token', - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${err}`); - } - - const data = await res.json(); - const updated = { ...tokens, ...data }; - writeFileSync(TOKEN_PATH, JSON.stringify(updated, null, 2)); - return data.access_token; -} - -// ---------- API helpers ---------- - -async function runReport(accessToken, body) { - const res = await fetch(`${DATA_BASE}:runReport`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`runReport failed: ${res.status} ${err}`); - } - - return res.json(); -} - -async function adminGet(accessToken, path) { - const res = await fetch(`${ADMIN_BASE}/${path}`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Admin API failed: ${res.status} ${err}`); - } - - return res.json(); -} - -// ---------- Formatters ---------- - -function printTable(headers, rows) { - const widths = headers.map((h, i) => - Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)) - ); - - const sep = widths.map((w) => '-'.repeat(w + 2)).join('+'); - const formatRow = (row) => - row.map((cell, i) => ` ${String(cell || '').padEnd(widths[i])} `).join('|'); - - console.log(formatRow(headers)); - console.log(sep); - rows.forEach((row) => console.log(formatRow(row))); - console.log(`\n${rows.length} rows\n`); -} - -function extractRows(report) { - if (!report.rows) return []; - return report.rows.map((row) => [ - ...row.dimensionValues.map((d) => d.value), - ...row.metricValues.map((m) => m.value), - ]); -} - -// ---------- Report types ---------- - -async function reportTraffic(accessToken) { - console.log('Traffic by Source/Medium (last 30 days)\n'); - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'sessionSourceMedium' }], - metrics: [{ name: 'sessions' }, { name: 'activeUsers' }, { name: 'bounceRate' }], - orderBys: [{ metric: { metricName: 'sessions' }, desc: true }], - limit: 20, - }); - - const rows = extractRows(report); - printTable(['Source / Medium', 'Sessions', 'Users', 'Bounce Rate'], rows); -} - -async function reportEvents(accessToken) { - console.log('Event Counts (last 30 days)\n'); - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }], - limit: 50, - }); - - const rows = extractRows(report); - printTable(['Event Name', 'Count', 'Users'], rows); -} - -async function reportFunnels(accessToken) { - console.log('Key Funnel Metrics (last 30 days)\n'); - - const funnelEvents = [ - 'session_start', - 'onboarding_start', - 'onboarding_complete', - 'auth_prompt', - 'auth_success', - 'itinerary', - 'check_in', - 'submit_event_open', - 'submit_event_success', - ]; - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: funnelEvents }, - }, - }, - orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }], - }); - - const rows = extractRows(report); - - // Sort by funnel order - const orderMap = Object.fromEntries(funnelEvents.map((e, i) => [e, i])); - rows.sort((a, b) => (orderMap[a[0]] ?? 99) - (orderMap[b[0]] ?? 99)); - - printTable(['Funnel Step', 'Count', 'Users'], rows); -} - -async function reportConversions(accessToken) { - console.log('Key Conversion Events (last 30 days)\n'); - - const conversionEvents = [ - 'auth_success', - 'itinerary', - 'check_in', - 'submit_event_success', - 'onboarding_complete', - 'ad_click', - 'rsvp_confirm', - ]; - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: conversionEvents }, - }, - }, - orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }], - }); - - const rows = extractRows(report); - printTable(['Conversion Event', 'Count', 'Users'], rows); -} - -async function reportDimensions(accessToken) { - console.log('Registered Custom Dimensions\n'); - - const data = await adminGet(accessToken, 'customDimensions'); - const dims = data.customDimensions || []; - - const rows = dims.map((d) => [ - d.parameterName, - d.displayName, - d.scope, - d.description || '', - ]); - - // Sort by scope then name - rows.sort((a, b) => a[2].localeCompare(b[2]) || a[0].localeCompare(b[0])); - - printTable(['Parameter', 'Display Name', 'Scope', 'Description'], rows); -} - -async function reportFunnelDetail(accessToken) { - console.log('Funnel Analysis (last 30 days)\n'); - - // Onboarding funnel - const onboardingEvents = ['session_start', 'onboarding_start', 'onboarding_step', 'onboarding_complete']; - const onboardingReport = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: onboardingEvents }, - }, - }, - }); - - console.log('=== Onboarding Funnel ==='); - const onboardingRows = extractRows(onboardingReport); - const orderMap1 = Object.fromEntries(onboardingEvents.map((e, i) => [e, i])); - onboardingRows.sort((a, b) => (orderMap1[a[0]] ?? 99) - (orderMap1[b[0]] ?? 99)); - - // Calculate drop-off rates - const onboardingWithRates = onboardingRows.map((row, i) => { - const prevUsers = i > 0 ? Number(onboardingRows[i - 1][2]) : Number(row[2]); - const currentUsers = Number(row[2]); - const rate = i === 0 ? '100%' : `${((currentUsers / prevUsers) * 100).toFixed(1)}%`; - const dropOff = i === 0 ? '-' : `${((1 - currentUsers / prevUsers) * 100).toFixed(1)}%`; - return [...row, rate, dropOff]; - }); - printTable(['Step', 'Count', 'Users', 'Conv Rate', 'Drop-off'], onboardingWithRates); - - // Auth funnel - const authEvents = ['auth_prompt', 'auth_success']; - const authReport = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: authEvents }, - }, - }, - }); - - console.log('=== Auth Funnel ==='); - const authRows = extractRows(authReport); - const orderMap2 = Object.fromEntries(authEvents.map((e, i) => [e, i])); - authRows.sort((a, b) => (orderMap2[a[0]] ?? 99) - (orderMap2[b[0]] ?? 99)); - - const authWithRates = authRows.map((row, i) => { - const prevUsers = i > 0 ? Number(authRows[i - 1][2]) : Number(row[2]); - const currentUsers = Number(row[2]); - const rate = i === 0 ? '100%' : `${((currentUsers / prevUsers) * 100).toFixed(1)}%`; - const dropOff = i === 0 ? '-' : `${((1 - currentUsers / prevUsers) * 100).toFixed(1)}%`; - return [...row, rate, dropOff]; - }); - printTable(['Step', 'Count', 'Users', 'Conv Rate', 'Drop-off'], authWithRates); - - // Engagement funnel - const engagementEvents = ['session_start', 'event_click', 'itinerary', 'check_in']; - const engReport = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: engagementEvents }, - }, - }, - }); - - console.log('=== Engagement Funnel ==='); - const engRows = extractRows(engReport); - const orderMap3 = Object.fromEntries(engagementEvents.map((e, i) => [e, i])); - engRows.sort((a, b) => (orderMap3[a[0]] ?? 99) - (orderMap3[b[0]] ?? 99)); - - const engWithRates = engRows.map((row, i) => { - const prevUsers = i > 0 ? Number(engRows[i - 1][2]) : Number(row[2]); - const currentUsers = Number(row[2]); - const rate = i === 0 ? '100%' : `${((currentUsers / prevUsers) * 100).toFixed(1)}%`; - const dropOff = i === 0 ? '-' : `${((1 - currentUsers / prevUsers) * 100).toFixed(1)}%`; - return [...row, rate, dropOff]; - }); - printTable(['Step', 'Count', 'Users', 'Conv Rate', 'Drop-off'], engWithRates); -} - -// ---------- Main ---------- - -const REPORTS = { - traffic: reportTraffic, - events: reportEvents, - funnels: reportFunnels, - 'funnel-detail': reportFunnelDetail, - conversions: reportConversions, - dimensions: reportDimensions, -}; - -async function main() { - const reportType = process.argv[2]; - - if (!reportType || !REPORTS[reportType]) { - console.log('Usage: node scripts/ga4-report.mjs [traffic|events|funnels|funnel-detail|conversions|dimensions]'); - console.log('\nReport types:'); - console.log(' traffic Sessions by source/medium (last 30 days)'); - console.log(' events Event counts (last 30 days)'); - console.log(' funnels Key funnel metrics (onboarding, auth)'); - console.log(' funnel-detail Detailed funnel analysis with conversion/drop-off rates'); - console.log(' conversions Key conversion events'); - console.log(' dimensions List registered custom dimensions'); - process.exit(1); - } - - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - await REPORTS[reportType](accessToken); -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/scripts/ga4-setup.mjs b/scripts/ga4-setup.mjs deleted file mode 100644 index 67b18eb0..00000000 --- a/scripts/ga4-setup.mjs +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node - -/** - * GA4 Setup Script - * - * Registers custom dimensions and key events (conversions) in GA4 via the Admin API. - * Uses OAuth2 refresh token stored at ~/.claude/ga4-tokens.json. - * - * Usage: node scripts/ga4-setup.mjs - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const PROPERTY_ID = '524531628'; -const TOKEN_PATH = join(homedir(), '.claude', 'ga4-tokens.json'); - -// Client credentials are read from the tokens file (client_id, client_secret fields) -// or from GA4_CLIENT_ID / GA4_CLIENT_SECRET env vars. -// To keep secrets out of version control, add to ~/.claude/ga4-tokens.json: -// "client_id": "", -// "client_secret": "" - -const ADMIN_BASE = `https://analyticsadmin.googleapis.com/v1beta/properties/${PROPERTY_ID}`; - -// ---------- Custom dimensions to register ---------- - -const EVENT_DIMENSIONS = [ - { parameterName: 'view_mode', displayName: 'View Mode', description: 'Map/List/Table/Gallery' }, - { parameterName: 'conference', displayName: 'Conference', description: 'Conference slug' }, - { parameterName: 'tag', displayName: 'Tag', description: 'Filter tag name' }, - { parameterName: 'action', displayName: 'Action', description: 'Add/remove action' }, - { parameterName: 'search_term', displayName: 'Search Term', description: 'Search query' }, - { parameterName: 'event_name', displayName: 'Tracked Event Name', description: 'Name of event clicked' }, - { parameterName: 'trigger', displayName: 'Auth Trigger', description: 'What triggered auth prompt' }, - { parameterName: 'placement', displayName: 'Ad Placement', description: 'Ad placement location' }, - { parameterName: 'step', displayName: 'Onboarding Step', description: 'Which onboarding step' }, - { parameterName: 'modal', displayName: 'Modal Name', description: 'Which modal was dismissed' }, - { parameterName: 'provider', displayName: 'Nav Provider', description: 'Google Maps/Uber/Lyft' }, - { parameterName: 'visibility', displayName: 'Comment Visibility', description: 'Public/friends' }, - { parameterName: 'category', displayName: 'POI Category', description: 'POI category' }, - { parameterName: 'emoji', displayName: 'Reaction Emoji', description: 'Which emoji used' }, - { parameterName: 'test_id', displayName: 'AB Test ID', description: 'A/B test identifier' }, - { parameterName: 'variant_id', displayName: 'AB Variant', description: 'A/B test variant' }, -]; - -const USER_DIMENSIONS = [ - { parameterName: 'conference_slug', displayName: 'User Conference', description: 'User selected conference' }, - { parameterName: 'has_itinerary', displayName: 'Has Itinerary', description: 'Whether user has itinerary items' }, - { parameterName: 'is_authenticated', displayName: 'Is Authenticated', description: 'Whether user is logged in' }, -]; - -const KEY_EVENTS = [ - 'auth_success', - 'itinerary', - 'check_in', - 'submit_event_success', - 'onboarding_complete', - 'ad_click', - 'rsvp_confirm', -]; - -// ---------- Token refresh ---------- - -async function refreshAccessToken() { - const tokens = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')); - - const clientId = process.env.GA4_CLIENT_ID || tokens.client_id; - const clientSecret = process.env.GA4_CLIENT_SECRET || tokens.client_secret; - - if (!clientId || !clientSecret) { - throw new Error( - 'Missing client credentials. Set GA4_CLIENT_ID/GA4_CLIENT_SECRET env vars ' + - 'or add client_id/client_secret to ' + TOKEN_PATH - ); - } - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: tokens.refresh_token, - grant_type: 'refresh_token', - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${err}`); - } - - const data = await res.json(); - // Persist the new access token (keep existing refresh_token) - const updated = { ...tokens, ...data }; - writeFileSync(TOKEN_PATH, JSON.stringify(updated, null, 2)); - return data.access_token; -} - -// ---------- API helpers ---------- - -async function createCustomDimension(accessToken, dimension, scope) { - const body = { - parameterName: dimension.parameterName, - displayName: dimension.displayName, - description: dimension.description || '', - scope: scope, // EVENT or USER - }; - - const res = await fetch(`${ADMIN_BASE}/customDimensions`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (res.status === 409) { - console.log(` [skip] ${dimension.parameterName} (already exists)`); - return; - } - - if (!res.ok) { - const err = await res.text(); - console.error(` [error] ${dimension.parameterName}: ${res.status} ${err}`); - return; - } - - console.log(` [created] ${dimension.parameterName}`); -} - -async function createKeyEvent(accessToken, eventName) { - const body = { eventName }; - - const res = await fetch(`${ADMIN_BASE}/keyEvents`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (res.status === 409) { - console.log(` [skip] ${eventName} (already exists)`); - return; - } - - if (!res.ok) { - const err = await res.text(); - console.error(` [error] ${eventName}: ${res.status} ${err}`); - return; - } - - console.log(` [created] ${eventName}`); -} - -// ---------- Main ---------- - -async function main() { - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - console.log(`Registering ${EVENT_DIMENSIONS.length} event-scoped custom dimensions...`); - for (const dim of EVENT_DIMENSIONS) { - await createCustomDimension(accessToken, dim, 'EVENT'); - } - - console.log(`\nRegistering ${USER_DIMENSIONS.length} user-scoped custom dimensions...`); - for (const dim of USER_DIMENSIONS) { - await createCustomDimension(accessToken, dim, 'USER'); - } - - console.log(`\nRegistering ${KEY_EVENTS.length} key events (conversions)...`); - for (const eventName of KEY_EVENTS) { - await createKeyEvent(accessToken, eventName); - } - - console.log('\nDone.'); -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b286555c..ccb92cda 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -11,14 +11,12 @@ import type { AdminConfig, SponsorEntry, NativeAd, UpsellCopy, AdInventoryItem, import { isConferencePast, conferenceToTab } from '@/lib/conferences'; import type { TabConfig } from '@/lib/conferences'; import SponsorDataTab from '@/components/admin/SponsorDataTab'; -import SubmissionsTab from '@/components/admin/SubmissionsTab'; const SESSION_KEY = 'sheeets-admin-auth'; -type AdminTab = 'submissions' | 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics' | 'sponsorData'; +type AdminTab = 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics' | 'sponsorData'; const TAB_LABELS: { key: AdminTab; label: string }[] = [ - { key: 'submissions', label: 'Submissions' }, { key: 'featured', label: 'Featured' }, { key: 'conferences', label: 'Conferences' }, { key: 'sponsors', label: 'Sponsors' }, @@ -108,7 +106,7 @@ export default function AdminPage() { const [search, setSearch] = useState(''); const [togglingId, setTogglingId] = useState(null); - const [activeTab, setActiveTab] = useState('submissions'); + const [activeTab, setActiveTab] = useState('featured'); const [adminConfig, setAdminConfig] = useState(null); const [configLoading, setConfigLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -663,11 +661,6 @@ export default function AdminPage() { {/* Content */}
- {/* Tab: Submissions */} - {activeTab === 'submissions' && ( - - )} - {/* Tab 1: Featured */} {activeTab === 'featured' && ( <> diff --git a/src/app/api/admin/submissions/route.ts b/src/app/api/admin/submissions/route.ts deleted file mode 100644 index a37834a3..00000000 --- a/src/app/api/admin/submissions/route.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createClient } from '@supabase/supabase-js'; -import { parseBody, SubmissionActionSchema } from '@/lib/api-validation'; -import { insertEventRowSorted, getSheetTitle, findReviewRow, deleteSheetRow, writeCell } from '@/lib/google-sheets'; -import { FALLBACK_TABS } from '@/lib/conferences'; -import { getConferenceTabs } from '@/lib/get-conferences'; -import { normalizeAddress } from '@/lib/utils'; - -const ADMIN_PASSWORD = 'trusttheplan'; - -function getSupabase() { - return createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); -} - -export async function GET(req: NextRequest) { - const password = req.nextUrl.searchParams.get('password'); - if (password !== ADMIN_PASSWORD) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const status = req.nextUrl.searchParams.get('status') || 'pending'; - const conference = req.nextUrl.searchParams.get('conference') || ''; - - const supabase = getSupabase(); - let query = supabase - .from('event_submissions') - .select('*') - .order('created_at', { ascending: false }); - - if (status !== 'all') { - query = query.eq('status', status); - } - if (conference) { - query = query.eq('conference', conference); - } - - const { data, error } = await query; - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - return NextResponse.json({ submissions: data }); -} - -export async function POST(req: NextRequest) { - const { data, error: parseError } = await parseBody(req, SubmissionActionSchema); - if (parseError) return parseError; - - const { password, action, id, rejection_reason, edits } = data; - - if (password !== ADMIN_PASSWORD) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const supabase = getSupabase(); - - // Fetch the submission - const { data: submission, error: fetchError } = await supabase - .from('event_submissions') - .select('*') - .eq('id', id) - .eq('status', 'pending') - .single(); - - if (fetchError || !submission) { - return NextResponse.json( - { error: 'Submission not found or already reviewed' }, - { status: 404 } - ); - } - - // Resolve conference tab - let tabs = FALLBACK_TABS; - try { - tabs = await getConferenceTabs(); - } catch { /* use fallback */ } - - const tab = tabs.find((t: { name: string }) => t.name === submission.conference); - if (!tab) { - return NextResponse.json( - { error: `Conference tab not found: ${submission.conference}` }, - { status: 400 } - ); - } - - const sheetName = await getSheetTitle(tab.gid); - - if (action === 'reject') { - // 1. Find the review row by original name/date - const reviewRowIdx = await findReviewRow(sheetName, submission.event_name, submission.event_date); - - // 2. Mark column M as REJECTED (if row found) - if (reviewRowIdx !== null) { - try { - await writeCell(sheetName, `M${reviewRowIdx + 1}`, 'REJECTED'); - } catch (sheetErr) { - console.warn('Failed to mark sheet row as rejected:', sheetErr); - } - } else { - console.warn(`Review row not found for rejection: ${submission.event_name} / ${submission.event_date}`); - } - - // 3. Update Supabase - const { error: updateError } = await supabase - .from('event_submissions') - .update({ - status: 'rejected', - rejection_reason: rejection_reason || null, - reviewed_at: new Date().toISOString(), - }) - .eq('id', id) - .eq('status', 'pending'); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ success: true, action: 'rejected' }); - } - - // ---- Approve ---- - - // Merge admin edits over submission fields - const eventFields = { - event_name: edits?.event_name ?? submission.event_name, - event_date: edits?.event_date ?? submission.event_date, - start_time: edits?.start_time ?? submission.start_time, - end_time: edits?.end_time ?? submission.end_time, - organizer: edits?.organizer ?? submission.organizer, - address: edits?.address ?? submission.address, - cost: edits?.cost ?? submission.cost, - tags: edits?.tags ?? submission.tags, - link: edits?.link ?? submission.link, - has_food: edits?.has_food ?? submission.has_food, - has_bar: edits?.has_bar ?? submission.has_bar, - note: edits?.note ?? submission.note, - }; - - try { - // 1. Find the review row by ORIGINAL name/date (before edits) - const reviewRowIdx = await findReviewRow(sheetName, submission.event_name, submission.event_date); - - // 2. Insert into main section (this shifts all rows below down by 1) - const sheetRow = await insertEventRowSorted(sheetName, tab.gid, { - date: eventFields.event_date, - startTime: eventFields.start_time, - endTime: eventFields.end_time, - organizer: eventFields.organizer, - name: eventFields.event_name, - address: eventFields.address, - cost: eventFields.cost, - tags: eventFields.tags, - link: eventFields.link, - food: eventFields.has_food, - bar: eventFields.has_bar, - note: eventFields.note, - }); - - // 3. Delete from review section (+1 because insertEventRowSorted shifted rows down) - if (reviewRowIdx !== null) { - try { - await deleteSheetRow(tab.gid, reviewRowIdx + 1); - } catch (deleteErr) { - console.warn('Failed to delete review row:', deleteErr); - } - } else { - console.warn(`Review row not found for approval: ${submission.event_name} / ${submission.event_date}`); - } - - // 4. Upsert geocoded address if coords exist - if (submission.coords_lat && submission.coords_lng && eventFields.address) { - try { - await supabase.from('geocoded_addresses').upsert({ - normalized_address: normalizeAddress(eventFields.address), - lat: submission.coords_lat, - lng: submission.coords_lng, - matched_address: eventFields.address, - conference: submission.conference, - }); - } catch (geoErr) { - console.error('Failed to save geocoded address:', geoErr); - } - } - - // 5. Update Supabase status - const { error: updateError } = await supabase - .from('event_submissions') - .update({ - status: 'approved', - reviewed_at: new Date().toISOString(), - sheet_row: sheetRow, - }) - .eq('id', id) - .eq('status', 'pending'); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ success: true, action: 'approved', sheet_row: sheetRow }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return NextResponse.json( - { error: `Failed to write to sheet: ${message}` }, - { status: 500 } - ); - } -} diff --git a/src/app/api/image-proxy/route.ts b/src/app/api/image-proxy/route.ts new file mode 100644 index 00000000..0949b59b --- /dev/null +++ b/src/app/api/image-proxy/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get('url'); + if (!url) { + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); + } + + try { + new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid url' }, { status: 400 }); + } + + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(10000), + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; sheeets-bot/1.0)', + }, + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Upstream fetch failed' }, { status: 502 }); + } + + const contentType = res.headers.get('content-type') || 'image/jpeg'; + const buffer = await res.arrayBuffer(); + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400, s-maxage=86400', + }, + }); + } catch { + return NextResponse.json({ error: 'Proxy fetch failed' }, { status: 502 }); + } +} diff --git a/src/app/api/submit-event/route.ts b/src/app/api/submit-event/route.ts index 81c8efef..28888929 100644 --- a/src/app/api/submit-event/route.ts +++ b/src/app/api/submit-event/route.ts @@ -31,7 +31,6 @@ export async function POST(request: NextRequest) { const sheetName = await getSheetTitle(tab.gid); - // Write to sheet review section (primary — must succeed) const row = await appendEventRow(sheetName, { date: event.date.trim(), startTime: event.startTime.trim(), @@ -47,35 +46,6 @@ export async function POST(request: NextRequest) { note: event.note.trim(), }); - // Track in Supabase (non-fatal — wrapped in try/catch) - try { - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); - await supabase.from('event_submissions').insert({ - conference, - status: 'pending', - event_name: event.name.trim(), - event_date: event.date.trim(), - start_time: event.startTime.trim(), - end_time: event.endTime.trim(), - organizer: event.organizer.trim(), - address: event.address.trim(), - cost: event.cost.trim(), - tags: event.tags.trim(), - link: event.link.trim(), - has_food: event.food, - has_bar: event.bar, - note: event.note.trim(), - coords_lat: coords?.lat ?? null, - coords_lng: coords?.lng ?? null, - sheet_row: row, - }); - } catch (trackErr) { - console.error('Failed to track submission in Supabase:', trackErr); - } - // Upsert geocoded address to Supabase for instant map pin display if (coords && event.address.trim()) { try { @@ -91,11 +61,12 @@ export async function POST(request: NextRequest) { conference, }); } catch (geoErr) { + // Non-fatal: log but don't fail the submission console.error('Failed to save geocoded address:', geoErr); } } - return NextResponse.json({ success: true, row, pending: true }); + return NextResponse.json({ success: true, row }); } catch (err) { console.error('Submit event error:', err); const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/src/app/error.tsx b/src/app/error.tsx deleted file mode 100644 index c00c473c..00000000 --- a/src/app/error.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { trackError } from '@/lib/analytics'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - trackError(error.message, 'error-boundary'); - }, [error]); - - return ( -
-

Something went wrong

-

{error.message}

- -
- ); -} diff --git a/src/app/globals.css b/src/app/globals.css index 85085715..1483e09d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -53,42 +53,6 @@ --theme-logo-filter: invert(1); - --theme-header-bg: var(--theme-bg-primary); - --theme-header-border: var(--theme-border-secondary); - --theme-header-logo-filter: var(--theme-logo-filter); - --theme-header-text: var(--theme-text-secondary); - --theme-header-text-hover: var(--theme-text-primary); - --theme-header-control-bg: var(--theme-bg-secondary); - --theme-header-control-bg-hover: var(--theme-bg-tertiary); - --theme-header-control-border: var(--theme-border-primary); - --theme-header-accent: var(--theme-accent); - --theme-header-accent-muted: var(--theme-accent-muted); - --theme-header-badge-bg: var(--theme-accent); - --theme-header-badge-text: var(--theme-accent-text); - --theme-header-badge-border: var(--theme-accent); - - --theme-map-pin: #1c1917; - --theme-map-pin-night: rgba(255,255,255,0.4); - - --theme-bg-list: var(--theme-bg-primary); - --theme-bg-filter: var(--theme-bg-primary); - --theme-filter-text: var(--theme-text-secondary); - --theme-filter-control-bg: var(--theme-bg-secondary); - --theme-filter-control-border: var(--theme-border-primary); - --theme-filter-active: var(--theme-accent); - --theme-filter-active-bg: var(--theme-accent-muted); - --theme-filter-badge-bg: var(--theme-text-secondary); - --theme-filter-badge-text: var(--theme-bg-primary); - --theme-filter-badge-border: var(--theme-text-secondary); - --theme-table-header-bg: var(--theme-bg-secondary); - --theme-table-header-text: var(--theme-text-secondary); - --theme-landing-card-bg: var(--theme-bg-card); - --theme-landing-card-text: var(--theme-text-primary); - --theme-landing-card-secondary: var(--theme-text-secondary); - --theme-landing-card-muted: var(--theme-text-muted); - --theme-landing-card-accent: var(--theme-accent); - --theme-landing-card-border: var(--theme-border-primary); - --theme-scrollbar-track: #0c0a09; --theme-scrollbar-thumb: #292524; --theme-scrollbar-thumb-hover: #44403c; @@ -106,21 +70,6 @@ --tag-purple: #A855F7; --tag-default: #6B7280; --friend-blue: #3B82F6; - - --theme-modal-header-bg: transparent; - --theme-modal-header-text: var(--theme-text-primary); - --theme-modal-header-close: var(--theme-text-secondary); - --theme-modal-bg: var(--theme-bg-secondary); - - --theme-date-sep-bg: var(--theme-bg-primary); - --theme-date-sep-text: var(--theme-text-primary); - --theme-date-sep-muted: var(--theme-text-muted); - --theme-date-sep-border: var(--theme-border-secondary); - - --theme-ticker-bg: var(--theme-bg-primary); - --theme-ticker-text: var(--theme-text-secondary); - --theme-ticker-link: var(--theme-text-primary); - --theme-ticker-border: var(--theme-border-secondary); } /* --- Paper theme — warm parchment/cream aesthetic --- */ @@ -158,9 +107,6 @@ --theme-now-bg: #c0564a; - --theme-map-pin: #d4a32c; - --theme-map-pin-night: #6b4c0a; - --theme-logo-filter: invert(0); --theme-scrollbar-track: #f5f0e8; @@ -217,9 +163,6 @@ --theme-now-bg: #22c55e; - --theme-map-pin: #fca5a5; - --theme-map-pin-night: #dc2626; - --theme-logo-filter: invert(0); --theme-scrollbar-track: #fafafa; @@ -253,8 +196,8 @@ --theme-bg-primary: #fafafa; --theme-bg-secondary: #f5f5f5; --theme-bg-tertiary: #e5e5e5; - --theme-bg-card: #f7f7f8; - --theme-bg-card-hover: #f0f0f2; + --theme-bg-card: #ffffff; + --theme-bg-card-hover: #f5f5f5; --theme-bg-input: #ffffff; --theme-border-primary: #d4d4d4; @@ -278,33 +221,6 @@ --theme-logo-filter: brightness(0) saturate(100%) invert(22%) sepia(60%) saturate(1200%) hue-rotate(198deg) brightness(95%) contrast(95%); - --theme-header-bg: #1c4586; - --theme-header-border: #153a73; - --theme-header-logo-filter: brightness(0) invert(1); - --theme-header-text: rgba(255, 255, 255, 0.7); - --theme-header-text-hover: #ffffff; - --theme-header-control-bg: rgba(255, 255, 255, 0.1); - --theme-header-control-bg-hover: rgba(255, 255, 255, 0.2); - --theme-header-control-border: rgba(255, 255, 255, 0.2); - --theme-header-accent: #ffffff; - --theme-header-accent-muted: rgba(255, 255, 255, 0.15); - - --theme-map-pin: #93c5fd; - --theme-map-pin-night: #1c4586; - - --theme-bg-list: rgba(28, 69, 134, 0.06); - --theme-bg-filter: #1c4586; - --theme-filter-text: rgba(255, 255, 255, 0.7); - --theme-filter-control-bg: rgba(255, 255, 255, 0.1); - --theme-filter-control-border: rgba(255, 255, 255, 0.2); - --theme-filter-active: #ffffff; - --theme-filter-active-bg: rgba(255, 255, 255, 0.15); - --theme-filter-badge-bg: transparent; - --theme-filter-badge-text: #ffffff; - --theme-filter-badge-border: #ffffff; - --theme-table-header-bg: #1c4586; - --theme-table-header-text: #ffffff; - --theme-scrollbar-track: #fafafa; --theme-scrollbar-thumb: #d4d4d4; --theme-scrollbar-thumb-hover: #a3a3a3; @@ -314,40 +230,14 @@ --theme-popup-featured-border: rgba(28, 69, 134, 0.5); --theme-popup-shadow: rgba(0, 0, 0, 0.1); - --theme-landing-card-bg: #1c4586; - --theme-landing-card-text: #ffffff; - --theme-landing-card-secondary: rgba(255, 255, 255, 0.7); - --theme-landing-card-muted: rgba(255, 255, 255, 0.5); - --theme-landing-card-accent: #ffffff; - --theme-landing-card-border: rgba(255, 255, 255, 0.15); - - --tag-green: #34d399; - --tag-blue: #93c5fd; - --tag-yellow: #fbbf24; - --tag-pink: #f472b6; - --tag-teal: #5eead4; - --tag-purple: #c4b5fd; - --tag-default: #94a3b8; + --tag-green: #059669; + --tag-blue: #2563EB; + --tag-yellow: #D97706; + --tag-pink: #DB2777; + --tag-teal: #0D9488; + --tag-purple: #7C3AED; + --tag-default: #4B5563; --friend-blue: #2563EB; - - --theme-modal-header-bg: #1c4586; - --theme-modal-header-text: #ffffff; - --theme-modal-header-close: rgba(255, 255, 255, 0.7); - --theme-modal-bg: #edf0f3; - - --theme-date-sep-bg: #1c4586; - --theme-date-sep-text: #ffffff; - --theme-date-sep-muted: rgba(255, 255, 255, 0.7); - --theme-date-sep-border: rgba(255, 255, 255, 0.2); - - --theme-ticker-bg: var(--theme-bg-list); - --theme-ticker-text: #1c4586; - --theme-ticker-link: #1c4586; - --theme-ticker-border: rgba(28, 69, 134, 0.15); - - --theme-header-badge-bg: transparent; - --theme-header-badge-text: #ffffff; - --theme-header-badge-border: #ffffff; } /* Light-blue: alternating table rows */ @@ -355,7 +245,6 @@ background-color: rgba(28, 69, 134, 0.05); } - /* --- SXSW theme — neon lime on dark forest green --- */ [data-theme="sxsw"] { --background: #0a1410; diff --git a/src/app/itinerary/page.tsx b/src/app/itinerary/page.tsx index 30c9f94d..7710673d 100644 --- a/src/app/itinerary/page.tsx +++ b/src/app/itinerary/page.tsx @@ -1,553 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useMemo, useState, useRef, useCallback, useEffect } from 'react'; -import Link from 'next/link'; -import dynamic from 'next/dynamic'; -import { AddressLink } from '@/components/AddressLink'; -import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Star, ExternalLink, Eye, EyeOff, MapPinCheck, Loader2 } from 'lucide-react'; -import clsx from 'clsx'; -import { useEvents } from '@/hooks/useEvents'; -import { useItinerary } from '@/hooks/useItinerary'; -import { useAuth } from '@/contexts/AuthContext'; -import { supabase } from '@/lib/supabase'; -import { VIBE_COLORS } from '@/lib/tags'; -import { formatDateLabel } from '@/lib/utils'; -import { sortByStartTime, detectConflicts } from '@/lib/time-parse'; -import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryShareLink, trackItineraryReorder, trackItineraryView } from '@/lib/analytics'; -import type { ETHDenverEvent } from '@/lib/types'; -import { Loading } from '@/components/Loading'; -import { useEventCheckIn } from '@/hooks/useEventCheckIn'; -import { passesNowFilter, getConferenceNow } from '@/lib/filters'; -import { useDragReorder } from '@/hooks/useDragReorder'; -import { useProfile } from '@/hooks/useProfile'; -import { ShareCardModal } from '@/components/ShareCardModal'; -import { GoogleCalendarButton } from '@/components/GoogleCalendarButton'; -import { getTabConfig } from '@/lib/conferences'; -import { useConferenceTabs } from '@/hooks/useConferenceTabs'; - -const MapView = dynamic( - () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), - { - ssr: false, - loading: () => ( -
-
Loading map...
-
- ), - } -); - -type ItineraryViewMode = 'list' | 'map'; - -function generateShortCode(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let code = ''; - for (let i = 0; i < 8; i++) { - code += chars[Math.floor(Math.random() * chars.length)]; - } - return code; -} - -function CheckInToast({ result, onDismiss }: { result: { ok: boolean; message: string }; onDismiss: () => void }) { - useEffect(() => { - const timer = setTimeout(onDismiss, 3000); - return () => clearTimeout(timer); - }, [onDismiss]); - - return ( -
- {result.message} -
- ); -} - -export default function ItineraryPage() { - const { events, loading } = useEvents(); - const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); - const { user } = useAuth(); - const { profile } = useProfile(); - const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); - const { tabs } = useConferenceTabs(); - const [showClearConfirm, setShowClearConfirm] = useState(false); - const [viewMode, setViewMode] = useState('list'); - const [shareStatus, setShareStatus] = useState<'idle' | 'sharing' | 'copied'>('idle'); - const [showShareCard, setShowShareCard] = useState(false); - const captureRef = useRef(null); - - useEffect(() => { - trackItineraryView(); - }, []); - - // Get conferences that have itinerary events - const allItineraryEvents = useMemo( - () => events.filter((e) => itinerary.has(e.id)), - [events, itinerary] - ); - const conferences = useMemo( - () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))], - [allItineraryEvents] - ); - const [activeConference, setActiveConference] = useState(''); - - // Auto-select first conference when data loads - useMemo(() => { - if (conferences.length > 0 && !activeConference) { - setActiveConference(conferences[0]); - } - }, [conferences, activeConference]); - - const itineraryEvents = useMemo( - () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), - [allItineraryEvents, activeConference] - ); - - const conflicts = useMemo(() => detectConflicts(itineraryEvents), [itineraryEvents]); - - // Timezone for the active conference (used for Google Calendar export) - const conferenceTimezone = useMemo( - () => getTabConfig(activeConference, tabs).timezone, - [activeConference, tabs] - ); - - // Events eligible for Google Calendar export (exclude hidden) - const exportableEvents = useMemo( - () => itineraryEvents.filter((e) => !hiddenEvents.has(e.id)), - [itineraryEvents, hiddenEvents] - ); - - const dateGroups = useMemo(() => { - const groupMap = new Map(); - for (const event of itineraryEvents) { - const key = event.dateISO || 'unknown'; - if (!groupMap.has(key)) groupMap.set(key, []); - groupMap.get(key)!.push(event); - } - return Array.from(groupMap.entries()) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([dateISO, groupEvents]) => ({ - dateISO, - label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), - events: groupEvents.sort(sortByStartTime), - })); - }, [itineraryEvents]); - - // Flat ordered list of all event IDs across date groups (for drag reorder) - const flatEventIds = useMemo( - () => dateGroups.flatMap((g) => g.events.map((e) => e.id)), - [dateGroups] - ); - - const handleReorder = useCallback( - (orderedIds: string[]) => { - trackItineraryReorder(); - // The drag reorder gives us the visible (conference-filtered) IDs in new order. - // Preserve IDs from other conferences that aren't visible. - const allIds = [...itinerary]; - const visibleIdSet = new Set(flatEventIds); - const otherIds = allIds.filter((id) => !visibleIdSet.has(id)); - reorderItinerary([...orderedIds, ...otherIds]); - }, - [itinerary, flatEventIds, reorderItinerary] - ); - - const { - setOrderedIds, - registerItemRef, - getDragHandleProps, - getItemProps, - getDropIndicator, - dragId, - } = useDragReorder({ onReorder: handleReorder }); - - // Keep ordered IDs in sync - useEffect(() => { - setOrderedIds(flatEventIds); - }, [flatEventIds, setOrderedIds]); - - const handleShareLink = useCallback(async () => { - if (itineraryEvents.length === 0) return; - setShareStatus('sharing'); - try { - const shortCode = generateShortCode(); - const eventIds = itineraryEvents - .filter((e) => !hiddenEvents.has(e.id)) - .map((e) => e.id); - const { error } = await supabase.from('shared_itineraries').insert({ - short_code: shortCode, - event_ids: eventIds, - created_by: user?.id ?? null, - }); - if (error) { - console.error('Failed to create share link:', error); - setShareStatus('idle'); - return; - } - const shareUrl = `${window.location.origin}/itinerary/s/${shortCode}`; - await navigator.clipboard.writeText(shareUrl); - setShareStatus('copied'); - setTimeout(() => setShareStatus('idle'), 2500); - } catch (err) { - console.error('Share failed:', err); - setShareStatus('idle'); - } - }, [itineraryEvents, hiddenEvents, user]); - - if (loading) { - return ( -
- -
- ); - } - - return ( -
- {/* Header */} -
-
-
- - - -

- My Itinerary{' '} - - ({itineraryEvents.length} event{itineraryEvents.length !== 1 ? 's' : ''}) - -

-
- {/* Conference tabs */} - {conferences.length > 0 && ( -
- {conferences.map((conf) => ( - - ))} -
- )} -
- {itineraryEvents.length > 0 && ( - <> - {/* View toggle */} -
- {([ - { mode: 'list' as const, icon: List, label: 'List' }, - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, - ]).map(({ mode, icon: Icon, label }) => ( - - ))} -
- - - - - - )} -
-
-
- - {/* Check-in result toast */} - {checkInResult && ( - - )} - - {/* Content */} - {itineraryEvents.length === 0 ? ( -
- -

No events in your itinerary yet

-

- Star events from the main page to build your schedule! -

- - Browse Events - -
- ) : viewMode === 'map' ? ( -
- -
- ) : ( -
-
-
- 📅 - sheeets.xyz - — My Itinerary -
- - {conflicts.size > 0 && ( -
- -

- {conflicts.size} event{conflicts.size !== 1 ? 's' : ''} with schedule conflicts -

-
- )} - - {dateGroups.map((group) => ( -
-
-
-

- {group.label} -

-
-
- -
- {group.events.map((event) => { - const hasConflict = conflicts.has(event.id); - const vibeColor = VIBE_COLORS[event.vibe] || VIBE_COLORS['default']; - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; - const dropIndicator = getDropIndicator(event.id); - const isBeingDragged = dragId === event.id; - - return ( -
registerItemRef(event.id, el)} - {...getItemProps(event.id)} - className="relative" - > - {/* Drop indicator - above */} - {dropIndicator.showAbove && ( -
- )} - -
- {hasConflict && ( -
- - - Schedule conflict - -
- )} - -
-
- -
-

- {event.name} -

-
- {passesNowFilter(event, getConferenceNow(activeConference)) && ( - - )} - {event.link && ( - - - - )} - - -
-
- - {event.organizer && ( -

By {event.organizer}

- )} - -

{timeDisplay}

- - {event.address && ( - - {event.address} - - )} - -
- {event.vibe && ( - - {event.vibe} - - )} - {event.isFree && ( - - FREE - - )} -
-
- - {/* Drop indicator - below */} - {dropIndicator.showBelow && ( -
- )} -
- ); - })} -
-
- ))} - -
- sheeets.xyz — side event guide -
-
- - {/* Clear button */} -
- {showClearConfirm ? ( -
- Clear all events? - - -
- ) : ( - - )} -
-
- )} - - setShowShareCard(false)} - events={itineraryEvents} - conferenceName={activeConference || 'Itinerary'} - displayName={profile?.display_name ?? null} - avatarUrl={profile?.avatar_url} - hiddenEventIds={hiddenEvents} - /> -
- ); +export default function ItineraryRedirect() { + redirect('/plan'); } diff --git a/src/app/itinerary/s/[code]/page.tsx b/src/app/itinerary/s/[code]/page.tsx index 9ba6a3ed..63d72a11 100644 --- a/src/app/itinerary/s/[code]/page.tsx +++ b/src/app/itinerary/s/[code]/page.tsx @@ -1,351 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useMemo, useState, useCallback } from 'react'; -import { useParams } from 'next/navigation'; -import Link from 'next/link'; -import { AddressLink } from '@/components/AddressLink'; -import dynamic from 'next/dynamic'; -import { ArrowLeft, Calendar, Map as MapIcon, List, Copy, Check } from 'lucide-react'; -import clsx from 'clsx'; -import { useEvents } from '@/hooks/useEvents'; -import { useItinerary } from '@/hooks/useItinerary'; -import { useAuth } from '@/contexts/AuthContext'; -import { supabase } from '@/lib/supabase'; -import { VIBE_COLORS } from '@/lib/tags'; -import { formatDateLabel } from '@/lib/utils'; -import { sortByStartTime } from '@/lib/time-parse'; -import type { ETHDenverEvent } from '@/lib/types'; -import { Loading } from '@/components/Loading'; -import { AuthModal } from '@/components/AuthModal'; - -const MapView = dynamic( - () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), - { - ssr: false, - loading: () => ( -
-
Loading map...
-
- ), - } -); - -type SharedViewMode = 'list' | 'map'; - -export default function SharedItineraryPage() { - const params = useParams(); - const code = params.code as string; - - const { events, loading: eventsLoading } = useEvents(); - const { itinerary, addMany, toggle: toggleItinerary } = useItinerary(); - const { user } = useAuth(); - - const [sharedEventIds, setSharedEventIds] = useState(null); - const [loadingShare, setLoadingShare] = useState(true); - const [notFound, setNotFound] = useState(false); - const [viewMode, setViewMode] = useState('list'); - const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle'); - const [showAuth, setShowAuth] = useState(false); - const [pendingCopy, setPendingCopy] = useState(false); - - // Fetch shared itinerary from Supabase - useEffect(() => { - async function fetchShared() { - try { - const { data, error } = await supabase - .from('shared_itineraries') - .select('event_ids') - .eq('short_code', code) - .maybeSingle(); - - if (error || !data) { - setNotFound(true); - } else { - setSharedEventIds(data.event_ids); - } - } catch { - setNotFound(true); - } - setLoadingShare(false); - } - - if (code) fetchShared(); - }, [code]); - - // Handle pending copy after auth - useEffect(() => { - if (pendingCopy && user && sharedEventIds) { - addMany(sharedEventIds); - setCopyStatus('copied'); - setPendingCopy(false); - setTimeout(() => setCopyStatus('idle'), 2500); - } - }, [pendingCopy, user, sharedEventIds, addMany]); - - const sharedEvents = useMemo(() => { - if (!sharedEventIds) return []; - const idSet = new Set(sharedEventIds); - return events.filter((e) => idSet.has(e.id)); - }, [events, sharedEventIds]); - - const dateGroups = useMemo(() => { - const groupMap = new Map(); - for (const event of sharedEvents) { - const key = event.dateISO || 'unknown'; - if (!groupMap.has(key)) groupMap.set(key, []); - groupMap.get(key)!.push(event); - } - return Array.from(groupMap.entries()) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([dateISO, groupEvents]) => ({ - dateISO, - label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), - events: groupEvents.sort(sortByStartTime), - })); - }, [sharedEvents]); - - const dateRange = useMemo(() => { - if (dateGroups.length === 0) return ''; - if (dateGroups.length === 1) return dateGroups[0].label; - return `${dateGroups[0].label} - ${dateGroups[dateGroups.length - 1].label}`; - }, [dateGroups]); - - const handleCopyToItinerary = useCallback(() => { - if (!sharedEventIds || sharedEventIds.length === 0) return; - - if (!user) { - setPendingCopy(true); - setShowAuth(true); - return; - } - - addMany(sharedEventIds); - setCopyStatus('copied'); - setTimeout(() => setCopyStatus('idle'), 2500); - }, [sharedEventIds, user, addMany]); - - const handleAuthClose = useCallback(() => { - setShowAuth(false); - // If user didn't sign in, cancel pending copy - if (!user) { - setPendingCopy(false); - } - }, [user]); - - const loading = eventsLoading || loadingShare; - - if (loading) { - return ( -
- -
- ); - } - - if (notFound) { - return ( -
-

Itinerary not found

-

This share link may have expired or is invalid.

- - Browse Events - -
- ); - } - - return ( -
- {/* Header */} -
-
-
- - - -
-

Shared Itinerary

-

- {sharedEvents.length} event{sharedEvents.length !== 1 ? 's' : ''} - {dateRange && ` · ${dateRange}`} -

-
-
-
- {sharedEvents.length > 0 && ( - <> - {/* View toggle */} -
- {([ - { mode: 'list' as const, icon: List, label: 'List' }, - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, - ]).map(({ mode, icon: Icon, label }) => ( - - ))} -
- - )} -
-
-
- - {/* Copy to itinerary banner */} - {sharedEvents.length > 0 && ( -
-
-

- Add these events to your itinerary -

- -
-
- )} - - {/* Content */} - {sharedEvents.length === 0 ? ( -
- -

No matching events found

-

- The events in this itinerary may no longer be available. -

- - Browse Events - -
- ) : viewMode === 'map' ? ( -
- -
- ) : ( -
- {dateGroups.map((group) => ( -
-
-
-

- {group.label} -

-
-
- -
- {group.events.map((event) => { - const vibeColor = VIBE_COLORS[event.vibe] || VIBE_COLORS['default']; - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; - - return ( -
-

- {event.link ? ( - - {event.name} - - ) : ( - event.name - )} -

- - {event.organizer && ( -

By {event.organizer}

- )} - -

{timeDisplay}

- - {event.address && ( - - {event.address} - - )} - -
- {event.vibe && ( - - {event.vibe} - - )} - {event.isFree && ( - - FREE - - )} -
-
- ); - })} -
-
- ))} - -
- sheeets.xyz — side event guide -
-
- )} - - -
- ); +export default function SharedItineraryRedirect({ params }: { params: { code: string } }) { + redirect(`/plan/s/${params.code}`); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e550247a..cd64c88f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,11 +51,8 @@ export default function RootLayout({ const websiteJsonLd = buildWebSiteJsonLd(); return ( - + -