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/api/image-proxy/route.ts b/src/app/api/image-proxy/route.ts
new file mode 100644
index 00000000..0949b59b
--- /dev/null
+++ b/src/app/api/image-proxy/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ const url = request.nextUrl.searchParams.get('url');
+ if (!url) {
+ return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
+ }
+
+ try {
+ new URL(url);
+ } catch {
+ return NextResponse.json({ error: 'Invalid url' }, { status: 400 });
+ }
+
+ try {
+ const res = await fetch(url, {
+ signal: AbortSignal.timeout(10000),
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; sheeets-bot/1.0)',
+ },
+ });
+
+ if (!res.ok) {
+ return NextResponse.json({ error: 'Upstream fetch failed' }, { status: 502 });
+ }
+
+ const contentType = res.headers.get('content-type') || 'image/jpeg';
+ const buffer = await res.arrayBuffer();
+
+ return new NextResponse(buffer, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=86400, s-maxage=86400',
+ },
+ });
+ } catch {
+ return NextResponse.json({ error: 'Proxy fetch failed' }, { status: 502 });
+ }
+}
diff --git a/src/app/itinerary/page.tsx b/src/app/itinerary/page.tsx
index a9ed7a7c..7710673d 100644
--- a/src/app/itinerary/page.tsx
+++ b/src/app/itinerary/page.tsx
@@ -1,529 +1,5 @@
-'use client';
+import { redirect } from 'next/navigation';
-import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
-import Link from 'next/link';
-import dynamic from 'next/dynamic';
-import { AddressLink } from '@/components/AddressLink';
-import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Star, ExternalLink, Eye, EyeOff, MapPinCheck, Loader2 } from 'lucide-react';
-import clsx from 'clsx';
-import { useEvents } from '@/hooks/useEvents';
-import { useItinerary } from '@/hooks/useItinerary';
-import { useAuth } from '@/contexts/AuthContext';
-import { supabase } from '@/lib/supabase';
-import { VIBE_COLORS } from '@/lib/tags';
-import { formatDateLabel } from '@/lib/utils';
-import { sortByStartTime, detectConflicts } from '@/lib/time-parse';
-import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryShareLink, trackItineraryReorder } 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: () => (
-
- ),
- }
-);
-
-type ItineraryViewMode = 'list' | 'map';
-
-function generateShortCode(): string {
- const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
- let code = '';
- for (let i = 0; i < 8; i++) {
- code += chars[Math.floor(Math.random() * chars.length)];
- }
- return code;
-}
-
-function CheckInToast({ result, onDismiss }: { result: { ok: boolean; message: string }; onDismiss: () => void }) {
- useEffect(() => {
- const timer = setTimeout(onDismiss, 3000);
- return () => clearTimeout(timer);
- }, [onDismiss]);
-
- return (
-
- {result.message}
-
- );
-}
-
-export default function ItineraryPage() {
- const { events, loading } = useEvents();
- const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary();
- const { user } = useAuth();
- const { profile } = useProfile();
- const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn();
- const [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
- const allItineraryEvents = useMemo(
- () => events.filter((e) => itinerary.has(e.id)),
- [events, itinerary]
- );
- const conferences = useMemo(
- () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))],
- [allItineraryEvents]
- );
- const [activeConference, setActiveConference] = useState('');
-
- // Auto-select first conference when data loads
- useMemo(() => {
- if (conferences.length > 0 && !activeConference) {
- setActiveConference(conferences[0]);
- }
- }, [conferences, activeConference]);
-
- const itineraryEvents = useMemo(
- () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference),
- [allItineraryEvents, activeConference]
- );
-
- const conflicts = useMemo(() => detectConflicts(itineraryEvents), [itineraryEvents]);
-
- 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.sort(sortByStartTime),
- }));
- }, [itineraryEvents]);
-
- // Flat ordered list of all event IDs across date groups (for drag reorder)
- const flatEventIds = useMemo(
- () => dateGroups.flatMap((g) => g.events.map((e) => e.id)),
- [dateGroups]
- );
-
- const handleReorder = useCallback(
- (orderedIds: string[]) => {
- trackItineraryReorder();
- // The drag reorder gives us the visible (conference-filtered) IDs in new order.
- // Preserve IDs from other conferences that aren't visible.
- const allIds = [...itinerary];
- const visibleIdSet = new Set(flatEventIds);
- const otherIds = allIds.filter((id) => !visibleIdSet.has(id));
- reorderItinerary([...orderedIds, ...otherIds]);
- },
- [itinerary, flatEventIds, reorderItinerary]
- );
-
- const {
- setOrderedIds,
- registerItemRef,
- getDragHandleProps,
- getItemProps,
- getDropIndicator,
- dragId,
- } = useDragReorder({ onReorder: handleReorder });
-
- // Keep ordered IDs in sync
- useEffect(() => {
- setOrderedIds(flatEventIds);
- }, [flatEventIds, setOrderedIds]);
-
- const handleShareLink = useCallback(async () => {
- if (itineraryEvents.length === 0) return;
- setShareStatus('sharing');
- try {
- const shortCode = generateShortCode();
- const eventIds = itineraryEvents
- .filter((e) => !hiddenEvents.has(e.id))
- .map((e) => e.id);
- const { error } = await supabase.from('shared_itineraries').insert({
- short_code: shortCode,
- event_ids: eventIds,
- created_by: user?.id ?? null,
- });
- if (error) {
- console.error('Failed to create share link:', error);
- setShareStatus('idle');
- return;
- }
- const shareUrl = `${window.location.origin}/itinerary/s/${shortCode}`;
- await navigator.clipboard.writeText(shareUrl);
- setShareStatus('copied');
- setTimeout(() => setShareStatus('idle'), 2500);
- } catch (err) {
- console.error('Share failed:', err);
- setShareStatus('idle');
- }
- }, [itineraryEvents, hiddenEvents, user]);
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return (
-
- {/* Header */}
-
-
- {/* Check-in result toast */}
- {checkInResult && (
-
- )}
-
- {/* Content */}
- {itineraryEvents.length === 0 ? (
-
-
-
No events in your itinerary yet
-
- Star events from the main page to build your schedule!
-
-
- Browse Events
-
-
- ) : viewMode === 'map' ? (
-
-
-
- ) : (
-
-
-
- ๐
- sheeets.xyz
- โ My Itinerary
-
-
- {conflicts.size > 0 && (
-
-
-
- {conflicts.size} event{conflicts.size !== 1 ? 's' : ''} with schedule conflicts
-
-
- )}
-
- {dateGroups.map((group) => (
-
-
-
-
- {group.label}
-
-
-
-
-
- {group.events.map((event) => {
- const hasConflict = conflicts.has(event.id);
- const vibeColor = VIBE_COLORS[event.vibe] || VIBE_COLORS['default'];
- const timeDisplay = event.isAllDay
- ? 'All Day'
- : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`;
- const dropIndicator = getDropIndicator(event.id);
- const isBeingDragged = dragId === event.id;
-
- return (
-
registerItemRef(event.id, el)}
- {...getItemProps(event.id)}
- className="relative"
- >
- {/* Drop indicator - above */}
- {dropIndicator.showAbove && (
-
- )}
-
-
- {hasConflict && (
-
-
-
- Schedule conflict
-
-
- )}
-
-
-
-
-
-
- {event.name}
-
-
- {passesNowFilter(event, getConferenceNow(activeConference)) && (
-
- )}
- {event.link && (
-
-
-
- )}
-
-
-
-
-
- {event.organizer && (
-
By {event.organizer}
- )}
-
-
{timeDisplay}
-
- {event.address && (
-
- {event.address}
-
- )}
-
-
- {event.vibe && (
-
- {event.vibe}
-
- )}
- {event.isFree && (
-
- FREE
-
- )}
-
-
-
- {/* Drop indicator - below */}
- {dropIndicator.showBelow && (
-
- )}
-
- );
- })}
-
-
- ))}
-
-
- sheeets.xyz โ side event guide
-
-
-
- {/* Clear button */}
-
- {showClearConfirm ? (
-
- Clear all events?
-
-
-
- ) : (
-
- )}
-
-
- )}
-
-
setShowShareCard(false)}
- events={itineraryEvents}
- conferenceName={activeConference || 'Itinerary'}
- displayName={profile?.display_name ?? null}
- avatarUrl={profile?.avatar_url}
- hiddenEventIds={hiddenEvents}
- />
-
- );
+export default function ItineraryRedirect() {
+ redirect('/plan');
}
diff --git a/src/app/itinerary/s/[code]/page.tsx b/src/app/itinerary/s/[code]/page.tsx
index 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: () => (
-
- ),
- }
-);
-
-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..f9713c5e
--- /dev/null
+++ b/src/app/plan/page.tsx
@@ -0,0 +1,507 @@
+'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 { 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, trackItineraryReorder } from '@/lib/analytics';
+import type { ETHDenverEvent } from '@/lib/types';
+import { Loading } from '@/components/Loading';
+import { useEventCheckIn } from '@/hooks/useEventCheckIn';
+import { useDragReorder } from '@/hooks/useDragReorder';
+import { useProfile } from '@/hooks/useProfile';
+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';
+import type { FriendInfo } from '@/lib/types';
+import { STORAGE_KEYS } from '@/lib/storage-keys';
+
+const MapView = dynamic(
+ () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ }
+);
+
+type ItineraryViewMode = 'list' | 'map' | 'table';
+
+
+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 { tabs: conferenceTabs } = useConferenceTabs();
+ const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary();
+
+ const { profile } = useProfile();
+ const { config } = useAdminConfig();
+ 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, 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);
+ if (saved === 'list' || saved === 'map' || saved === 'table') {
+ setViewMode(saved);
+ }
+ setViewModeRestored(true);
+ }, []);
+ const [showShareCard, setShowShareCard] = useState(false);
+ 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]));
+ 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') || '';
+
+ // 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)) {
+ setConference(confParam);
+ } else if (conferences.length > 0 && !activeConference) {
+ setConference(conferences[0]);
+ }
+ }, [conferences, confParam]);
+
+ 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(() => {
+ 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]);
+
+ 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)),
+ [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]);
+
+
+ if (loading || !viewModeRestored) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+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: () => (
+
+ ),
+ }
+);
+
+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/EventCard.tsx b/src/components/EventCard.tsx
index 3c4a5be1..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
)}
@@ -302,9 +306,9 @@ export function EventCard({
title="Copy link"
>
{copied ? (
-
+
) : (
-
+
)}
)}
@@ -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 */}
-
-
-
- {event.organizer && (
-
{event.organizer}
-)}
-
- {onItineraryToggle && (
-
- )}
-
-
- {/* 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 &&
}
);
diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx
index 00c170b1..ef18f558 100644
--- a/src/components/FilterBar.tsx
+++ b/src/components/FilterBar.tsx
@@ -39,7 +39,7 @@ interface FilterBarProps {
conferenceTabs?: TabConfig[];
itineraryCount: number;
onItineraryToggle: () => void;
- isItineraryActive: boolean;
+ trailingButtons?: React.ReactNode;
}
export function FilterBar({
@@ -66,7 +66,7 @@ export function FilterBar({
conferenceTabs,
itineraryCount,
onItineraryToggle,
- isItineraryActive,
+ trailingButtons,
}: FilterBarProps) {
const [expanded, setExpanded] = useState(false);
const [confOpen, setConfOpen] = useState(false);
@@ -240,30 +240,21 @@ export function FilterBar({
)}
- {/* Itinerary toggle */}
-
+ {/* Trailing buttons: plan button or custom (e.g. share/calendar on /plan) */}
+ {trailingButtons || (
+
+ )}
{/* Search bar โ mobile only (desktop is inline in the row above) */}
diff --git a/src/components/GoogleCalendarButton.tsx b/src/components/GoogleCalendarButton.tsx
new file mode 100644
index 00000000..495d7d8d
--- /dev/null
+++ b/src/components/GoogleCalendarButton.tsx
@@ -0,0 +1,69 @@
+'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/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 ? (
diff --git a/src/components/ItineraryPanel.tsx b/src/components/ItineraryPanel.tsx
index 6c67d295..0a9585f4 100644
--- a/src/components/ItineraryPanel.tsx
+++ b/src/components/ItineraryPanel.tsx
@@ -166,7 +166,7 @@ export function ItineraryPanel({
{/* Header */}
- My Itinerary{' '}
+ My Plan{' '}
({itineraryEvents.length} event
{itineraryEvents.length !== 1 ? 's' : ''})
@@ -176,7 +176,7 @@ export function ItineraryPanel({
{itineraryEvents.length > 0 && (
<>
- No events in your itinerary yet
+ No events in your plan yet
Tap + on any event to add it to your plan!
@@ -242,8 +242,8 @@ export function ItineraryPanel({
{/* Branding header (visible in PNG) */}
๐
- sheeets.xyz
- โ My Itinerary
+ plan.wtf
+ โ My Plan
{/* Conflict warning */}
@@ -400,7 +400,7 @@ export function ItineraryPanel({
{/* Footer in PNG */}
- sheeets.xyz โ side event guide
+ plan.wtf โ side event guide
@@ -432,7 +432,7 @@ export function ItineraryPanel({
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-[var(--theme-bg-secondary)] hover:bg-[var(--theme-bg-tertiary)] active:bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-secondary)] active:text-[var(--theme-text-secondary)] rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
- Clear Itinerary
+ Clear Plan
)}
@@ -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}
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]);
diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx
index 5935198b..499586a5 100644
--- a/src/components/ShareCardModal.tsx
+++ b/src/components/ShareCardModal.tsx
@@ -7,7 +7,9 @@ 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';
+import type { ShareCardMode } from './ShareCardTemplate';
interface ShareCardModalProps {
isOpen: boolean;
@@ -35,6 +37,8 @@ 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