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: () => ( -
-
Loading map...
-
- ), - } -); - -type ItineraryViewMode = 'list' | 'map'; - -function generateShortCode(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let code = ''; - for (let i = 0; i < 8; i++) { - code += chars[Math.floor(Math.random() * chars.length)]; - } - return code; -} - -function CheckInToast({ result, onDismiss }: { result: { ok: boolean; message: string }; onDismiss: () => void }) { - useEffect(() => { - const timer = setTimeout(onDismiss, 3000); - return () => clearTimeout(timer); - }, [onDismiss]); - - return ( -
- {result.message} -
- ); -} - -export default function ItineraryPage() { - const { events, loading } = useEvents(); - const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); - const { user } = useAuth(); - const { profile } = useProfile(); - const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); - const [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 */} -
-
-
- - - -

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

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

No events in your itinerary yet

-

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

- - Browse Events - -
- ) : viewMode === 'map' ? ( -
- -
- ) : ( -
-
-
- ๐Ÿ“… - sheeets.xyz - โ€” My Itinerary -
- - {conflicts.size > 0 && ( -
- -

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

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

- {group.label} -

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

- {event.name} -

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

By {event.organizer}

- )} - -

{timeDisplay}

- - {event.address && ( - - {event.address} - - )} - -
- {event.vibe && ( - - {event.vibe} - - )} - {event.isFree && ( - - FREE - - )} -
-
- - {/* Drop indicator - below */} - {dropIndicator.showBelow && ( -
- )} -
- ); - })} -
-
- ))} - -
- sheeets.xyz โ€” side event guide -
-
- - {/* Clear button */} -
- {showClearConfirm ? ( -
- Clear all events? - - -
- ) : ( - - )} -
-
- )} - - setShowShareCard(false)} - events={itineraryEvents} - conferenceName={activeConference || 'Itinerary'} - displayName={profile?.display_name ?? null} - avatarUrl={profile?.avatar_url} - hiddenEventIds={hiddenEvents} - /> -
- ); +export default function ItineraryRedirect() { + redirect('/plan'); } diff --git a/src/app/itinerary/s/[code]/page.tsx b/src/app/itinerary/s/[code]/page.tsx index 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..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: () => ( +
+
Loading map...
+
+ ), + } +); + +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 ( +
+
{}} + activeConference={activeConference} + /> + + + + setFilter('searchQuery', query)} + eventCount={itineraryEvents.length} + conferenceTabs={conferenceTabs} + itineraryCount={itineraryEvents.length} + onItineraryToggle={() => {}} + trailingButtons={ + itineraryEvents.length > 0 ? ( +
+ + +
+ ) : undefined + } + /> + + {/* 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' ? ( +
+ +
+ ) : viewMode === 'table' ? ( +
+ +
+ ) : ( +
+
+ {dateGroups.map((group) => ( +
+
+
+

+ {group.label} +

+
+
+ +
+ {group.events.map((event) => { + 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 && ( +
+ )} + +
+
+ +
+
+ checkInToEvent(eventId, event.name)} + checkInLoading={checkInLoading} + liveUrgency={passesNowFilter(event, getConferenceNow(activeConference)) ? 'green' : undefined} + conference={activeConference} + userLocation={userLocation} + friendsGoing={friendsByEvent.get(event.id)} + /> +
+
+ + {/* 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/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 */} - - - {/* 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>(new Map()); + const [cardMode, setCardMode] = useState('gallery'); const cardRef = useRef(null); const debounceRef = useRef | null>(null); const trackedRef = useRef(false); @@ -93,6 +97,64 @@ export function ShareCardModal({ } setGenerating(true); try { + // --- 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 }); + } + } + + 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 +167,7 @@ export function ShareCardModal({ } finally { setGenerating(false); } - }, [selectedEvents.length]); + }, [selectedEvents, cardMode]); // Debounced preview regeneration when selection changes useEffect(() => { @@ -117,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(() => { @@ -234,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 ? ( @@ -373,6 +455,8 @@ export function ShareCardModal({ conferenceName={cardTitle || conferenceName} displayName={displayName} avatarUrl={avatarUrl} + flyerImages={flyerImages} + mode={cardMode} /> , document.body diff --git a/src/components/ShareCardTemplate.tsx b/src/components/ShareCardTemplate.tsx index a1e05d11..49205802 100644 --- a/src/components/ShareCardTemplate.tsx +++ b/src/components/ShareCardTemplate.tsx @@ -5,17 +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 }, ref) { + function ShareCardTemplate({ events, conferenceName, displayName, avatarUrl, flyerImages, mode = 'gallery' }, ref) { const dateGroups = useMemo(() => { const groupMap = new Map(); for (const event of events) { @@ -32,10 +36,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 +57,6 @@ const ShareCardTemplate = forwardRef( }, { groups: [], count: 0, isTruncated: false } ); - return { truncatedGroups: result.groups, truncated: result.isTruncated, @@ -78,40 +79,19 @@ const ShareCardTemplate = forwardRef( }} > {/* Header */} -
+
{avatarUrl ? ( ) : displayName ? (
{displayName.charAt(0).toUpperCase()} @@ -119,13 +99,8 @@ const ShareCardTemplate = forwardRef( ) : null}
{conferenceName} @@ -133,14 +108,7 @@ const ShareCardTemplate = forwardRef(
{/* Amber separator */} -
+
{/* Events block */}
@@ -149,90 +117,119 @@ const ShareCardTemplate = forwardRef( {/* 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 */} -
+ {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 ( +
+
+
+ {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} -
- {event.address && (
- {event.address} + {timeDisplay} +
+
+
+ {event.name} +
+ {event.address && ( +
+ {event.address} +
+ )}
- )} -
-
- ); - })} +
+ ); + })} +
+ )}
))} - {/* Truncation notice */} {truncated && remainingCount > 0 && (
... and {remainingCount} more event{remainingCount !== 1 ? 's' : ''} @@ -241,22 +238,11 @@ const ShareCardTemplate = forwardRef(
{/* Footer */} -
+
plan.wtf
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' }); }, []); 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; +}