From 087742092d0393c178ecc5fea3740149f36e3c77 Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 19:36:43 -0400 Subject: [PATCH 01/32] feat(itinerary): overhaul page with EventCard, fix drag reorder, pass conference param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom minimal cards with full EventCard component (flyers, tags, reactions, friend avatars) - Fix drag reorder: preserve itinerary Set order instead of re-sorting by start time - Fix setDragImage to use item element instead of tiny drag handle - Pass ?conf= param when navigating to /itinerary from ItineraryPanel - Read conf param on itinerary page and use as initial active conference - Fix useMemo antipattern (setState inside useMemo) โ†’ useEffect - Rebrand sheeets.xyz โ†’ plan.wtf Task: pineapple-22344 Co-Authored-By: Claude Opus 4.6 --- src/app/itinerary/page.tsx | 214 ++++++++++++------------------ src/components/ItineraryPanel.tsx | 6 +- src/hooks/useDragReorder.ts | 8 +- 3 files changed, 90 insertions(+), 138 deletions(-) diff --git a/src/app/itinerary/page.tsx b/src/app/itinerary/page.tsx index a9ed7a7c..477315fc 100644 --- a/src/app/itinerary/page.tsx +++ b/src/app/itinerary/page.tsx @@ -1,18 +1,18 @@ 'use client'; -import { useMemo, useState, useRef, useCallback, useEffect } from 'react'; +import { Suspense, useMemo, useState, useRef, useCallback, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; 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 { EventCard } from '@/components/EventCard'; +import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Eye, EyeOff } 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 { detectConflicts } from '@/lib/time-parse'; import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryShareLink, trackItineraryReorder } from '@/lib/analytics'; import type { ETHDenverEvent } from '@/lib/types'; import { Loading } from '@/components/Loading'; @@ -62,7 +62,7 @@ function CheckInToast({ result, onDismiss }: { result: { ok: boolean; message: s ); } -export default function ItineraryPage() { +function ItineraryContent() { const { events, loading } = useEvents(); const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); const { user } = useAuth(); @@ -74,23 +74,32 @@ export default function ItineraryPage() { const [showShareCard, setShowShareCard] = useState(false); const captureRef = useRef(null); - // Get conferences that have itinerary events - const allItineraryEvents = useMemo( - () => events.filter((e) => itinerary.has(e.id)), - [events, itinerary] - ); + // Get conferences that have itinerary events โ€” iterate Set to preserve insertion/reorder order + const allItineraryEvents = useMemo(() => { + const eventMap = new Map(events.map(e => [e.id, e])); + const result: ETHDenverEvent[] = []; + for (const id of itinerary) { + const ev = eventMap.get(id); + if (ev) result.push(ev); + } + return result; + }, [events, itinerary]); const conferences = useMemo( () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))], [allItineraryEvents] ); - const [activeConference, setActiveConference] = useState(''); + const searchParams = useSearchParams(); + const confParam = searchParams.get('conf') || ''; + const [activeConference, setActiveConference] = useState(confParam); - // Auto-select first conference when data loads - useMemo(() => { - if (conferences.length > 0 && !activeConference) { + // Auto-select conference: respect URL param, then fall back to first conference + useEffect(() => { + if (confParam && conferences.includes(confParam)) { + setActiveConference(confParam); + } else if (conferences.length > 0 && !activeConference) { setActiveConference(conferences[0]); } - }, [conferences, activeConference]); + }, [conferences, confParam]); const itineraryEvents = useMemo( () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), @@ -111,7 +120,7 @@ export default function ItineraryPage() { .map(([dateISO, groupEvents]) => ({ dateISO, label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), - events: groupEvents.sort(sortByStartTime), + events: groupEvents, })); }, [itineraryEvents]); @@ -309,7 +318,7 @@ export default function ItineraryPage() {
๐Ÿ“… - sheeets.xyz + plan.wtf โ€” My Itinerary
@@ -335,10 +344,6 @@ export default function ItineraryPage() {
{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; @@ -354,120 +359,57 @@ export default function ItineraryPage() {
)} -
- {hasConflict && ( -
- - - Schedule conflict - -
- )} - -
-
- -
-

- {event.name} -

-
- {passesNowFilter(event, getConferenceNow(activeConference)) && ( - - )} - {event.link && ( - - - - )} - - -
+ {hasConflict && ( +
+ + + Schedule conflict +
+ )} - {event.organizer && ( -

By {event.organizer}

- )} - -

{timeDisplay}

- - {event.address && ( - - {event.address} - - )} - -
- {event.vibe && ( - - {event.vibe} - - )} - {event.isFree && ( - - FREE - - )} +
+
+ +
+
+ checkInToEvent(eventId, event.name)} + checkInLoading={checkInLoading} + liveUrgency={passesNowFilter(event, getConferenceNow(activeConference)) ? 'green' : undefined} + conference={activeConference} + />
+ {/* Hide/show toggle for friends visibility */} +
+ +
+ {/* Drop indicator - below */} {dropIndicator.showBelow && (
@@ -480,7 +422,7 @@ export default function ItineraryPage() { ))}
- sheeets.xyz โ€” side event guide + plan.wtf โ€” side event guide
@@ -527,3 +469,11 @@ export default function ItineraryPage() {
); } + +export default function ItineraryPage() { + return ( +
}> + + + ); +} diff --git a/src/components/ItineraryPanel.tsx b/src/components/ItineraryPanel.tsx index 6c67d295..35c15074 100644 --- a/src/components/ItineraryPanel.tsx +++ b/src/components/ItineraryPanel.tsx @@ -176,7 +176,7 @@ export function ItineraryPanel({ {itineraryEvents.length > 0 && ( <> ๐Ÿ“… - sheeets.xyz + plan.wtf โ€” My Itinerary
@@ -400,7 +400,7 @@ export function ItineraryPanel({ {/* Footer in PNG */}
- sheeets.xyz โ€” side event guide + plan.wtf โ€” side event guide
diff --git a/src/hooks/useDragReorder.ts b/src/hooks/useDragReorder.ts index 00532635..a119bee1 100644 --- a/src/hooks/useDragReorder.ts +++ b/src/hooks/useDragReorder.ts @@ -71,10 +71,12 @@ export function useDragReorder({ onReorder }: UseDragReorderOptions) { const handleDragStart = useCallback((e: React.DragEvent, id: string) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); - // Set a small drag image offset + // Use the full item element as drag image instead of the tiny handle if (e.dataTransfer.setDragImage) { - const target = e.currentTarget as HTMLElement; - e.dataTransfer.setDragImage(target, 20, 20); + const itemEl = itemRefs.current.get(id); + if (itemEl) { + e.dataTransfer.setDragImage(itemEl, 20, 20); + } } setDragState({ dragId: id, overId: null, position: 'below' }); }, []); From 906606a67e7888bf298169dc47da595aa05f88a5 Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 22:48:35 -0400 Subject: [PATCH 02/32] feat: rename /itinerary to /plan with dropdown conference selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route /itinerary now redirects to /plan, shared links redirect similarly - Heading changed to "My Plan", conference tabs replaced with dropdown - FilterBar itinerary button navigates to /plan instead of toggling filter - Removed isItineraryActive prop from FilterBar (no longer a toggle) - Updated all text: "My Itinerary" โ†’ "My Plan", "Clear Itinerary" โ†’ "Clear Plan" - Updated branding in shared page from sheeets.xyz to plan.wtf Co-Authored-By: Claude Opus 4.6 --- src/app/itinerary/page.tsx | 480 +------------------------- src/app/itinerary/s/[code]/page.tsx | 352 +------------------ src/app/plan/page.tsx | 510 ++++++++++++++++++++++++++++ src/app/plan/s/[code]/page.tsx | 351 +++++++++++++++++++ src/app/robots.ts | 2 +- src/components/EventApp.tsx | 7 +- src/components/FilterBar.tsx | 21 +- src/components/ItineraryPanel.tsx | 12 +- 8 files changed, 882 insertions(+), 853 deletions(-) create mode 100644 src/app/plan/page.tsx create mode 100644 src/app/plan/s/[code]/page.tsx diff --git a/src/app/itinerary/page.tsx b/src/app/itinerary/page.tsx index 477315fc..7710673d 100644 --- a/src/app/itinerary/page.tsx +++ b/src/app/itinerary/page.tsx @@ -1,479 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { Suspense, useMemo, useState, useRef, useCallback, useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import dynamic from 'next/dynamic'; -import { EventCard } from '@/components/EventCard'; -import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Eye, EyeOff } 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 { formatDateLabel } from '@/lib/utils'; -import { detectConflicts } from '@/lib/time-parse'; -import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryShareLink, trackItineraryReorder } 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'; - -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} -
- ); -} - -function ItineraryContent() { - 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 [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); - - // Get conferences that have itinerary events โ€” iterate Set to preserve insertion/reorder order - const allItineraryEvents = useMemo(() => { - const eventMap = new Map(events.map(e => [e.id, e])); - const result: ETHDenverEvent[] = []; - for (const id of itinerary) { - const ev = eventMap.get(id); - if (ev) result.push(ev); - } - return result; - }, [events, itinerary]); - const conferences = useMemo( - () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))], - [allItineraryEvents] - ); - const searchParams = useSearchParams(); - const confParam = searchParams.get('conf') || ''; - const [activeConference, setActiveConference] = useState(confParam); - - // Auto-select conference: respect URL param, then fall back to first conference - useEffect(() => { - if (confParam && conferences.includes(confParam)) { - setActiveConference(confParam); - } else if (conferences.length > 0 && !activeConference) { - setActiveConference(conferences[0]); - } - }, [conferences, confParam]); - - const itineraryEvents = useMemo( - () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), - [allItineraryEvents, activeConference] - ); - - const conflicts = useMemo(() => detectConflicts(itineraryEvents), [itineraryEvents]); - - 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.localeCompare(b)) - .map(([dateISO, groupEvents]) => ({ - dateISO, - label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), - events: groupEvents, - })); - }, [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' ? ( -
- -
- ) : ( -
-
-
- ๐Ÿ“… - plan.wtf - โ€” 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 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 - -
- )} - -
-
- -
-
- checkInToEvent(eventId, event.name)} - checkInLoading={checkInLoading} - liveUrgency={passesNowFilter(event, getConferenceNow(activeConference)) ? 'green' : undefined} - conference={activeConference} - /> -
-
- - {/* Hide/show toggle for friends visibility */} -
- -
- - {/* Drop indicator - below */} - {dropIndicator.showBelow && ( -
- )} -
- ); - })} -
-
- ))} - -
- plan.wtf โ€” 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 ItineraryPage() { - return ( -
}> - - - ); +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 957540fc..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.localeCompare(b)) - .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/plan/page.tsx b/src/app/plan/page.tsx new file mode 100644 index 00000000..06c5450e --- /dev/null +++ b/src/app/plan/page.tsx @@ -0,0 +1,510 @@ +'use client'; + +import { Suspense, useMemo, useState, useRef, useCallback, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import dynamic from 'next/dynamic'; +import { EventCard } from '@/components/EventCard'; +import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Eye, EyeOff, ChevronDown, MapPin } 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 { formatDateLabel } from '@/lib/utils'; +import { detectConflicts } from '@/lib/time-parse'; +import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryShareLink, trackItineraryReorder } 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'; + +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} +
+ ); +} + +function ItineraryContent() { + 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 [showClearConfirm, setShowClearConfirm] = useState(false); + const [viewMode, setViewMode] = useState('list'); + const [shareStatus, setShareStatus] = useState<'idle' | 'sharing' | 'copied'>('idle'); + const [showShareCard, setShowShareCard] = useState(false); + const [confOpen, setConfOpen] = useState(false); + const confBtnRef = useRef(null); + const captureRef = useRef(null); + + // Get conferences that have itinerary events โ€” iterate Set to preserve insertion/reorder order + const allItineraryEvents = useMemo(() => { + const eventMap = new Map(events.map(e => [e.id, e])); + const result: ETHDenverEvent[] = []; + for (const id of itinerary) { + const ev = eventMap.get(id); + if (ev) result.push(ev); + } + return result; + }, [events, itinerary]); + const conferences = useMemo( + () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))], + [allItineraryEvents] + ); + const searchParams = useSearchParams(); + const confParam = searchParams.get('conf') || ''; + const [activeConference, setActiveConference] = useState(confParam); + + // Auto-select conference: respect URL param, then fall back to first conference + useEffect(() => { + if (confParam && conferences.includes(confParam)) { + setActiveConference(confParam); + } else if (conferences.length > 0 && !activeConference) { + setActiveConference(conferences[0]); + } + }, [conferences, confParam]); + + const itineraryEvents = useMemo( + () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), + [allItineraryEvents, activeConference] + ); + + const conflicts = useMemo(() => detectConflicts(itineraryEvents), [itineraryEvents]); + + 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.localeCompare(b)) + .map(([dateISO, groupEvents]) => ({ + dateISO, + label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), + events: groupEvents, + })); + }, [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}/plan/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 Plan{' '} + + ({itineraryEvents.length} event{itineraryEvents.length !== 1 ? 's' : ''}) + +

+
+ {/* Conference dropdown */} + {conferences.length > 0 && ( +
+ + {confOpen && ( + <> +
setConfOpen(false)} /> +
+ {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 plan yet

+

+ Add events from the main page to build your schedule! +

+ + Browse Events + +
+ ) : viewMode === 'map' ? ( +
+ +
+ ) : ( +
+
+
+ ๐Ÿ“… + plan.wtf + โ€” My Plan +
+ + {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 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 + +
+ )} + +
+
+ +
+
+ checkInToEvent(eventId, event.name)} + checkInLoading={checkInLoading} + liveUrgency={passesNowFilter(event, getConferenceNow(activeConference)) ? 'green' : undefined} + conference={activeConference} + /> +
+
+ + {/* Hide/show toggle for friends visibility */} +
+ +
+ + {/* Drop indicator - below */} + {dropIndicator.showBelow && ( +
+ )} +
+ ); + })} +
+
+ ))} + +
+ plan.wtf โ€” side event guide +
+
+ + {/* Clear button */} +
+ {showClearConfirm ? ( +
+ Clear all events? + + +
+ ) : ( + + )} +
+
+ )} + + setShowShareCard(false)} + events={itineraryEvents} + conferenceName={activeConference || 'My Plan'} + displayName={profile?.display_name ?? null} + avatarUrl={profile?.avatar_url} + hiddenEventIds={hiddenEvents} + /> +
+ ); +} + +export default function ItineraryPage() { + return ( +
}> + + + ); +} diff --git a/src/app/plan/s/[code]/page.tsx b/src/app/plan/s/[code]/page.tsx new file mode 100644 index 00000000..e70a0cf5 --- /dev/null +++ b/src/app/plan/s/[code]/page.tsx @@ -0,0 +1,351 @@ +'use client'; + +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.localeCompare(b)) + .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 ( +
+

Plan not found

+

This share link may have expired or is invalid.

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

Shared Plan

+

+ {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 plan +

+ +
+
+ )} + + {/* 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 + + )} +
+
+ ); + })} +
+
+ ))} + +
+ plan.wtf โ€” side event guide +
+
+ )} + + +
+ ); +} diff --git a/src/app/robots.ts b/src/app/robots.ts index a2b370f2..b5eb3575 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -5,7 +5,7 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: '*', allow: '/', - disallow: ['/admin', '/itinerary'], + disallow: ['/admin', '/plan', '/itinerary'], }, sitemap: 'https://plan.wtf/sitemap.xml', }; diff --git a/src/components/EventApp.tsx b/src/components/EventApp.tsx index d633ab00..bb3b8348 100644 --- a/src/components/EventApp.tsx +++ b/src/components/EventApp.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useRouter } from 'next/navigation'; import { useEvents } from '@/hooks/useEvents'; import { useFilters } from '@/hooks/useFilters'; import { useItinerary } from '@/hooks/useItinerary'; @@ -239,14 +240,15 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen checkInToNearbyEvents(events, itinerary, filters.conference); }, [checkInToNearbyEvents, events, itinerary, filters.conference]); + const router = useRouter(); const handleItineraryFilterToggle = useCallback(() => { if (!authUser) { trackAuthPrompt('itinerary_button'); setShowSignIn(true); return; } - toggleBool('itineraryOnly'); - }, [authUser, toggleBool]); + router.push(`/plan${filters.conference ? `?conf=${encodeURIComponent(filters.conference)}` : ''}`); + }, [authUser, router, filters.conference]); // Theme: read from admin config per-conference and apply const { setTheme } = useTheme(); @@ -485,7 +487,6 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen conferenceTabs={conferenceTabs} itineraryCount={filteredEvents.filter(e => itinerary.has(e.id)).length} onItineraryToggle={handleItineraryFilterToggle} - isItineraryActive={filters.itineraryOnly} /> diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index 00c170b1..17527046 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -39,7 +39,6 @@ interface FilterBarProps { conferenceTabs?: TabConfig[]; itineraryCount: number; onItineraryToggle: () => void; - isItineraryActive: boolean; } export function FilterBar({ @@ -66,7 +65,6 @@ export function FilterBar({ conferenceTabs, itineraryCount, onItineraryToggle, - isItineraryActive, }: FilterBarProps) { const [expanded, setExpanded] = useState(false); const [confOpen, setConfOpen] = useState(false); @@ -240,26 +238,15 @@ export function FilterBar({ )} - {/* Itinerary toggle */} + {/* My Plan button */} )} @@ -445,7 +445,7 @@ export function ItineraryPanel({ isOpen={showShareCard} onClose={() => setShowShareCard(false)} events={itineraryEvents} - conferenceName={selectedConference || 'Itinerary'} + conferenceName={selectedConference || 'My Plan'} displayName={profile?.display_name ?? null} avatarUrl={profile?.avatar_url} hiddenEventIds={hiddenEvents} From 3f0ad8f94ea241cd9593b380800064f6966f3538 Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 23:15:10 -0400 Subject: [PATCH 03/32] feat: add table view to /plan and center map on selected conference - Map view now passes conference + conferenceTabs so it centers correctly - Added table view as third view mode option (list/table/map) Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 06c5450e..2a4e1cd6 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import dynamic from 'next/dynamic'; import { EventCard } from '@/components/EventCard'; -import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Eye, EyeOff, ChevronDown, MapPin } from 'lucide-react'; +import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, Table, GripVertical, Eye, EyeOff, ChevronDown, MapPin } from 'lucide-react'; import clsx from 'clsx'; import { useEvents } from '@/hooks/useEvents'; import { useItinerary } from '@/hooks/useItinerary'; @@ -21,6 +21,8 @@ import { passesNowFilter, getConferenceNow } from '@/lib/filters'; import { useDragReorder } from '@/hooks/useDragReorder'; import { useProfile } from '@/hooks/useProfile'; import { ShareCardModal } from '@/components/ShareCardModal'; +import { useConferenceTabs } from '@/hooks/useConferenceTabs'; +import { TableView } from '@/components/TableView'; const MapView = dynamic( () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), @@ -34,7 +36,7 @@ const MapView = dynamic( } ); -type ItineraryViewMode = 'list' | 'map'; +type ItineraryViewMode = 'list' | 'map' | 'table'; function generateShortCode(): string { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; @@ -64,6 +66,7 @@ function CheckInToast({ result, onDismiss }: { result: { ok: boolean; message: s function ItineraryContent() { const { events, loading } = useEvents(); + const { tabs: conferenceTabs } = useConferenceTabs(); const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); const { user } = useAuth(); const { profile } = useProfile(); @@ -196,7 +199,7 @@ function ItineraryContent() { } return ( -
+
{/* Header */}
@@ -270,6 +273,7 @@ function ItineraryContent() {
{([ { mode: 'list' as const, icon: List, label: 'List' }, + { mode: 'table' as const, icon: Table, label: 'Table' }, { mode: 'map' as const, icon: MapIcon, label: 'Map' }, ]).map(({ mode, icon: Icon, label }) => ( - )}
From c17480f3a649b54e0807856dadeb24be86507efc Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 23:31:46 -0400 Subject: [PATCH 05/32] feat: show distance from event on /plan page Watch user geolocation and pass userLocation to EventCard (list view) and TableView so distance is displayed when location is shared. Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 3ead7d96..eb31360c 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -70,6 +70,18 @@ function ItineraryContent() { const confBtnRef = useRef(null); const captureRef = useRef(null); + // User location for distance display + const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); + useEffect(() => { + if (!navigator.geolocation) return; + const watchId = navigator.geolocation.watchPosition( + (pos) => setUserLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }), + () => {}, + { maximumAge: 30000, enableHighAccuracy: false } + ); + return () => navigator.geolocation.clearWatch(watchId); + }, []); + // Get conferences that have itinerary events โ€” iterate Set to preserve insertion/reorder order const allItineraryEvents = useMemo(() => { const eventMap = new Map(events.map(e => [e.id, e])); @@ -309,6 +321,7 @@ function ItineraryContent() { itinerary={itinerary} onItineraryToggle={toggleItinerary} conference={activeConference} + userLocation={userLocation} /> ) : ( @@ -385,6 +398,7 @@ function ItineraryContent() { checkInLoading={checkInLoading} liveUrgency={passesNowFilter(event, getConferenceNow(activeConference)) ? 'green' : undefined} conference={activeConference} + userLocation={userLocation} />
From 1d5b1add3b8496549c30219fd1e896c540331bfe Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 23:47:58 -0400 Subject: [PATCH 06/32] feat: share card flyer gallery with image proxy Co-Authored-By: Claude Opus 4.6 --- src/app/api/image-proxy/route.ts | 39 ++++++ src/components/ShareCardModal.tsx | 61 ++++++++- src/components/ShareCardTemplate.tsx | 193 ++++++++++----------------- 3 files changed, 166 insertions(+), 127 deletions(-) create mode 100644 src/app/api/image-proxy/route.ts 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/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index 5935198b..2b0b389c 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -7,6 +7,7 @@ import type { ETHDenverEvent } from '@/lib/types'; import { formatDateLabel } from '@/lib/utils'; import { sortByStartTime } from '@/lib/time-parse'; import { trackShareCardOpen, trackShareCardCopy, trackShareCardDownload } from '@/lib/analytics'; +import { imageCache } from './OGImage'; import ShareCardTemplate from './ShareCardTemplate'; interface ShareCardModalProps { @@ -35,6 +36,7 @@ export function ShareCardModal({ const [previewUrl, setPreviewUrl] = useState(null); const [generating, setGenerating] = useState(false); const [copyStatus, setCopyStatus] = useState<'idle' | 'copying' | 'copied'>('idle'); + const [flyerImages, setFlyerImages] = useState>(new Map()); const cardRef = useRef(null); const debounceRef = useRef | null>(null); const trackedRef = useRef(false); @@ -93,6 +95,62 @@ export function ShareCardModal({ } setGenerating(true); try { + // --- Pre-fetch flyer images as data URLs --- + const imageMap = new Map(); + const ogUrls = new Map(); + const uncachedItems: { eventId: string; url: string }[] = []; + + for (const event of selectedEvents) { + if (!event.link) continue; + const cached = imageCache.get(event.link); + if (cached) { + ogUrls.set(event.id, cached); + } else if (cached === undefined) { + uncachedItems.push({ eventId: event.id, url: event.link }); + } + } + + if (uncachedItems.length > 0) { + try { + const res = await fetch('/api/og', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items: uncachedItems.slice(0, 20) }), + }); + if (res.ok) { + const data = await res.json(); + const results = data.results as Record; + for (const [eventId, imgUrl] of Object.entries(results)) { + if (imgUrl) ogUrls.set(eventId, imgUrl); + const item = uncachedItems.find(i => i.eventId === eventId); + if (item) imageCache.set(item.url, imgUrl ?? null); + } + } + } catch (err) { + console.error('Batch OG fetch failed:', err); + } + } + + const proxyFetches = Array.from(ogUrls.entries()).map(async ([eventId, imgUrl]) => { + try { + const proxyRes = await fetch(`/api/image-proxy?url=${encodeURIComponent(imgUrl)}`); + if (!proxyRes.ok) return; + const blob = await proxyRes.blob(); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + imageMap.set(eventId, dataUrl); + } catch { /* skip failed images */ } + }); + + await Promise.all(proxyFetches); + setFlyerImages(imageMap); + + await new Promise(r => setTimeout(r, 50)); + const { toPng } = await import('html-to-image'); const dataUrl = await toPng(cardRef.current, { pixelRatio: 2, @@ -105,7 +163,7 @@ export function ShareCardModal({ } finally { setGenerating(false); } - }, [selectedEvents.length]); + }, [selectedEvents]); // Debounced preview regeneration when selection changes useEffect(() => { @@ -373,6 +431,7 @@ export function ShareCardModal({ conferenceName={cardTitle || conferenceName} displayName={displayName} avatarUrl={avatarUrl} + flyerImages={flyerImages} /> , document.body diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index a1e05d11..4f098a31 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -10,12 +10,13 @@ interface ShareCardTemplateProps { conferenceName: string; displayName: string | null; avatarUrl?: string | null; + flyerImages?: Map; } const MAX_EVENTS = 15; const ShareCardTemplate = forwardRef( - function ShareCardTemplate({ events, conferenceName, displayName, avatarUrl }, ref) { + function ShareCardTemplate({ events, conferenceName, displayName, avatarUrl, flyerImages }, ref) { const dateGroups = useMemo(() => { const groupMap = new Map(); for (const event of events) { @@ -32,10 +33,8 @@ const ShareCardTemplate = forwardRef( })); }, [events]); - // Truncate to MAX_EVENTS using reduce to avoid mutable variables const { truncatedGroups, truncated, remainingCount } = useMemo(() => { const remaining = events.length > MAX_EVENTS ? events.length - MAX_EVENTS : 0; - const result = dateGroups.reduce<{ groups: typeof dateGroups; count: number; @@ -55,7 +54,6 @@ const ShareCardTemplate = forwardRef( }, { groups: [], count: 0, isTruncated: false } ); - return { truncatedGroups: result.groups, truncated: result.isTruncated, @@ -78,40 +76,19 @@ const ShareCardTemplate = forwardRef( }} > {/* Header */} -
+
{avatarUrl ? ( ) : displayName ? (
{displayName.charAt(0).toUpperCase()} @@ -119,13 +96,8 @@ const ShareCardTemplate = forwardRef( ) : null}
{conferenceName} @@ -133,106 +105,86 @@ const ShareCardTemplate = forwardRef(
{/* Amber separator */} -
+
- {/* Events block */} + {/* Events block - gallery grid per day */}
{truncatedGroups.map((group) => (
- {/* Day header */}
{group.label}
- {/* Event rows */} - {group.events.map((event) => { - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; - - return ( -
- {/* Time */} -
- {timeDisplay} -
- - {/* Event details */} -
-
- {event.name} -
- {event.address && ( +
+ {group.events.map((event) => { + const flyerDataUrl = flyerImages?.get(event.id); + const timeDisplay = event.isAllDay ? 'All Day' : event.startTime || ''; + + return ( +
+ {flyerDataUrl ? ( + + ) : (
- {event.address} +
+ {event.name} +
)} +
+
+ {event.name} +
+ {timeDisplay && ( +
+ {timeDisplay} +
+ )} +
-
- ); - })} + ); + })} +
))} - {/* Truncation notice */} {truncated && remainingCount > 0 && (
... and {remainingCount} more event{remainingCount !== 1 ? 's' : ''} @@ -241,22 +193,11 @@ const ShareCardTemplate = forwardRef(
{/* Footer */} -
+
plan.wtf
From 692713b1496abe56dcc7364470e3c6e06e8d7ccf Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 23:58:27 -0400 Subject: [PATCH 07/32] ui: square flyer tiles with title and time above image in share card Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardTemplate.tsx | 50 +++++++++++----------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index 4f098a31..9759f721 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -127,36 +127,8 @@ const ShareCardTemplate = forwardRef( const timeDisplay = event.isAllDay ? 'All Day' : event.startTime || ''; return ( -
- {flyerDataUrl ? ( - - ) : ( -
-
- {event.name} -
-
- )} +
+ {/* Text info above the image */}
(
)}
+ {/* Image (square) or fallback */} + {flyerDataUrl ? ( + + ) : ( +
+ )}
); })} From c300f6ba9c1803f4c51d1f51e790d1a6a9521191 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 00:20:32 -0400 Subject: [PATCH 08/32] ui: use explicit pixel sizes for share card gallery tiles Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardTemplate.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index 9759f721..86ecadea 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -121,15 +121,15 @@ const ShareCardTemplate = forwardRef( {group.label}
-
+
{group.events.map((event) => { const flyerDataUrl = flyerImages?.get(event.id); const timeDisplay = event.isAllDay ? 'All Day' : event.startTime || ''; return ( -
+
{/* Text info above the image */} -
+
( src={flyerDataUrl} alt="" style={{ - width: '100%', aspectRatio: '1/1', objectFit: 'cover', + width: '237px', height: '237px', objectFit: 'cover', borderRadius: '8px', display: 'block', }} /> ) : (
From 67c55aa945f369db5fe404c545880fcf6fb3e508 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 00:26:22 -0400 Subject: [PATCH 09/32] ui: double flyer tile size on share card (2-column layout) Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardTemplate.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index 86ecadea..8ebf058e 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -127,12 +127,12 @@ const ShareCardTemplate = forwardRef( const timeDisplay = event.isAllDay ? 'All Day' : event.startTime || ''; return ( -
+
{/* Text info above the image */}
( {event.name}
{timeDisplay && ( -
+
{timeDisplay}
)} @@ -151,14 +151,14 @@ const ShareCardTemplate = forwardRef( src={flyerDataUrl} alt="" style={{ - width: '237px', height: '237px', objectFit: 'cover', + width: '486px', height: '486px', objectFit: 'cover', borderRadius: '8px', display: 'block', }} /> ) : (
From c20371905bbff58e275cdbc6e93b9dbd4795f782 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 00:29:22 -0400 Subject: [PATCH 10/32] ui: improve time readability on share card flyer tiles Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardTemplate.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index 8ebf058e..c3a5a149 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -140,7 +140,7 @@ const ShareCardTemplate = forwardRef( {event.name}
{timeDisplay && ( -
+
{timeDisplay}
)} From 7885de38fa905f6e23c990754bb58a4839bb85d3 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 00:34:40 -0400 Subject: [PATCH 11/32] feat: show friend avatars on itinerary event cards Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index eb31360c..7da7dc69 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -23,6 +23,9 @@ import { useProfile } from '@/hooks/useProfile'; import { ShareCardModal } from '@/components/ShareCardModal'; import { useConferenceTabs } from '@/hooks/useConferenceTabs'; import { TableView } from '@/components/TableView'; +import { useFriends } from '@/hooks/useFriends'; +import { useFriendsItineraries } from '@/hooks/useFriendsItineraries'; +import type { FriendInfo } from '@/lib/types'; const MapView = dynamic( () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), @@ -62,6 +65,25 @@ function ItineraryContent() { const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); const { profile } = useProfile(); + const { friends } = useFriends(); + const { friendItineraries } = useFriendsItineraries(friends); + + const friendsByEvent = useMemo(() => { + const map = new Map(); + for (const fi of friendItineraries) { + for (const eid of fi.eventIds) { + if (!map.has(eid)) map.set(eid, []); + map.get(eid)!.push({ + userId: fi.userId, + displayName: fi.displayName, + avatarUrl: fi.avatarUrl, + xHandle: fi.xHandle, + }); + } + } + return map; + }, [friendItineraries]); + const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); const [showClearConfirm, setShowClearConfirm] = useState(false); const [viewMode, setViewMode] = useState('list'); @@ -399,6 +421,7 @@ function ItineraryContent() { liveUrgency={passesNowFilter(event, getConferenceNow(activeConference)) ? 'green' : undefined} conference={activeConference} userLocation={userLocation} + friendsGoing={friendsByEvent.get(event.id)} />
From c09fc542f2ccf690317b17ef643de75dc4c10fc2 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 00:34:41 -0400 Subject: [PATCH 12/32] ui: show end times on share card flyer tiles Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardTemplate.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index c3a5a149..4b5bd661 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -124,7 +124,9 @@ const ShareCardTemplate = forwardRef(
{group.events.map((event) => { const flyerDataUrl = flyerImages?.get(event.id); - const timeDisplay = event.isAllDay ? 'All Day' : event.startTime || ''; + const timeDisplay = event.isAllDay + ? 'All Day' + : `${event.startTime || ''}${event.endTime ? ` - ${event.endTime}` : ''}`; return (
From fe05b7b4364d01d50a51472d63723c05ec980a77 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 00:35:57 -0400 Subject: [PATCH 13/32] ui: remove schedule conflict indicators from itinerary Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 7da7dc69..2ec1a17e 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -5,14 +5,13 @@ import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import dynamic from 'next/dynamic'; import { EventCard } from '@/components/EventCard'; -import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, Table, GripVertical, Eye, EyeOff, ChevronDown, MapPin } from 'lucide-react'; +import { ArrowLeft, Trash2, CalendarX, Share2, Map as MapIcon, List, Table, GripVertical, Eye, EyeOff, ChevronDown, MapPin } from 'lucide-react'; import clsx from 'clsx'; import { useEvents } from '@/hooks/useEvents'; import { useItinerary } from '@/hooks/useItinerary'; import { formatDateLabel } from '@/lib/utils'; -import { detectConflicts } from '@/lib/time-parse'; import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryReorder } from '@/lib/analytics'; import type { ETHDenverEvent } from '@/lib/types'; import { Loading } from '@/components/Loading'; @@ -136,8 +135,6 @@ function ItineraryContent() { [allItineraryEvents, activeConference] ); - const conflicts = useMemo(() => detectConflicts(itineraryEvents), [itineraryEvents]); - const dateGroups = useMemo(() => { const groupMap = new Map(); for (const event of itineraryEvents) { @@ -355,15 +352,6 @@ function ItineraryContent() { โ€” My Plan
- {conflicts.size > 0 && ( -
- -

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

-
- )} - {dateGroups.map((group) => (
@@ -376,7 +364,6 @@ function ItineraryContent() {
{group.events.map((event) => { - const hasConflict = conflicts.has(event.id); const dropIndicator = getDropIndicator(event.id); const isBeingDragged = dragId === event.id; @@ -392,15 +379,6 @@ function ItineraryContent() {
)} - {hasConflict && ( -
- - - Schedule conflict - -
- )} -
Date: Sun, 3 May 2026 01:07:45 -0400 Subject: [PATCH 14/32] feat: /plan page inherits view mode from main page Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 2ec1a17e..9a78cc0f 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -25,6 +25,7 @@ import { TableView } from '@/components/TableView'; import { useFriends } from '@/hooks/useFriends'; import { useFriendsItineraries } from '@/hooks/useFriendsItineraries'; import type { FriendInfo } from '@/lib/types'; +import { STORAGE_KEYS } from '@/lib/storage-keys'; const MapView = dynamic( () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), @@ -86,6 +87,12 @@ function ItineraryContent() { const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); const [showClearConfirm, setShowClearConfirm] = useState(false); const [viewMode, setViewMode] = useState('list'); + useEffect(() => { + const saved = localStorage.getItem(STORAGE_KEYS.VIEW_MODE); + if (saved === 'list' || saved === 'map' || saved === 'table') { + setViewMode(saved); + } + }, []); const [showShareCard, setShowShareCard] = useState(false); const [confOpen, setConfOpen] = useState(false); const confBtnRef = useRef(null); From f431eea1d69d64e76f77fc80a7d3f24f330c26b3 Mon Sep 17 00:00:00 2001 From: snackman Date: Sat, 2 May 2026 20:15:20 -0400 Subject: [PATCH 15/32] fix: onboarding wizard tag count uses 'any' match instead of 'all' The matchingEventCount preview in OnboardingWizard used .every() (AND mode) but the actual filter system defaults to tagMatchAll=false (OR/any mode). Changed to .some() so the preview count matches what users will actually see. Co-Authored-By: Claude Opus 4.6 --- src/components/OnboardingWizard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index 7d15254d..09be461e 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -112,7 +112,7 @@ export function OnboardingWizard({ if (selectedTags.size === 0) return conferenceEvents.length; const tagsArr = Array.from(selectedTags); return conferenceEvents.filter((e) => - tagsArr.every((t) => e.tags.includes(t)) + tagsArr.some((t) => e.tags.includes(t)) ).length; }, [conferenceEvents, selectedTags]); From ffcf299130631ffc3223fccf0e389730be144c84 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 01:08:21 -0400 Subject: [PATCH 16/32] ui: hide plan header title, back button navigates to active conference Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 9a78cc0f..6ce4b768 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -21,6 +21,7 @@ import { useDragReorder } from '@/hooks/useDragReorder'; import { useProfile } from '@/hooks/useProfile'; import { ShareCardModal } from '@/components/ShareCardModal'; import { useConferenceTabs } from '@/hooks/useConferenceTabs'; +import { getTabConfig } from '@/lib/conferences'; import { TableView } from '@/components/TableView'; import { useFriends } from '@/hooks/useFriends'; import { useFriendsItineraries } from '@/hooks/useFriendsItineraries'; @@ -207,18 +208,12 @@ function ItineraryContent() {
-

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

{/* Conference dropdown */} {conferences.length > 0 && ( From 9b467d42bb5adb8439b4ff828f440594d735c9b3 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 01:10:39 -0400 Subject: [PATCH 17/32] feat: add Google Calendar export button to /plan page Co-Authored-By: Claude Opus 4.6 --- src/app/api/google-calendar/export/route.ts | 60 +++++++ src/app/plan/page.tsx | 15 ++ src/components/GoogleCalendarButton.tsx | 93 +++++++++++ src/hooks/useGoogleCalendarExport.ts | 172 ++++++++++++++++++++ src/lib/analytics.ts | 6 + src/lib/calendar.ts | 2 +- src/lib/google-calendar.ts | 168 +++++++++++++++++++ src/types/google-identity.d.ts | 30 ++++ 8 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 src/app/api/google-calendar/export/route.ts create mode 100644 src/components/GoogleCalendarButton.tsx create mode 100644 src/hooks/useGoogleCalendarExport.ts create mode 100644 src/lib/google-calendar.ts create mode 100644 src/types/google-identity.d.ts diff --git a/src/app/api/google-calendar/export/route.ts b/src/app/api/google-calendar/export/route.ts new file mode 100644 index 00000000..40edfeeb --- /dev/null +++ b/src/app/api/google-calendar/export/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { parseBody } from '@/lib/api-validation'; +import { insertEvents } from '@/lib/google-calendar'; + +const GoogleCalendarExportSchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + events: z + .array( + z.object({ + id: z.string(), + name: z.string(), + date: z.string(), + dateISO: z.string(), + startTime: z.string(), + endTime: z.string(), + isAllDay: z.boolean(), + organizer: z.string(), + address: z.string(), + cost: z.string(), + isFree: z.boolean(), + vibe: z.string(), + tags: z.array(z.string()), + conference: z.string(), + link: z.string(), + hasFood: z.boolean(), + hasBar: z.boolean(), + note: z.string(), + timeOfDay: z.enum(['morning', 'afternoon', 'evening', 'night', 'all-day']), + lat: z.number().optional(), + lng: z.number().optional(), + matchedAddress: z.string().optional(), + isDuplicate: z.boolean().optional(), + isFeatured: z.boolean().optional(), + }) + ) + .min(1, 'At least one event is required') + .max(200, 'Too many events (max 200)'), + timezone: z.string().min(1, 'Timezone is required'), +}); + +export async function POST(request: NextRequest) { + try { + const { data, error } = await parseBody(request, GoogleCalendarExportSchema); + if (error) return error; + + const { accessToken, events, timezone } = data; + + const result = await insertEvents(accessToken, events, timezone); + + return NextResponse.json(result); + } catch (err) { + console.error('Google Calendar export error:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json( + { error: `Failed to export to Google Calendar: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 6ce4b768..85480d48 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -21,6 +21,7 @@ import { useDragReorder } from '@/hooks/useDragReorder'; import { useProfile } from '@/hooks/useProfile'; import { ShareCardModal } from '@/components/ShareCardModal'; import { useConferenceTabs } from '@/hooks/useConferenceTabs'; +import { GoogleCalendarButton } from '@/components/GoogleCalendarButton'; import { getTabConfig } from '@/lib/conferences'; import { TableView } from '@/components/TableView'; import { useFriends } from '@/hooks/useFriends'; @@ -159,6 +160,16 @@ function ItineraryContent() { })); }, [itineraryEvents]); + const conferenceTimezone = useMemo( + () => getTabConfig(activeConference, conferenceTabs).timezone, + [activeConference, conferenceTabs] + ); + + const exportableEvents = useMemo( + () => itineraryEvents.filter((e) => !hiddenEvents.has(e.id)), + [itineraryEvents, hiddenEvents] + ); + // 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)), @@ -298,6 +309,10 @@ function ItineraryContent() { > + )}
diff --git a/src/components/GoogleCalendarButton.tsx b/src/components/GoogleCalendarButton.tsx new file mode 100644 index 00000000..39991d22 --- /dev/null +++ b/src/components/GoogleCalendarButton.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useCallback } from 'react'; +import { CalendarPlus, Loader2, Check, AlertCircle } from 'lucide-react'; +import clsx from 'clsx'; +import { useGoogleCalendarExport } from '@/hooks/useGoogleCalendarExport'; +import type { ETHDenverEvent } from '@/lib/types'; + +interface GoogleCalendarButtonProps { + events: ETHDenverEvent[]; + timezone: string; +} + +export function GoogleCalendarButton({ events, timezone }: GoogleCalendarButtonProps) { + const { status, result, errorMessage, exportToGoogleCalendar, reset } = useGoogleCalendarExport(); + + // Auto-dismiss success state after 3 seconds + useEffect(() => { + if (status === 'success') { + const timer = setTimeout(reset, 3000); + return () => clearTimeout(timer); + } + }, [status, reset]); + + const handleClick = useCallback(() => { + if (status === 'authorizing' || status === 'exporting') return; + if (status === 'error') { + reset(); + // Small delay so the reset is visible before re-triggering + setTimeout(() => exportToGoogleCalendar(events, timezone), 100); + return; + } + exportToGoogleCalendar(events, timezone); + }, [status, events, timezone, exportToGoogleCalendar, reset]); + + if (events.length === 0) return null; + + const isLoading = status === 'authorizing' || status === 'exporting'; + + return ( + + ); +} diff --git a/src/hooks/useGoogleCalendarExport.ts b/src/hooks/useGoogleCalendarExport.ts new file mode 100644 index 00000000..62e5ad26 --- /dev/null +++ b/src/hooks/useGoogleCalendarExport.ts @@ -0,0 +1,172 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import type { ETHDenverEvent } from '@/lib/types'; +import type { InsertResult } from '@/lib/google-calendar'; +import { trackGoogleCalendarExport, trackGoogleCalendarError } from '@/lib/analytics'; + +/// + +export type GoogleCalendarStatus = 'idle' | 'authorizing' | 'exporting' | 'success' | 'error'; + +const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; +const GIS_SCRIPT_URL = 'https://accounts.google.com/gsi/client'; +const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.events'; + +/** Load the Google Identity Services script lazily */ +function loadGISScript(): Promise { + return new Promise((resolve, reject) => { + // Already loaded + if (typeof google !== 'undefined' && google.accounts?.oauth2) { + resolve(); + return; + } + + // Already loading + const existing = document.querySelector(`script[src="${GIS_SCRIPT_URL}"]`); + if (existing) { + existing.addEventListener('load', () => resolve()); + existing.addEventListener('error', () => reject(new Error('Failed to load Google Identity Services'))); + return; + } + + const script = document.createElement('script'); + script.src = GIS_SCRIPT_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load Google Identity Services')); + document.head.appendChild(script); + }); +} + +export function useGoogleCalendarExport() { + const [status, setStatus] = useState('idle'); + const [result, setResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const tokenClientRef = useRef(null); + + const exportToGoogleCalendar = useCallback( + async (events: ETHDenverEvent[], timezone: string) => { + if (!GOOGLE_CLIENT_ID) { + setStatus('error'); + setErrorMessage('Google Calendar integration is not configured'); + trackGoogleCalendarError('missing_client_id'); + return; + } + + if (events.length === 0) { + setStatus('error'); + setErrorMessage('No events to export'); + return; + } + + setStatus('authorizing'); + setErrorMessage(null); + setResult(null); + + try { + // Load GIS script lazily + await loadGISScript(); + } catch { + setStatus('error'); + setErrorMessage('Could not connect to Google. Please try again.'); + trackGoogleCalendarError('gis_load_failed'); + return; + } + + // Create token client if not already created + if (!tokenClientRef.current) { + tokenClientRef.current = google.accounts.oauth2.initTokenClient({ + client_id: GOOGLE_CLIENT_ID, + scope: CALENDAR_SCOPE, + callback: () => { + // Handled in the Promise below + }, + }); + } + + // Request access token โ€” wraps the callback in a promise + try { + const tokenResponse = await new Promise( + (resolve, reject) => { + // Re-initialize to set the correct callback for this request + tokenClientRef.current = google.accounts.oauth2.initTokenClient({ + client_id: GOOGLE_CLIENT_ID!, + scope: CALENDAR_SCOPE, + callback: (response) => { + if (response.error) { + reject(new Error(response.error_description || response.error)); + } else { + resolve(response); + } + }, + error_callback: (error) => { + reject(new Error(error.message || 'Authorization failed')); + }, + }); + + tokenClientRef.current!.requestAccessToken({ prompt: 'consent' }); + } + ); + + // Got the token โ€” now export events + setStatus('exporting'); + + const response = await fetch('/api/google-calendar/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: tokenResponse.access_token, + events, + timezone, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + const insertResult: InsertResult = await response.json(); + setResult(insertResult); + + if (insertResult.failed > 0 && insertResult.inserted === 0) { + setStatus('error'); + setErrorMessage(`Failed to add events. ${insertResult.errors[0]?.error || 'Please try again.'}`); + trackGoogleCalendarError('all_failed'); + } else { + setStatus('success'); + trackGoogleCalendarExport(insertResult.inserted, insertResult.failed); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Authorization failed'; + + // User closed popup or denied consent + if (msg.includes('popup_closed') || msg.includes('access_denied')) { + setStatus('idle'); + return; + } + + setStatus('error'); + setErrorMessage(msg); + trackGoogleCalendarError(msg); + } + }, + [] + ); + + const reset = useCallback(() => { + setStatus('idle'); + setResult(null); + setErrorMessage(null); + }, []); + + return { + status, + result, + errorMessage, + exportToGoogleCalendar, + reset, + }; +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index d5eed98b..e476580e 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -172,6 +172,12 @@ export const trackAdClick = (placement: string, url: string) => export const trackAdImpression = (placement: string) => track('ad_impression', { placement }); +// Google Calendar Export +export const trackGoogleCalendarExport = (inserted: number, failed: number) => + track('google_calendar_export', { inserted, failed }); +export const trackGoogleCalendarError = (error: string) => + track('google_calendar_error', { error }); + // A/B Testing (GA4 dual-tracking -- events also tracked in Supabase via /api/ab/track) export const trackABImpression = (testId: string, variantId: string) => track('ab_impression', { test_id: testId, variant_id: variantId }); diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts index 50dbe3c6..8d67d457 100644 --- a/src/lib/calendar.ts +++ b/src/lib/calendar.ts @@ -1,6 +1,6 @@ import type { ETHDenverEvent } from './types'; -function parseTime(t: string): { hour: number; minute: number } | null { +export function parseTime(t: string): { hour: number; minute: number } | null { if (!t) return null; const normalized = t.toLowerCase().trim(); if (normalized === 'all day' || normalized === 'tbd') return null; diff --git a/src/lib/google-calendar.ts b/src/lib/google-calendar.ts new file mode 100644 index 00000000..55059103 --- /dev/null +++ b/src/lib/google-calendar.ts @@ -0,0 +1,168 @@ +/** + * Server-side utility for inserting events into Google Calendar via REST API. + * Uses raw fetch โ€” no googleapis npm package needed. + */ + +import type { ETHDenverEvent } from './types'; +import { parseTime } from './calendar'; + +const CALENDAR_API = 'https://www.googleapis.com/calendar/v3/calendars/primary/events'; + +interface GoogleCalendarEvent { + summary: string; + description?: string; + location?: string; + start: { dateTime?: string; date?: string; timeZone?: string }; + end: { dateTime?: string; date?: string; timeZone?: string }; + source?: { title: string; url: string }; +} + +export interface InsertResult { + inserted: number; + failed: number; + errors: Array<{ eventName: string; error: string }>; +} + +/** + * Build a Google Calendar event object from an ETHDenverEvent. + */ +export function buildCalendarEvent( + event: ETHDenverEvent, + timezone: string +): GoogleCalendarEvent { + const calEvent: GoogleCalendarEvent = { + summary: event.name, + start: {}, + end: {}, + }; + + // Description + const details: string[] = []; + if (event.organizer) details.push(`Organized by: ${event.organizer}`); + if (event.link) details.push(`RSVP: ${event.link}`); + if (event.cost && !event.isFree) details.push(`Cost: ${event.cost}`); + if (event.isFree) details.push('Free event'); + if (event.tags?.length) details.push(`Tags: ${event.tags.join(', ')}`); + if (details.length > 0) calEvent.description = details.join('\n'); + + // Location + if (event.address) calEvent.location = event.address; + + // Source link + if (event.link) { + calEvent.source = { title: 'RSVP', url: event.link }; + } + + // Date/time handling + if (event.isAllDay && event.dateISO) { + // All-day event: use date-only format + calEvent.start = { date: event.dateISO }; + const nextDay = new Date(event.dateISO + 'T00:00:00'); + nextDay.setDate(nextDay.getDate() + 1); + calEvent.end = { date: nextDay.toISOString().slice(0, 10) }; + } else if (event.dateISO) { + const startTime = parseTime(event.startTime); + if (startTime) { + const startISO = formatDateTime(event.dateISO, startTime); + calEvent.start = { dateTime: startISO, timeZone: timezone }; + + const endTime = parseTime(event.endTime); + const endParsed = endTime || { + hour: startTime.hour + 1, + minute: startTime.minute, + }; + const endISO = formatDateTime(event.dateISO, endParsed); + calEvent.end = { dateTime: endISO, timeZone: timezone }; + } else { + // No parseable start time โ€” treat as all-day + calEvent.start = { date: event.dateISO }; + const nextDay = new Date(event.dateISO + 'T00:00:00'); + nextDay.setDate(nextDay.getDate() + 1); + calEvent.end = { date: nextDay.toISOString().slice(0, 10) }; + } + } + + return calEvent; +} + +/** + * Format a dateISO + time into an ISO 8601 datetime string (without timezone offset). + * Google Calendar will interpret using the provided timeZone. + */ +function formatDateTime( + dateISO: string, + time: { hour: number; minute: number } +): string { + const h = time.hour.toString().padStart(2, '0'); + const m = time.minute.toString().padStart(2, '0'); + return `${dateISO}T${h}:${m}:00`; +} + +/** + * Insert multiple events into the user's primary Google Calendar. + * Processes events sequentially to avoid rate limits. + */ +export async function insertEvents( + accessToken: string, + events: ETHDenverEvent[], + timezone: string +): Promise { + const result: InsertResult = { inserted: 0, failed: 0, errors: [] }; + + for (const event of events) { + const calEvent = buildCalendarEvent(event, timezone); + + try { + const response = await fetch(CALENDAR_API, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(calEvent), + }); + + if (response.ok) { + result.inserted++; + } else if (response.status === 429) { + // Rate limited โ€” wait and retry once + await delay(1000); + const retry = await fetch(CALENDAR_API, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(calEvent), + }); + + if (retry.ok) { + result.inserted++; + } else { + const errorBody = await retry.text().catch(() => 'Unknown error'); + result.failed++; + result.errors.push({ eventName: event.name, error: `Rate limited: ${errorBody}` }); + } + } else { + const errorBody = await response.text().catch(() => 'Unknown error'); + result.failed++; + result.errors.push({ + eventName: event.name, + error: `HTTP ${response.status}: ${errorBody}`, + }); + } + } catch (err) { + result.failed++; + result.errors.push({ + eventName: event.name, + error: err instanceof Error ? err.message : 'Network error', + }); + } + } + + return result; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/types/google-identity.d.ts b/src/types/google-identity.d.ts new file mode 100644 index 00000000..cf12cf25 --- /dev/null +++ b/src/types/google-identity.d.ts @@ -0,0 +1,30 @@ +/** + * Type declarations for Google Identity Services (GIS) OAuth2 token model. + * @see https://developers.google.com/identity/oauth2/web/reference/js-reference + */ + +declare namespace google.accounts.oauth2 { + interface TokenClientConfig { + client_id: string; + scope: string; + callback: (response: TokenResponse) => void; + error_callback?: (error: { type: string; message: string }) => void; + prompt?: '' | 'none' | 'consent' | 'select_account'; + } + + interface TokenResponse { + access_token: string; + expires_in: number; + scope: string; + token_type: string; + error?: string; + error_description?: string; + error_uri?: string; + } + + interface TokenClient { + requestAccessToken(overrideConfig?: { prompt?: string; scope?: string }): void; + } + + function initTokenClient(config: TokenClientConfig): TokenClient; +} From 152a9ae3434b8aff31b6ad5c4f86431cf6db7141 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 01:34:15 -0400 Subject: [PATCH 18/32] ui: icon-only calendar button, conference dropdown next to back arrow, logo branding - GoogleCalendarButton: icon-only with 18px CalendarPlus icon - Conference dropdown moved into left header group next to back arrow - Replace calendar emoji with plan.wtf logo - Change "plan.wtf" heading to "My Plan" - Empty state uses logo instead of CalendarX icon Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 107 ++++++++++++------------ src/components/GoogleCalendarButton.tsx | 48 +++-------- 2 files changed, 65 insertions(+), 90 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 85480d48..88e79f31 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import dynamic from 'next/dynamic'; import { EventCard } from '@/components/EventCard'; -import { ArrowLeft, Trash2, CalendarX, Share2, Map as MapIcon, List, Table, GripVertical, Eye, EyeOff, ChevronDown, MapPin } from 'lucide-react'; +import { ArrowLeft, Trash2, Share2, Map as MapIcon, List, Table, GripVertical, Eye, EyeOff, ChevronDown, MapPin } from 'lucide-react'; import clsx from 'clsx'; import { useEvents } from '@/hooks/useEvents'; import { useItinerary } from '@/hooks/useItinerary'; @@ -217,7 +217,7 @@ function ItineraryContent() { {/* Header */}
-
+
-
- {/* Conference dropdown */} - {conferences.length > 0 && ( -
- + {confOpen && ( + <> +
setConfOpen(false)} /> +
+ {conferences.map((conf) => ( + + ))} +
+ )} - style={confOpen ? { backgroundColor: 'var(--theme-accent-muted)' } : undefined} - > - - {activeConference ? activeConference.replace(/ 2026$/, '') : 'All'} - - - {confOpen && ( - <> -
setConfOpen(false)} /> -
- {conferences.map((conf) => ( - - ))} -
- - )} -
- )} +
+ )} +
{itineraryEvents.length > 0 && ( <> @@ -327,7 +327,7 @@ function ItineraryContent() { {/* Content */} {itineraryEvents.length === 0 ? (
- +

No events in your plan yet

Add events from the main page to build your schedule! @@ -364,9 +364,8 @@ function ItineraryContent() {

- ๐Ÿ“… - plan.wtf - โ€” My Plan + + My Plan
{dateGroups.map((group) => ( diff --git a/src/components/GoogleCalendarButton.tsx b/src/components/GoogleCalendarButton.tsx index 39991d22..495d7d8d 100644 --- a/src/components/GoogleCalendarButton.tsx +++ b/src/components/GoogleCalendarButton.tsx @@ -42,12 +42,12 @@ export function GoogleCalendarButton({ events, timezone }: GoogleCalendarButtonP onClick={handleClick} disabled={isLoading} className={clsx( - 'px-2 py-1 text-xs font-medium rounded transition-colors cursor-pointer inline-flex items-center gap-1', + 'p-1.5 rounded transition-colors cursor-pointer', status === 'success' - ? 'bg-emerald-500/20 text-emerald-400' + ? 'text-emerald-400' : status === 'error' - ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30' - : 'bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:text-[var(--theme-accent)] hover:border-[var(--theme-border-primary)]' + ? 'text-red-400 hover:text-red-300' + : 'text-[var(--theme-text-secondary)] hover:text-[var(--theme-accent)]' )} title={ status === 'error' && errorMessage @@ -55,38 +55,14 @@ export function GoogleCalendarButton({ events, timezone }: GoogleCalendarButtonP : 'Add all events to Google Calendar' } > - {status === 'authorizing' && ( - <> - - Connecting... - - )} - {status === 'exporting' && ( - <> - - Adding {events.length} event{events.length !== 1 ? 's' : ''}... - - )} - {status === 'success' && ( - <> - - - Added {result?.inserted ?? events.length} - {result?.failed ? ` (${result.failed} failed)` : ''}! - - - )} - {status === 'error' && ( - <> - - Retry - - )} - {status === 'idle' && ( - <> - - Google Cal - + {isLoading ? ( + + ) : status === 'success' ? ( + + ) : status === 'error' ? ( + + ) : ( + )} ); From 321a8b98ded902ead05daa4037f1237210019106 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 01:36:10 -0400 Subject: [PATCH 19/32] fix: initialize /plan view mode from localStorage immediately Use lazy initializer in useState instead of useEffect to avoid a flash of list view before switching to the saved mode. Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 88e79f31..524863f2 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -88,13 +88,12 @@ function ItineraryContent() { const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); const [showClearConfirm, setShowClearConfirm] = useState(false); - const [viewMode, setViewMode] = useState('list'); - useEffect(() => { + const [viewMode, setViewMode] = useState(() => { + if (typeof window === 'undefined') return 'list'; const saved = localStorage.getItem(STORAGE_KEYS.VIEW_MODE); - if (saved === 'list' || saved === 'map' || saved === 'table') { - setViewMode(saved); - } - }, []); + if (saved === 'list' || saved === 'map' || saved === 'table') return saved; + return 'list'; + }); const [showShareCard, setShowShareCard] = useState(false); const [confOpen, setConfOpen] = useState(false); const confBtnRef = useRef(null); From 62cbeb2331c1797b70486c6d21633f692f6a7066 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 02:04:32 -0400 Subject: [PATCH 20/32] fix: properly restore view mode from localStorage on /plan page Use useEffect + gate rendering until restored to avoid SSR hydration mismatch that was ignoring the saved view mode. Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 524863f2..344fd9c4 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -88,12 +88,15 @@ function ItineraryContent() { const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); const [showClearConfirm, setShowClearConfirm] = useState(false); - const [viewMode, setViewMode] = useState(() => { - if (typeof window === 'undefined') return 'list'; + const [viewMode, setViewMode] = useState('list'); + const [viewModeRestored, setViewModeRestored] = useState(false); + useEffect(() => { const saved = localStorage.getItem(STORAGE_KEYS.VIEW_MODE); - if (saved === 'list' || saved === 'map' || saved === 'table') return saved; - return 'list'; - }); + if (saved === 'list' || saved === 'map' || saved === 'table') { + setViewMode(saved); + } + setViewModeRestored(true); + }, []); const [showShareCard, setShowShareCard] = useState(false); const [confOpen, setConfOpen] = useState(false); const confBtnRef = useRef(null); @@ -203,7 +206,7 @@ function ItineraryContent() { }, [flatEventIds, setOrderedIds]); - if (loading) { + if (loading || !viewModeRestored) { return (
From ea62ecc25271c06cab5bad8291104d3144c29e19 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 02:14:41 -0400 Subject: [PATCH 21/32] ui: larger link/copy button icons to match star button size Co-Authored-By: Claude Opus 4.6 --- src/components/EventCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 3c4a5be1..31efb9e1 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -302,9 +302,9 @@ export function EventCard({ title="Copy link" > {copied ? ( - + ) : ( - + )} )} From 001237b27eb87b2b23667a6a47f7c8cebcc6fcbb Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 02:23:30 -0400 Subject: [PATCH 22/32] ui: larger name and time labels on share card tiles for readability Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardTemplate.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index 4b5bd661..48167049 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -131,10 +131,10 @@ const ShareCardTemplate = forwardRef( return (
{/* Text info above the image */} -
+
( {event.name}
{timeDisplay && ( -
+
{timeDisplay}
)} From 1fcfcff070ce32c6b8bac6dccfb73041b259938c Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 02:28:51 -0400 Subject: [PATCH 23/32] feat: add Flyers/Text Only toggle to share card modal - ShareCardTemplate supports 'gallery' and 'text' modes - Text mode renders the classic time + name + address list layout - Gallery mode renders flyer image grid (default) - Image pre-fetching is skipped in text mode for faster rendering - Toggle UI added between card title and preview Co-Authored-By: Claude Opus 4.6 --- src/components/ShareCardModal.tsx | 119 ++++++++++++++--------- src/components/ShareCardTemplate.tsx | 137 +++++++++++++++++++-------- 2 files changed, 167 insertions(+), 89 deletions(-) diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index 2b0b389c..499586a5 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -9,6 +9,7 @@ import { sortByStartTime } from '@/lib/time-parse'; import { trackShareCardOpen, trackShareCardCopy, trackShareCardDownload } from '@/lib/analytics'; import { imageCache } from './OGImage'; import ShareCardTemplate from './ShareCardTemplate'; +import type { ShareCardMode } from './ShareCardTemplate'; interface ShareCardModalProps { isOpen: boolean; @@ -37,6 +38,7 @@ export function ShareCardModal({ const [generating, setGenerating] = useState(false); const [copyStatus, setCopyStatus] = useState<'idle' | 'copying' | 'copied'>('idle'); const [flyerImages, setFlyerImages] = useState>(new Map()); + const [cardMode, setCardMode] = useState('gallery'); const cardRef = useRef(null); const debounceRef = useRef | null>(null); const trackedRef = useRef(false); @@ -95,59 +97,61 @@ export function ShareCardModal({ } setGenerating(true); try { - // --- Pre-fetch flyer images as data URLs --- - const imageMap = new Map(); - const ogUrls = new Map(); - const uncachedItems: { eventId: string; url: string }[] = []; + // --- Pre-fetch flyer images as data URLs (gallery mode only) --- + if (cardMode === 'gallery') { + const imageMap = new Map(); + const ogUrls = new Map(); + const uncachedItems: { eventId: string; url: string }[] = []; - for (const event of selectedEvents) { - if (!event.link) continue; - const cached = imageCache.get(event.link); - if (cached) { - ogUrls.set(event.id, cached); - } else if (cached === undefined) { - uncachedItems.push({ eventId: event.id, url: event.link }); + for (const event of selectedEvents) { + if (!event.link) continue; + const cached = imageCache.get(event.link); + if (cached) { + ogUrls.set(event.id, cached); + } else if (cached === undefined) { + uncachedItems.push({ eventId: event.id, url: event.link }); + } } - } - if (uncachedItems.length > 0) { - try { - const res = await fetch('/api/og', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items: uncachedItems.slice(0, 20) }), - }); - if (res.ok) { - const data = await res.json(); - const results = data.results as Record; - for (const [eventId, imgUrl] of Object.entries(results)) { - if (imgUrl) ogUrls.set(eventId, imgUrl); - const item = uncachedItems.find(i => i.eventId === eventId); - if (item) imageCache.set(item.url, imgUrl ?? null); + if (uncachedItems.length > 0) { + try { + const res = await fetch('/api/og', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items: uncachedItems.slice(0, 20) }), + }); + if (res.ok) { + const data = await res.json(); + const results = data.results as Record; + for (const [eventId, imgUrl] of Object.entries(results)) { + if (imgUrl) ogUrls.set(eventId, imgUrl); + const item = uncachedItems.find(i => i.eventId === eventId); + if (item) imageCache.set(item.url, imgUrl ?? null); + } } + } catch (err) { + console.error('Batch OG fetch failed:', err); } - } catch (err) { - console.error('Batch OG fetch failed:', err); } - } - const proxyFetches = Array.from(ogUrls.entries()).map(async ([eventId, imgUrl]) => { - try { - const proxyRes = await fetch(`/api/image-proxy?url=${encodeURIComponent(imgUrl)}`); - if (!proxyRes.ok) return; - const blob = await proxyRes.blob(); - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - imageMap.set(eventId, dataUrl); - } catch { /* skip failed images */ } - }); + const proxyFetches = Array.from(ogUrls.entries()).map(async ([eventId, imgUrl]) => { + try { + const proxyRes = await fetch(`/api/image-proxy?url=${encodeURIComponent(imgUrl)}`); + if (!proxyRes.ok) return; + const blob = await proxyRes.blob(); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + imageMap.set(eventId, dataUrl); + } catch { /* skip failed images */ } + }); - await Promise.all(proxyFetches); - setFlyerImages(imageMap); + await Promise.all(proxyFetches); + setFlyerImages(imageMap); + } await new Promise(r => setTimeout(r, 50)); @@ -163,7 +167,7 @@ export function ShareCardModal({ } finally { setGenerating(false); } - }, [selectedEvents]); + }, [selectedEvents, cardMode]); // Debounced preview regeneration when selection changes useEffect(() => { @@ -175,7 +179,7 @@ export function ShareCardModal({ return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [isOpen, selectedEvents, cardTitle, generatePreview]); + }, [isOpen, selectedEvents, cardTitle, cardMode, generatePreview]); // Track open event useEffect(() => { @@ -292,6 +296,26 @@ export function ShareCardModal({ />
+ {/* Card style toggle */} +
+ {([ + { mode: 'gallery' as const, label: 'Flyers' }, + { mode: 'text' as const, label: 'Text Only' }, + ]).map(({ mode, label }) => ( + + ))} +
+ {/* Preview area */}
{generating && !previewUrl ? ( @@ -432,6 +456,7 @@ export function ShareCardModal({ displayName={displayName} avatarUrl={avatarUrl} flyerImages={flyerImages} + mode={cardMode} /> , document.body diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index 48167049..49205802 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -5,18 +5,21 @@ import type { ETHDenverEvent } from '@/lib/types'; import { formatDateLabel } from '@/lib/utils'; import { sortByStartTime } from '@/lib/time-parse'; +export type ShareCardMode = 'gallery' | 'text'; + interface ShareCardTemplateProps { events: ETHDenverEvent[]; conferenceName: string; displayName: string | null; avatarUrl?: string | null; flyerImages?: Map; + mode?: ShareCardMode; } const MAX_EVENTS = 15; const ShareCardTemplate = forwardRef( - function ShareCardTemplate({ events, conferenceName, displayName, avatarUrl, flyerImages }, ref) { + function ShareCardTemplate({ events, conferenceName, displayName, avatarUrl, flyerImages, mode = 'gallery' }, ref) { const dateGroups = useMemo(() => { const groupMap = new Map(); for (const event of events) { @@ -107,10 +110,11 @@ const ShareCardTemplate = forwardRef( {/* Amber separator */}
- {/* Events block - gallery grid per day */} + {/* Events block */}
{truncatedGroups.map((group) => (
+ {/* Day header */}
( {group.label}
-
- {group.events.map((event) => { - const flyerDataUrl = flyerImages?.get(event.id); - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime || ''}${event.endTime ? ` - ${event.endTime}` : ''}`; + {mode === 'gallery' ? ( + /* Gallery grid */ +
+ {group.events.map((event) => { + const flyerDataUrl = flyerImages?.get(event.id); + const timeDisplay = event.isAllDay + ? 'All Day' + : `${event.startTime || ''}${event.endTime ? ` - ${event.endTime}` : ''}`; - return ( -
- {/* Text info above the image */} -
+ return ( +
+
+
+ {event.name} +
+ {timeDisplay && ( +
+ {timeDisplay} +
+ )} +
+ {flyerDataUrl ? ( + + ) : ( +
+ )} +
+ ); + })} +
+ ) : ( + /* Text-only list */ +
+ {group.events.map((event) => { + const timeDisplay = event.isAllDay + ? 'All Day' + : `${event.startTime || ''}${event.endTime ? ` - ${event.endTime}` : ''}`; + + return ( +
- {event.name} + {timeDisplay}
- {timeDisplay && ( -
- {timeDisplay} +
+
+ {event.name}
- )} + {event.address && ( +
+ {event.address} +
+ )} +
- {/* Image (square) or fallback */} - {flyerDataUrl ? ( - - ) : ( -
- )} -
- ); - })} -
+ ); + })} +
+ )}
))} From 7b69e4fc2a3cb1df99acedd5a311bca05f7ecd84 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 02:45:59 -0400 Subject: [PATCH 24/32] fix: persist view mode change on /plan page to localStorage Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 344fd9c4..312e617d 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -88,7 +88,11 @@ function ItineraryContent() { const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); const [showClearConfirm, setShowClearConfirm] = useState(false); - const [viewMode, setViewMode] = useState('list'); + const [viewMode, setViewModeState] = useState('list'); + const setViewMode = useCallback((mode: ItineraryViewMode) => { + setViewModeState(mode); + localStorage.setItem(STORAGE_KEYS.VIEW_MODE, mode); + }, []); const [viewModeRestored, setViewModeRestored] = useState(false); useEffect(() => { const saved = localStorage.getItem(STORAGE_KEYS.VIEW_MODE); From d3c8cd05e3639168f4980987d7b6a36f357f34e4 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 02:59:03 -0400 Subject: [PATCH 25/32] ui: match view toggle order on /plan to main view (Map/List/Table) Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 312e617d..db936686 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -286,9 +286,9 @@ function ItineraryContent() { {/* View toggle */}
{([ + { mode: 'map' as const, icon: MapIcon, label: 'Map' }, { mode: 'list' as const, icon: List, label: 'List' }, { mode: 'table' as const, icon: Table, label: 'Table' }, - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, ]).map(({ mode, icon: Icon, label }) => ( + ))} +
+ )} + {!authLoading && ( + user ? ( + {}} activeConference={activeConference} /> + ) : ( + + ) + )} +
+
+ {/* Row 2: Conference selector + share/calendar */} +
+
{conferences.length > 0 && (
-
- {itineraryEvents.length > 0 && ( - <> - {/* View toggle */} -
- {([ - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, - { mode: 'list' as const, icon: List, label: 'List' }, - { mode: 'table' as const, icon: Table, label: 'Table' }, - ]).map(({ mode, icon: Icon, label }) => ( - - ))} -
- - - - - )} -
+ {itineraryEvents.length > 0 && ( +
+ + +
+ )}
+ setShowAuth(false)} /> + {/* Check-in result toast */} {checkInResult && ( From b978105ce01815d1a0ce046f0bb6b9213417dac5 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 03:42:38 -0400 Subject: [PATCH 29/32] ui: use shared Header component on /plan with sponsors ticker + filter bar Replace custom two-row header with the same Header component used on conference pages. Add children slot to Header for share/calendar buttons. Wire up SponsorsTicker, FilterBar, useFilters, and useConferenceData on the /plan page so filters apply to itinerary events. Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 254 +++++++++++++++++--------------------- src/components/Header.tsx | 4 + 2 files changed, 115 insertions(+), 143 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 7a714ae6..b1dfb6c9 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -5,27 +5,30 @@ import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import dynamic from 'next/dynamic'; import { EventCard } from '@/components/EventCard'; -import { ArrowLeft, Trash2, Share2, Map as MapIcon, List, Table, GripVertical, Eye, EyeOff, ChevronDown, MapPin, User } from 'lucide-react'; -import clsx from 'clsx'; +import { Trash2, Share2, GripVertical, Eye, EyeOff } from 'lucide-react'; import { useEvents } from '@/hooks/useEvents'; import { useItinerary } from '@/hooks/useItinerary'; import { formatDateLabel } from '@/lib/utils'; -import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryReorder } from '@/lib/analytics'; +import { trackItineraryClear, trackItineraryReorder } 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 { useAuth } from '@/contexts/AuthContext'; -import { AuthModal, UserMenu } from '@/components/AuthModal'; -import { trackAuthPrompt } from '@/lib/analytics'; +import { Header } from '@/components/Header'; +import { FilterBar } from '@/components/FilterBar'; +import { SponsorsTicker } from '@/components/SponsorsTicker'; import { ShareCardModal } from '@/components/ShareCardModal'; import { useConferenceTabs } from '@/hooks/useConferenceTabs'; +import { useFilters } from '@/hooks/useFilters'; +import { useAdminConfig } from '@/hooks/useAdminConfig'; +import { useConferenceData } from '@/hooks/useConferenceData'; import { GoogleCalendarButton } from '@/components/GoogleCalendarButton'; import { getTabConfig } from '@/lib/conferences'; +import { passesNowFilter, applyFilters, computeTagCounts, getConferenceNow } from '@/lib/filters'; +import { resolveItemVariants, getVisitorId } from '@/lib/ab-testing'; import { TableView } from '@/components/TableView'; import { useFriends } from '@/hooks/useFriends'; import { useFriendsItineraries } from '@/hooks/useFriendsItineraries'; @@ -70,8 +73,7 @@ function ItineraryContent() { const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); const { profile } = useProfile(); - const { user, loading: authLoading } = useAuth(); - const [showAuth, setShowAuth] = useState(false); + const { config } = useAdminConfig(); const { friends } = useFriends(); const { friendItineraries } = useFriendsItineraries(friends); @@ -107,8 +109,6 @@ function ItineraryContent() { setViewModeRestored(true); }, []); const [showShareCard, setShowShareCard] = useState(false); - const [confOpen, setConfOpen] = useState(false); - const confBtnRef = useRef(null); const captureRef = useRef(null); // User location for distance display @@ -139,20 +139,61 @@ function ItineraryContent() { ); const searchParams = useSearchParams(); const confParam = searchParams.get('conf') || ''; - const [activeConference, setActiveConference] = useState(confParam); - // Auto-select conference: respect URL param, then fall back to first conference + // Filters + const { + filters, + setFilter, + setConference, + setDateTimeRange, + toggleVibe, + toggleNowMode, + toggleTagMatchAll, + toggleFriend, + clearFilters, + activeFilterCount, + } = useFilters(confParam || undefined, conferenceTabs); + + const activeConference = filters.conference; + + // Auto-select conference from URL param or first itinerary conference useEffect(() => { if (confParam && conferences.includes(confParam)) { - setActiveConference(confParam); + setConference(confParam); } else if (conferences.length > 0 && !activeConference) { - setActiveConference(conferences[0]); + setConference(conferences[0]); } }, [conferences, confParam]); - const itineraryEvents = useMemo( - () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), - [allItineraryEvents, activeConference] + const { + availableConferences, + availableTypes, + availableVibes, + friendsForFilter, + selectedFriendEventIds, + } = useConferenceData({ + events: allItineraryEvents, + filters, + itinerary, + friends, + friendItineraries, + checkInUsersByEvent: new Map(), + setFilter, + }); + + // Apply filters to itinerary events + const itineraryEvents = useMemo(() => { + const confFiltered = allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference); + return applyFilters(confFiltered, filters, itinerary, filters.nowMode ? getConferenceNow(filters.conference).getTime() : undefined, selectedFriendEventIds); + }, [allItineraryEvents, activeConference, filters, itinerary, selectedFriendEventIds]); + + const tagCounts = useMemo(() => computeTagCounts(itineraryEvents), [itineraryEvents]); + + // Sponsors + const visitorId = useMemo(() => typeof window !== 'undefined' ? getVisitorId() : '', []); + const resolvedSponsors = useMemo( + () => resolveItemVariants(config?.sponsors || [], visitorId), + [config?.sponsors, visitorId] ); const dateGroups = useMemo(() => { @@ -225,133 +266,60 @@ function ItineraryContent() { return (
- {/* Header */} -
- {/* Row 1: Logo + view toggle + profile */} -
-
- {}} + activeConference={activeConference} + > + {itineraryEvents.length > 0 && ( + <> +
-
- {itineraryEvents.length > 0 && ( -
- {([ - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, - { mode: 'list' as const, icon: List, label: 'List' }, - { mode: 'table' as const, icon: Table, label: 'Table' }, - ]).map(({ mode, icon: Icon, label }) => ( - - ))} -
- )} - {!authLoading && ( - user ? ( - {}} activeConference={activeConference} /> - ) : ( - - ) - )} -
-
- {/* Row 2: Conference selector + share/calendar */} -
-
- {conferences.length > 0 && ( -
- - {confOpen && ( - <> -
setConfOpen(false)} /> -
- {conferences.map((conf) => ( - - ))} -
- - )} -
- )} -
- {itineraryEvents.length > 0 && ( -
- - -
- )} -
-
+ + + + + )} + + + - setShowAuth(false)} /> + setFilter('searchQuery', query)} + eventCount={itineraryEvents.length} + conferenceTabs={conferenceTabs} + itineraryCount={itineraryEvents.length} + onItineraryToggle={() => {}} + /> {/* Check-in result toast */} {checkInResult && ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fc642f72..3fb7e94b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -21,6 +21,7 @@ interface HeaderProps { hasNearbyLiveEvents?: boolean; onBulkCheckIn?: () => void; checkInLoading?: boolean; + children?: React.ReactNode; } export function Header({ @@ -35,6 +36,7 @@ export function Header({ hasNearbyLiveEvents, onBulkCheckIn, checkInLoading, + children, }: HeaderProps) { const { user, loading } = useAuth(); const [showAuth, setShowAuth] = useState(false); @@ -69,6 +71,8 @@ export function Header({ )} + {children} + {/* Auth / Profile โ€” far right */} {!loading && ( user ? ( From 7936110b288e000968bd6777398f4ffe62c7b1cd Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 03:50:53 -0400 Subject: [PATCH 30/32] ui: replace plan button with share + calendar buttons in /plan filter bar Add trailingButtons prop to FilterBar to allow custom buttons in place of the default My Plan button. On /plan, show share and Google Calendar export buttons there instead. Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 37 ++++++++++++++++++------------------ src/components/FilterBar.tsx | 30 ++++++++++++++++------------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index b1dfb6c9..f1c482b9 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -273,24 +273,7 @@ function ItineraryContent() { itinerary={itinerary} onOpenFriends={() => {}} activeConference={activeConference} - > - {itineraryEvents.length > 0 && ( - <> - - - - )} - + /> {}} + trailingButtons={ + itineraryEvents.length > 0 ? ( +
+ + +
+ ) : undefined + } /> {/* Check-in result toast */} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index 17527046..ef18f558 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -39,6 +39,7 @@ interface FilterBarProps { conferenceTabs?: TabConfig[]; itineraryCount: number; onItineraryToggle: () => void; + trailingButtons?: React.ReactNode; } export function FilterBar({ @@ -65,6 +66,7 @@ export function FilterBar({ conferenceTabs, itineraryCount, onItineraryToggle, + trailingButtons, }: FilterBarProps) { const [expanded, setExpanded] = useState(false); const [confOpen, setConfOpen] = useState(false); @@ -238,19 +240,21 @@ export function FilterBar({ )} - {/* My Plan button */} - + {/* Trailing buttons: plan button or custom (e.g. share/calendar on /plan) */} + {trailingButtons || ( + + )}
{/* Search bar โ€” mobile only (desktop is inline in the row above) */} From c5751e3cb5db5a6398c7ac6940f3b72042f2266a Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 03:55:00 -0400 Subject: [PATCH 31/32] ui: remove My Plan logo+label row above list cards Co-Authored-By: Claude Opus 4.6 --- src/app/plan/page.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index f1c482b9..f9713c5e 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -367,11 +367,6 @@ function ItineraryContent() { ) : (
-
- - My Plan -
- {dateGroups.map((group) => (
From 96d51ebdf9e1dd3aed3dec5f3f75e9f5f2d079c8 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 3 May 2026 19:05:52 -0400 Subject: [PATCH 32/32] refactor: use EventCard in map popup with compact mode Add compact prop to EventCard that tightens padding, uses icon-only tags, compact reactions, and skips impression tracking. Map popup now renders EventCard directly instead of a separate SingleEventContent component, ensuring consistent layout between list and map views. Co-Authored-By: Claude Opus 4.6 --- src/components/EventCard.tsx | 13 +- src/components/EventPopup.tsx | 238 ++++------------------------------ 2 files changed, 32 insertions(+), 219 deletions(-) diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 31efb9e1..5498fb96 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -37,6 +37,8 @@ interface EventCardProps { liveUrgency?: 'green' | 'yellow' | 'red'; userLocation?: { lat: number; lng: number } | null; onOpenLightbox?: (imageUrl: string, rsvpUrl?: string) => void; + /** Compact mode for map popups โ€” smaller text, no impression tracking */ + compact?: boolean; } function FriendsGoingModal({ @@ -136,6 +138,7 @@ export function EventCard({ liveUrgency, userLocation, onOpenLightbox, + compact, }: EventCardProps) { const [showFriendsModal, setShowFriendsModal] = useState(false); const [showCheckedInModal, setShowCheckedInModal] = useState(false); @@ -147,7 +150,7 @@ export function EventCard({ // Track featured event impressions via IntersectionObserver useEffect(() => { - if (!event.isFeatured) return; + if (!event.isFeatured || compact) return; const el = cardRef.current; if (!el || featuredImpressionTracked.current) return; @@ -174,6 +177,7 @@ export function EventCard({ // Track event impressions via IntersectionObserver (all events in list view) useEffect(() => { + if (compact) return; const el = cardRef.current; if (!el || eventImpressionTracked.current) return; @@ -213,7 +217,7 @@ export function EventCard({ : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; return ( -
-

+

{event.isFeatured && ( Featured )} @@ -373,7 +377,7 @@ export function EventCard({ {event.tags.length > 0 && (
{event.tags.map((tag) => ( - + ))}
)} @@ -390,6 +394,7 @@ export function EventCard({ eventId={event.id} reactions={reactions} onToggle={onToggleReaction} + compact={compact} /> )} diff --git a/src/components/EventPopup.tsx b/src/components/EventPopup.tsx index 1eb67251..6f633ee7 100644 --- a/src/components/EventPopup.tsx +++ b/src/components/EventPopup.tsx @@ -1,18 +1,15 @@ 'use client'; import { Popup } from 'react-map-gl/mapbox'; -import { X, Calendar, MapPin, MapPinCheck, Loader2 } from 'lucide-react'; +import { X, Calendar, MapPin } from 'lucide-react'; import type { ETHDenverEvent, ReactionEmoji, FriendInfo } from '@/lib/types'; -import { trackEventClick } from '@/lib/analytics'; -import { trackEvent } from '@/lib/event-tracking'; import { shortenAddress } from '@/lib/utils'; import { StarButton } from './StarButton'; import { FriendAvatarStack } from './FriendAvatarStack'; import { AddressLink } from './AddressLink'; import { TagBadge } from './TagBadge'; import { OGImage } from './OGImage'; -import { EmojiReactions } from './EmojiReactions'; -import { CommentSection } from './CommentSection'; +import { EventCard } from './EventCard'; interface EventPopupProps { event: ETHDenverEvent; @@ -51,198 +48,6 @@ interface MultiEventPopupProps { commentCounts?: Map; } -function formatFriendsText(friends: FriendInfo[]): string { - const names = friends.map((f) => f.displayName.split(' ')[0]); - if (names.length === 1) return names[0]; - if (names.length === 2) return `${names[0]} & ${names[1]}`; - return `${names[0]}, ${names[1]} +${names.length - 2} more`; -} - -function FriendsRow({ friends }: { friends: FriendInfo[] }) { - if (!friends || friends.length === 0) return null; - return ( -
- -
- ); -} - -function CheckedInFriendsRow({ friends }: { friends: FriendInfo[] }) { - if (!friends || friends.length === 0) return null; - return ( -
- - - {formatFriendsText(friends)} checked in - -
- ); -} - -function SingleEventContent({ - event, - isInItinerary = false, - onItineraryToggle, - friendsCount, - friendsGoing, - checkedInFriends, - checkInCount, - reactions, - onToggleReaction, - commentCount, - conference, - onCheckIn, - checkInLoading, - liveUrgency, -}: { - event: ETHDenverEvent; - isInItinerary?: boolean; - onItineraryToggle?: (eventId: string) => void; - friendsCount?: number; - friendsGoing?: FriendInfo[]; - checkedInFriends?: FriendInfo[]; - checkInCount?: number; - reactions?: { emoji: ReactionEmoji; count: number; reacted: boolean }[]; - onToggleReaction?: (eventId: string, emoji: ReactionEmoji) => void; - commentCount?: number; - conference?: string; - onCheckIn?: (eventId: string) => void; - checkInLoading?: boolean; - liveUrgency?: 'green' | 'yellow' | 'red'; -}) { - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; - - return ( -
- {/* Left: cover image */} - {event.link && } - - {/* Right: event details */} -
- {/* Top row: Name + Star */} - - - {/* Date + Time */} -
-

- - {event.date} ยท {timeDisplay} -

- {(checkInCount ?? 0) > 0 && ( - - {checkInCount} - - )} -
- - {/* Address */} - {event.address && ( - - - {shortenAddress(event.address)} - - )} - - {/* Tags row (icons only) */} - {event.tags.length > 0 && ( -
- {event.tags.map((tag) => ( - - ))} -
- )} - - {/* Friends going */} - {friendsGoing && } - - {/* Friends checked in (green) */} - {checkedInFriends && } - - {/* Note */} - {event.note && ( -

{event.note}

- )} - - {/* Emoji reactions + Comments inline */} -
- {onToggleReaction && ( - - )} - -
- - {/* Check In button (live events only) */} - {liveUrgency && onCheckIn && ( - - )} -
-
- ); -} - export function EventPopup({ event, latitude, @@ -273,22 +78,25 @@ export function EventPopup({ offset={16} className={`map-popup${event.isFeatured ? ' map-popup-featured' : ''}`} > - +
+ +
); } @@ -305,6 +113,8 @@ export function MultiEventPopup({ friendsByEvent, checkedInFriendsByEvent, checkInCounts, + reactionsByEvent, + onToggleReaction, commentCounts, }: MultiEventPopupProps) { return ( @@ -395,8 +205,6 @@ export function MultiEventPopup({ ))}

)} - {eventFriends && } - {eventCheckedIn && }
);