diff --git a/__tests__/api/events.test.js b/__tests__/api/events.test.js index f31738e..11f248e 100644 --- a/__tests__/api/events.test.js +++ b/__tests__/api/events.test.js @@ -146,4 +146,66 @@ describe('GET /api/events', () => { error: expect.stringContaining('Rate limit exceeded') })); }); + + describe('segmentId filtering', () => { + it('should forward a single segmentId to Ticketmaster', async () => { + nock.cleanAll(); + nock('https://app.ticketmaster.com') + .get('/discovery/v2/events.json') + .query(params => params.segmentId === 'KZFzniwnSyZfZ7v7nJ' && params.city === 'Denver') + .reply(200, mockEvents); + + const request = new NextRequest('http://localhost:3000/api/events?city=Denver&segmentId=KZFzniwnSyZfZ7v7nJ'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data._embedded.events).toHaveLength(2); + }); + + it('should forward comma-separated segmentIds to Ticketmaster', async () => { + nock.cleanAll(); + nock('https://app.ticketmaster.com') + .get('/discovery/v2/events.json') + .query(params => params.segmentId === 'KZFzniwnSyZfZ7v7nJ,KZFzniwnSyZfZ7v7na' && params.city === 'Denver') + .reply(200, mockEvents); + + const request = new NextRequest('http://localhost:3000/api/events?city=Denver&segmentId=KZFzniwnSyZfZ7v7nJ,KZFzniwnSyZfZ7v7na'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data._embedded.events).toHaveLength(2); + }); + + it('should not include segmentId when not provided', async () => { + nock.cleanAll(); + nock('https://app.ticketmaster.com') + .get('/discovery/v2/events.json') + .query(params => !params.segmentId && params.city === 'Denver') + .reply(200, mockEvents); + + const request = new NextRequest('http://localhost:3000/api/events?city=Denver'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data._embedded.events).toHaveLength(2); + }); + + it('should forward segmentId with keyword search', async () => { + nock.cleanAll(); + nock('https://app.ticketmaster.com') + .get('/discovery/v2/events.json') + .query(params => params.segmentId === 'KZFzniwnSyZfZ7v7nE' && params.keyword === 'Taylor Swift') + .reply(200, mockEvents); + + const request = new NextRequest('http://localhost:3000/api/events?keyword=Taylor+Swift&segmentId=KZFzniwnSyZfZ7v7nE'); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data._embedded.events).toHaveLength(2); + }); + }); }); diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 414edd2..4853926 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -34,6 +34,11 @@ export async function GET(request: NextRequest) { sort: 'date,asc' }; + const segmentId = searchParams.get('segmentId'); + if (segmentId) { + params.segmentId = segmentId; + } + if (city) { params.city = city; params.radius = RADIUS; diff --git a/app/components/AttractionList.tsx b/app/components/AttractionList.tsx index 3a3ca74..e875b2f 100644 --- a/app/components/AttractionList.tsx +++ b/app/components/AttractionList.tsx @@ -27,40 +27,47 @@ interface AttractionListProps { const AttractionList: FC = ({ attractions, onSelect }) => { if (!attractions.length) { return ( -
- No attractions found +
+ + + +

No attractions found

+

Try a different search term

); } return ( -
+
{attractions.map(attraction => (
onSelect(attraction.id)} > -
- {attraction.images?.[0] && ( - {attraction.name} - )} -
-

- {attraction.name} -

-
- {attraction.type.charAt(0).toUpperCase() + attraction.type.slice(1)} -
- {attraction.classifications?.[0]?.segment && ( -
- {attraction.classifications[0].segment.name} -
- )} + {attraction.images?.[0] ? ( + {attraction.name} + ) : ( +
+ + + +
+ )} +
+

+ {attraction.name} +

+
+ {attraction.classifications?.[0]?.segment?.name || + (attraction.type.charAt(0).toUpperCase() + attraction.type.slice(1))}
diff --git a/app/components/EventDetails.tsx b/app/components/EventDetails.tsx index e0846be..2ec06b8 100644 --- a/app/components/EventDetails.tsx +++ b/app/components/EventDetails.tsx @@ -64,10 +64,9 @@ interface Attraction { const createGoogleCalendarUrl = (event: EventDetailsData) => { if (!event.dates.start.localDate) return ''; - // Build a Date in the local timezone using components to avoid UTC parsing issues const startDate = buildLocalEventDate(event.dates.start.localDate, event.dates.start.localTime); const endDate = new Date(startDate); - endDate.setHours(startDate.getHours() + 3); // Default to 3 hour duration + endDate.setHours(startDate.getHours() + 3); const venue = event._embedded?.venues?.[0]; const location = venue ? `${venue.name}, ${venue.city.name}, ${venue.state.name}` : ''; @@ -127,12 +126,13 @@ const EventDetails: React.FC = ({ eventId }) => { if (isLoading) { return ( -
-
-
-
-
-
+
+
+
+
+
+
+
); @@ -140,8 +140,8 @@ const EventDetails: React.FC = ({ eventId }) => { if (error) { return ( -
-
{error}
+
+

{error}

); } @@ -151,106 +151,113 @@ const EventDetails: React.FC = ({ eventId }) => { } return ( -
-
-
- {eventDetails?.classifications?.[0]?.segment?.name && ( - - )} -

- {eventDetails?.name} -

-
+
+ {/* Title */} +
+ {eventDetails?.classifications?.[0]?.segment?.name && ( + + )} +

+ {eventDetails?.name} +

-
-
+ {/* Date & Time */} +
+ + + + {formatDisplayDate(buildLocalEventDate(eventDetails.dates.start.localDate, eventDetails.dates.start.localTime))} -
-
+ {' '}·{' '} {eventDetails.dates.start.localTime ? formatDisplayTime(buildLocalEventDate(eventDetails.dates.start.localDate, eventDetails.dates.start.localTime)) : 'Time TBA' } -
+
+ + {/* Description */} {eventDetails?.description && ( -

+

{eventDetails.description}

)} -
- {eventDetails?._embedded?.venues?.[0] && ( -
- - - + + {/* Venue */} + {eventDetails?._embedded?.venues?.[0] && ( +
+ + + + + + {eventDetails._embedded.venues[0].name},{' '} + {eventDetails._embedded.venues[0].city.name},{' '} + {eventDetails._embedded.venues[0].state.name} + +
+ )} + + {/* Action Buttons */} + + SpotifyListen on Spotify + )} -
- {eventDetails?.attractions?.[0]?.externalLinks?.spotify?.[0]?.url && ( - - - - - Listen on Spotify - - )} - {eventDetails?._embedded?.attractions?.[0]?.externalLinks?.youtube?.[0]?.url && ( - - - + {eventDetails?._embedded?.attractions?.[0]?.externalLinks?.youtube?.[0]?.url && ( + + + - Watch on YouTube + YouTubeWatch on YouTube - )} + )} + + + + + CalendarAdd to Calendar + + {eventDetails?._embedded?.attractions?.[0]?.externalLinks?.homepage?.[0]?.url && ( - - + + - Add to Calendar + Website - {eventDetails?._embedded?.attractions?.[0]?.externalLinks?.homepage?.[0]?.url && ( - - - - - Visit Website - - )} -
+ )}
); diff --git a/app/globals.css b/app/globals.css index f1d8c73..f0e7de3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1 +1,56 @@ @import "tailwindcss"; + +@theme { + --color-brand-50: #f5f3ff; + --color-brand-100: #ede9fe; + --color-brand-200: #ddd6fe; + --color-brand-300: #c4b5fd; + --color-brand-400: #a78bfa; + --color-brand-500: #8b5cf6; + --color-brand-600: #7c3aed; + --color-brand-700: #6d28d9; + --color-brand-800: #5b21b6; + --color-brand-900: #4c1d95; + + --color-surface-50: #fafafa; + --color-surface-100: #f4f4f5; + --color-surface-200: #e4e4e7; + --color-surface-300: #d4d4d8; + --color-surface-400: #a1a1aa; + --color-surface-500: #71717a; + --color-surface-600: #52525b; + --color-surface-700: #3f3f46; + --color-surface-800: #27272a; + --color-surface-900: #18181b; + --color-surface-950: #09090b; +} + +@keyframes slide-up { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.animate-slide-up { + animation: slide-up 0.3s ease-out; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out; +} diff --git a/app/layout.tsx b/app/layout.tsx index c3916b9..97868f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; import './globals.css'; +const inter = Inter({ subsets: ['latin'] }); + export const metadata: Metadata = { title: 'MuseMeter', icons: { @@ -20,7 +23,7 @@ export default function RootLayout({ }) { return ( - {children} + {children} ); } diff --git a/app/lib/events.ts b/app/lib/events.ts index 46de2fd..a24b47f 100644 --- a/app/lib/events.ts +++ b/app/lib/events.ts @@ -7,13 +7,15 @@ export interface SearchParams { size: number; searchType: 'city' | 'keyword' | 'attraction'; searchValue: string; + segments?: string[]; } export const getEvents = async ({ page = 0, size = Number(defaultEventsPerPage), searchType = 'city', - searchValue = '' + searchValue = '', + segments }: SearchParams): Promise> => { try { let url = `/api/events?page=${page}&size=${size}`; @@ -24,6 +26,9 @@ export const getEvents = async ({ url += `&${searchType}=${searchValue}`; } } + if (segments?.length) { + url += `&segmentId=${segments.join(',')}`; + } const response = await fetch(url); const data: ApiResponse = await response.json(); @@ -87,10 +92,13 @@ export const getAttractionDetails = async (attractionId: string): Promise> => { +export const getEventsByAttraction = async (attractionId: string, page = 0, size = Number(defaultEventsPerPage), segments?: string[]): Promise> => { try { const attraction = await getAttractionDetails(attractionId); - const url = `/api/events?page=${page}&size=${size}&keyword=${encodeURIComponent(attraction.name)}`; + let url = `/api/events?page=${page}&size=${size}&keyword=${encodeURIComponent(attraction.name)}`; + if (segments?.length) { + url += `&segmentId=${segments.join(',')}`; + } const response = await fetch(url); const data = await response.json(); diff --git a/app/page.tsx b/app/page.tsx index 28fa34f..0d719c5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,6 +13,18 @@ import { buildLocalEventDate, formatDisplayDate, formatDisplayTime } from './uti const pageSize = parseInt(process.env.NEXT_PUBLIC_DEFAULT_EVENTS_PER_PAGE || '') || 10; +const SEGMENT_IDS: Record = { + Music: 'KZFzniwnSyZfZ7v7nJ', + Sports: 'KZFzniwnSyZfZ7v7nE', + 'Arts & Theatre': 'KZFzniwnSyZfZ7v7na', +}; + +const SEGMENT_ICON_NAMES: Record = { + Music: 'music', + Sports: 'sports', + 'Arts & Theatre': 'arts', +}; + export default function Home() { const [events, setEvents] = useState([]); const [attractions, setAttractions] = useState([]); @@ -21,29 +33,28 @@ export default function Home() { const [currentPage, setCurrentPage] = useState(0); const [isSearchingAttractions, setIsSearchingAttractions] = useState(false); const [showEventDetails, setShowEventDetails] = useState(false); - const [lastScrollPosition, setLastScrollPosition] = useState(0); const [lastClickedId, setLastClickedId] = useState(null); const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [activeSegments, setActiveSegments] = useState>(() => { + if (typeof window === 'undefined') return new Set(); + try { + const saved = localStorage.getItem('activeSegments'); + return saved ? new Set(JSON.parse(saved)) : new Set(); + } catch { + return new Set(); + } + }); const handleEventClick = (eventId: string) => { setLastClickedId(eventId); - setLastScrollPosition(window.scrollY); setSelectedEventId(eventId); setShowEventDetails(true); - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); }; const handleCloseDetails = () => { setShowEventDetails(false); setSelectedEventId(null); - // Restore last scroll position - window.scrollTo({ - top: lastScrollPosition, - behavior: 'smooth' - }); }; const [totalPages, setTotalPages] = useState(0); @@ -55,12 +66,12 @@ export default function Home() { return savedCity || 'Boulder'; }); - // Add debounced values const debouncedSearchValue = useDebounce(searchValue); async function fetchAttractions() { try { setError(null); + setIsLoading(true); debug('Fetching attractions:', { keyword: debouncedSearchValue, page: currentPage, @@ -78,12 +89,15 @@ export default function Home() { } catch (error) { console.error(error); setError('Failed to fetch attractions. Please try again.'); + } finally { + setIsLoading(false); } } async function fetchEvents() { try { setError(null); + setIsLoading(true); debug('Fetching events:', { searchType, searchValue: debouncedSearchValue, @@ -94,7 +108,8 @@ export default function Home() { page: currentPage, size: pageSize, searchType, - searchValue: debouncedSearchValue + searchValue: debouncedSearchValue, + segments: activeSegmentIds.length ? activeSegmentIds : undefined }); if (!data._embedded?.events || data._embedded.events.length === 0) { setError(`No events found ${searchType === 'city' ? 'in this city' : 'for this search'}. Please try a different ${searchType === 'city' ? 'location' : 'keyword'}.`); @@ -107,6 +122,8 @@ export default function Home() { } catch (error) { console.error(error); setError('Failed to fetch events. Please try again.'); + } finally { + setIsLoading(false); } } @@ -119,17 +136,18 @@ export default function Home() { }); if (debouncedSearchValue) { - if (searchType === 'attraction') { + if (searchType === 'attraction' && !selectedAttractionId) { setIsSearchingAttractions(true); fetchAttractions(); - setEvents([]); // Clear events while searching attractions - } else { + setEvents([]); + } else if (searchType !== 'attraction') { setIsSearchingAttractions(false); - setAttractions([]); // Clear attractions while searching events + setAttractions([]); fetchEvents(); } } - }, [searchType, debouncedSearchValue, currentPage]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchType, debouncedSearchValue, currentPage, activeSegments]); const handleAttractionSelect = async (attractionId: string) => { setSelectedAttractionId(attractionId); @@ -137,10 +155,11 @@ export default function Home() { setCurrentPage(0); try { + setIsLoading(true); const attraction = await getAttractionDetails(attractionId); debug('Selected attraction:', attraction); - const data = await getEventsByAttraction(attractionId); + const data = await getEventsByAttraction(attractionId, 0, pageSize, activeSegmentIds.length ? activeSegmentIds : undefined); if (!data._embedded?.events || data._embedded.events.length === 0) { setError('No upcoming events found for this artist/venue.'); setEvents([]); @@ -150,13 +169,14 @@ export default function Home() { setTotalPages(data.page?.totalPages || 1); } - // Update the search value to show what we're searching for setSearchValue(attraction.name); } catch (error) { console.error(error); setError('Failed to load events for this artist/venue. Please try again.'); setIsSearchingAttractions(true); setSelectedAttractionId(null); + } finally { + setIsLoading(false); } }; @@ -172,7 +192,7 @@ export default function Home() { const handleSearchTypeChange = (newType: 'city' | 'attraction') => { setError(null); setSearchType(newType); - setSearchValue(''); // Clear the search text + setSearchValue(''); setCurrentPage(0); }; @@ -187,107 +207,192 @@ export default function Home() { if (searchType === 'city') { localStorage.setItem('city', newValue); } else if (searchType === 'attraction') { - // Reset any selected attraction when starting a new search setSelectedAttractionId(null); } - setCurrentPage(0); // Reset to first page on search change + setCurrentPage(0); }; + const handleSegmentToggle = (segmentLabel: string) => { + setActiveSegments(prev => { + const next = new Set(prev); + if (next.has(segmentLabel)) { + next.delete(segmentLabel); + } else { + next.add(segmentLabel); + } + localStorage.setItem('activeSegments', JSON.stringify([...next])); + return next; + }); + setCurrentPage(0); + }; + + const activeSegmentIds = [...activeSegments].map(label => SEGMENT_IDS[label]).filter(Boolean); + return ( -
-
-

+
+
+ {/* Header */} +
{selectedAttractionId ? ( -
- Events for {searchValue} +
+

+ Events for {searchValue} +

- ) : 'MuseMeter'} -

+ ) : ( + <> +

+ MuseMeter +

+

+ Discover live events near you +

+ + )} +
- {/* Show event details when an event is selected */} - {showEventDetails && selectedEventId && ( -
- + +
+
+ {/* Search Input */} +
+ + - - + handleSearchValueChange(e.target.value)} + className="w-full pl-10 pr-4 py-3 rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100 placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-shadow" + /> +
+ + {/* Segment Filter Toggles */} +
+ {Object.keys(SEGMENT_IDS).map((label) => { + const isActive = activeSegments.has(label); + return ( + + ); + })} +
)} + {/* Error Banner */} {error && ( -
- {error} +
+ + + +

{error}

)} -
- - handleSearchValueChange(e.target.value)} - className="px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:focus:ring-indigo-400" + + {/* Content */} + {isSearchingAttractions ? ( + -
-
- {isSearchingAttractions ? ( - - ) : ( -
    - {events.map((event: Event, index: number) => ( -
  • -
    -
    -
    - + ) : isLoading ? ( + /* Skeleton Cards */ +
    + {[...Array(4)].map((_, i) => ( +
    +
    +
    +
    +
    + ))} +
    + ) : ( + /* Event Cards */ +
    + {events.map((event: Event, index: number) => ( +
    handleEventClick(event.id)} + className={`bg-white dark:bg-surface-900 rounded-xl p-4 shadow-sm cursor-pointer + hover:shadow-md hover:ring-1 hover:ring-brand-200 dark:hover:ring-brand-800 transition-all + ${event.id === lastClickedId ? 'ring-1 ring-brand-300 dark:ring-brand-700' : ''}`} + > +
    + {event.classifications?.[0]?.segment?.name && ( + + )} +

    + {event.name} +

    +
    +
    + + + + + + {event._embedded?.venues?.[0]?.name || 'Unknown Venue'} {event._embedded?.venues?.[0] && ( -
    + + {' '}·{' '} {[ event._embedded.venues[0].city?.name, event._embedded.venues[0].state?.stateCode, @@ -297,62 +402,88 @@ export default function Home() { ] .filter(Boolean) .join(', ')} -
    +
    )} -
    -
    -
    - {formatDisplayDate(buildLocalEventDate(event.dates.start.localDate, event.dates.start.localTime))} -
    -
    - {event.dates.start.localTime ? - formatDisplayTime(buildLocalEventDate(event.dates.start.localDate, event.dates.start.localTime)) - : 'Time TBA' - } -
    -
    -
    -
    -
    - - - - - {event._embedded?.venues?.[0]?.name || 'Unknown Venue'} -
    -
    +
    -
  • + + {formatDisplayDate(buildLocalEventDate(event.dates.start.localDate, event.dates.start.localTime))} + {event.dates.start.localTime && ( + + {formatDisplayTime(buildLocalEventDate(event.dates.start.localDate, event.dates.start.localTime))} + + )} + {!event.dates.start.localTime && ( + Time TBA + )} + +
))} - - )} -
- {/* Pagination controls */} +
+ )} + + {/* Pagination */} {(events.length > 0 || attractions.length > 0) && ( -
+
- - Page {currentPage + 1} of {totalPages} + + {currentPage + 1} / {totalPages}
)} - {selectedEventId && }
+ + {/* Modal Backdrop + Sheet */} + {showEventDetails && selectedEventId && ( +
+ {/* Backdrop */} +
+ {/* Modal */} +
e.stopPropagation()} + > + {/* Drag handle (mobile) */} +
+
+
+ {/* Close button */} + +
+ +
+
+
+ )}
); }