From 8dd06563301a3bae45b3d42439b5f6816befdacc Mon Sep 17 00:00:00 2001 From: rikoeldon <106416799+rikoeldon@users.noreply.github.com> Date: Wed, 27 Aug 2025 23:04:27 -0500 Subject: [PATCH 1/2] added timer --- frontend/pages/results/[planId].tsx | 33 +++++++++++++----- frontend/pages/vote/[planId].tsx | 53 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/frontend/pages/results/[planId].tsx b/frontend/pages/results/[planId].tsx index 86ee3b3..5dc7215 100644 --- a/frontend/pages/results/[planId].tsx +++ b/frontend/pages/results/[planId].tsx @@ -68,20 +68,37 @@ function useResults(planId: string) { const response = await fetch(`/api/plans/${planId}/results`); if (!response.ok) throw new Error('Failed to fetch results'); const data = await response.json(); - + + // Determine winning event with tie-breaking and optional preselected winner + const eventsArr: any[] = Array.isArray(data.events) ? data.events : []; + let selectedWinner: any | undefined; + try { + if (typeof window !== 'undefined') { + const pre = sessionStorage.getItem(`choosy:selectedWinner:${planId}`); + if (pre) { + selectedWinner = eventsArr.find(e => e.id === pre); + } + } + } catch { /* ignore */ } + if (!selectedWinner && eventsArr.length > 0) { + const maxVotes = Math.max(...eventsArr.map(e => e.votes || 0)); + const top = eventsArr.filter(e => (e.votes || 0) === maxVotes); + selectedWinner = top[Math.floor(Math.random() * top.length)] || eventsArr[0]; + } + return { planId, topic: data.plan.topic, groupSize: data.plan.group_size, zip: data.plan.zip_code, - winningEvent: data.events.length > 0 ? { - id: data.events[0].id, - name: data.events[0].name, - votes: data.events[0].votes, - image_url: data.events[0].image_url, + winningEvent: selectedWinner ? { + id: selectedWinner.id, + name: selectedWinner.name, + votes: selectedWinner.votes, + image_url: selectedWinner.image_url, hours: "2 hours", contact: { phone: '(555) 123-4567' }, - needs_reservation: data.events[0].needs_reservation ?? true + needs_reservation: selectedWinner.needs_reservation ?? true } : undefined, plan: { userName: data.plan.host_name, @@ -664,4 +681,4 @@ export default function Results() { /> ); -} \ No newline at end of file +} diff --git a/frontend/pages/vote/[planId].tsx b/frontend/pages/vote/[planId].tsx index 08172fd..0782fd2 100644 --- a/frontend/pages/vote/[planId].tsx +++ b/frontend/pages/vote/[planId].tsx @@ -67,6 +67,9 @@ const VotePage: React.FC = () => { // Voting status and progress const [completedVoters, setCompletedVoters] = useState(0); const [expectedVoters, setExpectedVoters] = useState(1); + // Voting session countdown timer + const [timeLeft, setTimeLeft] = useState(null); + const [timerStarted, setTimerStarted] = useState(false); // Join/leave active voters for live presence useEffect(() => { @@ -223,6 +226,11 @@ const VotePage: React.FC = () => { }); setDeck(events); + // Fixed 4-minute timer (240 seconds); initialize only once per session + if (!timerStarted) { + setTimeLeft(240); + setTimerStarted(true); + } } else { console.error('Failed to fetch events:', res.status, res.statusText); } @@ -233,6 +241,43 @@ const VotePage: React.FC = () => { // Authentication is handled by useAuth hook }, [planId]); + // Countdown effect and auto-finalize when time runs out + useEffect(() => { + if (!timerStarted || timeLeft === null) return; + if (timeLeft <= 0) { + (async () => { + try { + const res = await apiFetch(`/api/plans/${planId}/results`); + let chosenId: string | null = null; + if (res.ok) { + const data = await res.json(); + const events = Array.isArray(data?.events) ? data.events : []; + if (events.length > 0) { + const maxVotes = Math.max(...events.map((e: any) => e.votes || 0)); + const top = events.filter((e: any) => (e.votes || 0) === maxVotes); + const pick = top[Math.floor(Math.random() * top.length)]; + chosenId = pick?.id || null; + } + } + if (!chosenId && deck.length > 0) { + const pick = deck[Math.floor(Math.random() * deck.length)]; + chosenId = pick?.id || null; + } + if (chosenId && typeof window !== 'undefined') { + try { sessionStorage.setItem(`choosy:selectedWinner:${planId}`, chosenId); } catch {} + } + } catch (_) { + // ignore errors and continue to results + } finally { + router.push(`/results/${planId}`); + } + })(); + return; + } + const id = setInterval(() => setTimeLeft((s) => (s !== null ? s - 1 : s)), 1000); + return () => clearInterval(id); + }, [timeLeft, timerStarted, planId, deck, router]); + // Fetch voting status with live updates useEffect(() => { if (!planId) return; @@ -388,6 +433,14 @@ const VotePage: React.FC = () => {
+ {/* Session Timer - Top Right (before Share) */} + {typeof timeLeft === 'number' && timeLeft >= 0 && ( +
+
+ Time left: {Math.floor(timeLeft / 60)}:{(timeLeft % 60).toString().padStart(2, '0')} +
+
+ )} {/* Friends Voting Bar - Top Left */}
Date: Wed, 27 Aug 2025 23:38:27 -0500 Subject: [PATCH 2/2] checkpoint --- backend/config/environment.py | 17 +- backend/core/main.py | 55 ++++-- backend/main.env.example | 14 +- backend/services/global_event_apis.py | 256 ++++++++++++++++++++++++- frontend/components/CarouselVoting.tsx | 44 ++++- frontend/pages/results/[planId].tsx | 127 ++++++++++-- frontend/pages/vote/[planId].tsx | 12 +- 7 files changed, 476 insertions(+), 49 deletions(-) diff --git a/backend/config/environment.py b/backend/config/environment.py index 3bf179c..e28cf3d 100644 --- a/backend/config/environment.py +++ b/backend/config/environment.py @@ -76,6 +76,18 @@ def unsplash_access_key(self) -> Optional[str]: @property def unsplash_secret_key(self) -> Optional[str]: return os.getenv('UNSPLASH_SECRET_KEY') + + @property + def google_places_api_key(self) -> Optional[str]: + return os.getenv('GOOGLE_PLACES_API_KEY') + + @property + def meetup_api_key(self) -> Optional[str]: + return os.getenv('MEETUP_API_KEY') + + @property + def yelp_api_key(self) -> Optional[str]: + return os.getenv('YELP_API_KEY') @property def backend_url(self) -> str: @@ -91,7 +103,10 @@ def get_api_keys(self) -> dict: 'eventbrite': self.eventbrite_api_key, 'ticketmaster': self.ticketmaster_api_key, 'unsplash': self.unsplash_access_key, + 'google_places': self.google_places_api_key, + 'meetup': self.meetup_api_key, + 'yelp': self.yelp_api_key, } # Global configuration instance -env_config = EnvironmentConfig() \ No newline at end of file +env_config = EnvironmentConfig() diff --git a/backend/core/main.py b/backend/core/main.py index e1d2b69..9930174 100644 --- a/backend/core/main.py +++ b/backend/core/main.py @@ -410,6 +410,8 @@ async def get_events(lat: float, lng: float, category: str = "adventure", radius # Convert GlobalEvent objects to dictionaries for JSON serialization event_dicts = [] for event in events: + # Tickets flag: Eventbrite/Ticketmaster with external_url implies ticket purchase + tickets_required = True if (event.external_url and (event.source and event.source.value in ["eventbrite", "ticketmaster"])) else False event_dict = { 'id': event.id, 'name': event.name, @@ -432,7 +434,8 @@ async def get_events(lat: float, lng: float, category: str = "adventure", radius 'max_attendees': event.max_attendees, 'is_free': event.is_free, 'is_featured': event.is_featured, - 'metadata': event.metadata + 'metadata': event.metadata, + 'tickets_required': tickets_required } event_dicts.append(event_dict) @@ -606,6 +609,18 @@ def create_events_for_plan(plan_id: str, events: List[dict]): created_count = 0 for event in events: event_id = str(uuid.uuid4()) + # Map incoming source to DB constraint values + raw_source = (event.get("source") or event.get("source_type") or "").lower() + if raw_source in ("eventbrite", "ticketmaster", "yelp", "custom", "google", "local"): + source_type = raw_source + elif raw_source in ("google_places", "openstreetmap", "osm", "places"): + source_type = "google" + elif raw_source in ("meetup",): + source_type = "local" + elif not raw_source: + source_type = "local" + else: + source_type = "local" conn.execute( text(""" INSERT INTO events (id, plan_id, name, image, hours, source_type, votes_count, metadata) @@ -617,7 +632,7 @@ def create_events_for_plan(plan_id: str, events: List[dict]): "name": event.get("name", "Event"), "image": event.get("image_url") or event.get("image"), # Support both field names "hours": event.get("hours"), - "source_type": event.get("source_type", "google"), # Default to google instead of external + "source_type": source_type, "metadata": json.dumps(event.get("metadata", {})) } ) @@ -747,6 +762,8 @@ def get_events_for_plan(plan_id: str): "description": metadata.get('description', '') or f"Experience the best {metadata.get('category', 'local')} vibes at {row[1]}. Perfect for {metadata.get('category', 'fun')} activities and memorable moments.", "organizer": metadata.get('organizer', ''), "external_url": metadata.get('external_url'), + "tickets_required": bool(metadata.get('tickets_required')), + "reservations_accepted": bool(metadata.get('reservations_accepted')), "reviews": { "count": metadata.get('review_count', metadata.get('user_ratings_total', 42)), "stars": metadata.get('rating', metadata.get('stars', 4.2)) @@ -968,33 +985,33 @@ def get_plan_results(plan_id: str): events_result = conn.execute( text(""" - SELECT e.id, e.name, e.image, e.metadata, + SELECT e.id, e.name, e.image, e.source_type, e.metadata, COUNT(v.id) as total_votes, COUNT(CASE WHEN v.vote_type = 'like' THEN 1 END) as likes, COUNT(CASE WHEN v.vote_type = 'dislike' THEN 1 END) as dislikes FROM events e LEFT JOIN votes v ON e.id = v.event_id WHERE e.plan_id = :plan_id - GROUP BY e.id, e.name, e.image, e.metadata + GROUP BY e.id, e.name, e.image, e.source_type, e.metadata ORDER BY likes DESC, e.name ASC """), {"plan_id": plan_id} ) events = [] for row in events_result: - total_votes = row[4] or 0 - likes = row[5] or 0 - dislikes = row[6] or 0 + total_votes = row[5] or 0 + likes = row[6] or 0 + dislikes = row[7] or 0 percentage = (likes / total_votes * 100) if total_votes > 0 else 0 # Parse metadata for additional info metadata = {} - if row[3]: + if row[4]: try: - if isinstance(row[3], str): - metadata = json.loads(row[3]) + if isinstance(row[4], str): + metadata = json.loads(row[4]) else: - metadata = row[3] + metadata = row[4] except: metadata = {} @@ -1021,13 +1038,25 @@ def get_plan_results(plan_id: str): image_category = topic_image_map.get(plan[0] if plan else 'nightlife', 'nightlife') topic_specific_image = f"https://picsum.photos/600/400?random={str(row[0])[-6:]}&category={image_category}" + # Extract ticketing/reservation info from metadata when present + tickets_required = bool(metadata.get('tickets_required')) + reservations_accepted = bool(metadata.get('reservations_accepted')) + external_url = metadata.get('external_url') or metadata.get('purchase_url') + seatmap_url = metadata.get('seatmap_url') + event_data = { "id": str(row[0]), "name": row[1], "image_url": row[2] or metadata.get('image_url') or topic_specific_image, # Changed to image_url + "source_type": row[3], "votes": likes, "total_votes": total_votes, - "percentage": round(percentage, 1) + "percentage": round(percentage, 1), + "tickets_required": tickets_required, + "reservations_accepted": reservations_accepted, + "external_url": external_url, + "seatmap_url": seatmap_url, + "metadata": metadata } events.append(event_data) print(f"📊 Event: {row[1]}, Votes: {likes}, Total: {total_votes}") @@ -1768,4 +1797,4 @@ async def refresh_token(refresh_token: str): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/main.env.example b/backend/main.env.example index 4eba902..5e6ba9e 100644 --- a/backend/main.env.example +++ b/backend/main.env.example @@ -36,6 +36,18 @@ TICKETMASTER_API_SECRET= # Get key: https://www.meetup.com/api/ MEETUP_API_KEY= +# Google Places API (Venue details, ratings, hours, price) +# Billing required after free credit: https://developers.google.com/maps/documentation/places/web-service/overview +GOOGLE_PLACES_API_KEY= + +# Foursquare Places API (Global POI, ratings, hours) +# Docs: https://location.foursquare.com/developer/reference/place-search +FOURSQUARE_API_KEY= + +# Yelp Fusion API (Business ratings, price, reservations flag) +# Docs: https://docs.developer.yelp.com/reference/v3_business_search +YELP_API_KEY= + # ============================================================================= # SECURITY (Auto-generated if not provided) # ============================================================================= @@ -47,4 +59,4 @@ ALGORITHM=HS256 # ============================================================================= DEBUG=false ENVIRONMENT=development -ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \ No newline at end of file +ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 diff --git a/backend/services/global_event_apis.py b/backend/services/global_event_apis.py index 98316f4..03975ce 100644 --- a/backend/services/global_event_apis.py +++ b/backend/services/global_event_apis.py @@ -19,6 +19,7 @@ import json import hashlib from .api_config import api_template_manager, EventType +from core.cache_manager import cache_manager # Use centralized environment configuration from config.environment import env_config @@ -212,8 +213,238 @@ async def get_events_for_location( events.append(suggestion_event) # -------------------------------------- - print(f"✅ Found {len(events)} total events for {category}") - return events[:limit] + # Enrich a subset with place details where missing (ratings/hours/address) + try: + enriched_events = await self._enrich_events_with_places(events, lat, lng) + except Exception as e: + print(f"Enrichment skipped due to error: {e}") + enriched_events = events + + print(f"✅ Found {len(enriched_events)} total events for {category}") + return enriched_events[:limit] + + async def _enrich_events_with_places(self, events: List[GlobalEvent], lat: float, lng: float) -> List[GlobalEvent]: + """Enrich top events with venue details. + Regional priority: + - US/EU: Yelp → Foursquare → Google Places + - Else: Foursquare → Google Places → Yelp + Cached per venue and area. + """ + fsq_available = bool(self.api_keys.get('foursquare')) or bool(self.api_keys.get('FOURSQUARE')) or bool(os.getenv('FOURSQUARE_API_KEY')) + g_places_available = bool(self.api_keys.get('google_places')) + yelp_available = bool(os.getenv('YELP_API_KEY') or self.api_keys.get('yelp')) + if not fsq_available and not g_places_available: + return events + # Limit enrichment to first 10 to control rate + targets = [e for e in events if (not e.metadata or not e.metadata.get('rating') or not e.metadata.get('hours'))] + targets = targets[:10] + if not targets: + return events + import aiohttp + + async def enrich_with_foursquare(session: aiohttp.ClientSession, ev: GlobalEvent): + try: + name = ev.venue or ev.name + if not name: + return False + cache_key = f"fsq_enrich:{name}:{round(lat,4)}:{round(lng,4)}" + cached = cache_manager.get(cache_key) + if cached: + ev.metadata = {**(ev.metadata or {}), **cached} + if not ev.address and cached.get('address'): ev.address = cached.get('address') + return True + base = "https://api.foursquare.com/v3/places" + headers = {"Authorization": os.getenv('FOURSQUARE_API_KEY') or self.api_keys.get('foursquare'), "Accept": "application/json"} + params = {"query": name, "ll": f"{lat},{lng}", "radius": 5000, "limit": 1} + async with session.get(f"{base}/search", headers=headers, params=params, timeout=10) as r: + js = await r.json() + results = js.get('results') or [] + if not results: + return False + fsq_id = results[0].get('fsq_id') + if not fsq_id: + return False + fields = "rating,popularity,hours,location,tel,website,name" + async with session.get(f"{base}/{fsq_id}", headers=headers, params={"fields": fields}, timeout=10) as r2: + det = await r2.json() + # Map FS data + rating10 = det.get('rating') + rating5 = (rating10 / 2.0) if isinstance(rating10, (int, float)) else None + hours = det.get('hours', {}) + display_hours = hours.get('display') if isinstance(hours, dict) else None + location = det.get('location', {}) + merged = { + 'rating': rating5, + 'review_count': det.get('popularity'), + 'opening_hours': display_hours, + 'address': location.get('formatted_address'), + 'website': det.get('website'), + 'phone': det.get('tel') + } + ev.metadata = {**(ev.metadata or {}), **{k: v for k, v in merged.items() if v is not None}} + if not ev.address and merged.get('address'): + ev.address = merged['address'] + cache_manager.set(cache_key, ev.metadata, ttl=43200) + return True + except Exception as e: + print(f"Foursquare enrichment error for '{ev.name}': {e}") + return False + + async def enrich_with_places(session: aiohttp.ClientSession, ev: GlobalEvent): + name = ev.venue or ev.name + if not name: + return False + key = f"places_enrich:{name}:{round(lat,4)}:{round(lng,4)}" + cached = cache_manager.get(key) + if cached: + ev.metadata = {**(ev.metadata or {}), **cached} + # Fill common fields if empty + if not ev.city and cached.get('address'): ev.address = cached.get('address') + return True + try: + base = "https://maps.googleapis.com/maps/api/place" + # Text search near location + params = { + 'query': name, + 'location': f"{lat},{lng}", + 'radius': 5000, + 'key': self.api_keys['google_places'] + } + async with session.get(f"{base}/textsearch/json", params=params, timeout=10) as r: + js = await r.json() + candidate = (js.get('results') or [None])[0] + if not candidate: + return False + place_id = candidate.get('place_id') + fields = 'rating,user_ratings_total,price_level,opening_hours,formatted_address,website,name' + async with session.get(f"{base}/details/json", params={'place_id': place_id, 'fields': fields, 'key': self.api_keys['google_places']}, timeout=10) as r2: + det = await r2.json() + result = det.get('result', {}) + merged = { + 'rating': result.get('rating'), + 'review_count': result.get('user_ratings_total'), + 'price_level': result.get('price_level'), + 'opening_hours': result.get('opening_hours', {}).get('weekday_text'), + 'address': result.get('formatted_address'), + 'website': result.get('website') + } + # Merge into event metadata and basic fields + ev.metadata = {**(ev.metadata or {}), **{k:v for k,v in merged.items() if v is not None}} + if not ev.address and merged.get('address'): + ev.address = merged['address'] + # Try place photo as image_url if none present + if not ev.image_url: + photos = result.get('photos') or [] + if photos: + ref = photos[0].get('photo_reference') + if ref: + ev.image_url = f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference={ref}&key={self.api_keys['google_places']}" + # Cache for 12 hours + cache_manager.set(key, ev.metadata, ttl=43200) + return True + except Exception as e: + print(f"Places enrichment error for '{name}': {e}") + return False + + # Lightweight region detect (approx bounding boxes) + def is_us(lat: float, lng: float) -> bool: + # Continental US rough bounding box + return 24.0 <= lat <= 49.5 and -125.0 <= lng <= -66.0 + def is_canada(lat: float, lng: float) -> bool: + # Canada rough bounding box + return 41.7 <= lat <= 83.1 and -141.0 <= lng <= -52.6 + def is_europe(lat: float, lng: float) -> bool: + # Europe rough bounding box (excludes parts of Russia/Turkey for simplicity) + return 35.0 <= lat <= 71.0 and -10.0 <= lng <= 40.0 + in_us_ca = is_us(lat, lng) or is_canada(lat, lng) + in_eu = is_europe(lat, lng) + + async with aiohttp.ClientSession() as session: + async def enrich_one(ev: GlobalEvent): + yelp_key = (os.getenv('YELP_API_KEY') or self.api_keys.get('yelp')) if yelp_available else None + if in_us_ca: + # Yelp first in US/Canada + if yelp_key: + oky = await self._enrich_with_yelp(session, ev, lat, lng, yelp_key) + if oky: + return + if fsq_available: + ok = await enrich_with_foursquare(session, ev) + if ok: + return + if g_places_available: + await enrich_with_places(session, ev) + elif in_eu: + # Europe: Foursquare first, then Places, Yelp last + if fsq_available: + ok = await enrich_with_foursquare(session, ev) + if ok: + return + if g_places_available: + okp = await enrich_with_places(session, ev) + if okp: + return + if yelp_key: + await self._enrich_with_yelp(session, ev, lat, lng, yelp_key) + else: + # Rest of world + if fsq_available: + ok = await enrich_with_foursquare(session, ev) + if ok: + return + if g_places_available: + okp = await enrich_with_places(session, ev) + if okp: + return + if yelp_key: + await self._enrich_with_yelp(session, ev, lat, lng, yelp_key) + await asyncio.gather(*(enrich_one(ev) for ev in targets)) + return events + + async def _enrich_with_yelp(self, session, ev: GlobalEvent, lat: float, lng: float, api_key: str) -> bool: + try: + name = ev.venue or ev.name + if not name: + return False + cache_key = f"yelp_enrich:{name}:{round(lat,4)}:{round(lng,4)}" + cached = cache_manager.get(cache_key) + if cached: + ev.metadata = {**(ev.metadata or {}), **cached} + if not ev.address and cached.get('address'): ev.address = cached.get('address') + if not ev.image_url and cached.get('image_url'): ev.image_url = cached.get('image_url') + return True + headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"} + params = {"term": name, "latitude": lat, "longitude": lng, "radius": 5000, "limit": 1} + async with session.get("https://api.yelp.com/v3/businesses/search", headers=headers, params=params, timeout=10) as r: + js = await r.json() + businesses = js.get('businesses') or [] + if not businesses: + return False + biz = businesses[0] + biz_id = biz.get('id') + # details + async with session.get(f"https://api.yelp.com/v3/businesses/{biz_id}", headers=headers, timeout=10) as r2: + det = await r2.json() + merged = { + 'rating': det.get('rating'), + 'review_count': det.get('review_count'), + 'price': det.get('price'), + 'address': ", ".join(det.get('location', {}).get('display_address') or []), + 'website': det.get('url'), + 'phone': det.get('display_phone'), + 'reservations_accepted': 'restaurant_reservation' in (det.get('transactions') or []) + } + if not ev.image_url and det.get('image_url'): + merged['image_url'] = det.get('image_url') + ev.image_url = det.get('image_url') + ev.metadata = {**(ev.metadata or {}), **{k: v for k, v in merged.items() if v is not None}} + if not ev.address and merged.get('address'): + ev.address = merged['address'] + cache_manager.set(cache_key, ev.metadata, ttl=43200) + return True + except Exception as e: + print(f"Yelp enrichment error for '{ev.name}': {e}") + return False async def _fetch_eventbrite_events(self, lat: float, lng: float, category: str, radius: int) -> List[GlobalEvent]: """Fetch real events with Eventbrite as primary source""" @@ -2349,12 +2580,18 @@ def _convert_ticketmaster_to_event(self, event_data: dict, category: str) -> Opt latitude = location.get('latitude') longitude = location.get('longitude') - # Get price range + # Get price range and currency price_ranges = event_data.get('priceRanges', []) price = 'Varies' + currency = None + price_min = None + price_max = None if price_ranges: min_price = price_ranges[0].get('min', 0) max_price = price_ranges[0].get('max', 0) + currency = price_ranges[0].get('currency') + price_min = min_price + price_max = max_price if min_price == 0: price = 'Free' elif min_price == max_price: @@ -2451,8 +2688,9 @@ def _convert_ticketmaster_to_event(self, event_data: dict, category: str) -> Opt description = base_description - # Get external URL + # Get external URL and seat map external_url = event_data.get('url', '') + seatmap_url = event_data.get('seatmap', {}).get('staticUrl', '') return GlobalEvent( id=f"ticketmaster_{event_id}", @@ -2483,7 +2721,13 @@ def _convert_ticketmaster_to_event(self, event_data: dict, category: str) -> Opt 'status': event_data.get('status', {}).get('code', ''), 'accessibility': venue_data.get('accessibleSeatingDetail', ''), 'parking': venue_data.get('parkingDetail', ''), - 'general_info': venue_data.get('generalInfo', {}).get('generalRule', '') + 'general_info': venue_data.get('generalInfo', {}).get('generalRule', ''), + 'purchase_url': external_url, + 'seatmap_url': seatmap_url, + 'price_min': price_min, + 'price_max': price_max, + 'currency': currency, + 'price_text': price } ) except Exception as e: @@ -2493,4 +2737,4 @@ def _convert_ticketmaster_to_event(self, event_data: dict, category: str) -> Opt # Global instance -global_event_api = GlobalEventAPI() \ No newline at end of file +global_event_api = GlobalEventAPI() diff --git a/frontend/components/CarouselVoting.tsx b/frontend/components/CarouselVoting.tsx index ad08631..c410c08 100644 --- a/frontend/components/CarouselVoting.tsx +++ b/frontend/components/CarouselVoting.tsx @@ -18,6 +18,8 @@ interface EventCard { venue?: string; address?: string; price?: string; + tickets_required?: boolean; + reservations_accepted?: boolean; rating?: number; reviewCount?: number; category?: string; @@ -25,8 +27,21 @@ interface EventCard { contact?: { phone?: string; }; + source_type?: string; } +const vendorLabel = (s?: string) => { + const key = (s || '').toLowerCase(); + const map: Record = { + ticketmaster: 'Ticketmaster', + eventbrite: 'Eventbrite', + google: 'Google Places', + yelp: 'Yelp', + local: 'Meetup/Local' + }; + return map[key] || (s || ''); +}; + interface CarouselVotingProps { events: EventCard[]; onVote: (eventId: string, voteType: 'like' | 'dislike') => void; @@ -248,11 +263,28 @@ const CarouselVoting: React.FC = ({ {/* Badges */}
- {event.category && ( -
- {event.category} -
- )} +
+ {event.category && ( +
+ {event.category} +
+ )} + {event.source_type && ( +
+ {vendorLabel(event.source_type)} +
+ )} + {event.tickets_required && ( +
+ Tickets Required +
+ )} + {event.reservations_accepted && ( +
+ Reservations +
+ )} +
{event.price && (
@@ -402,4 +434,4 @@ const CarouselVoting: React.FC = ({ ); }; -export default CarouselVoting; \ No newline at end of file +export default CarouselVoting; diff --git a/frontend/pages/results/[planId].tsx b/frontend/pages/results/[planId].tsx index 5dc7215..dfc6c80 100644 --- a/frontend/pages/results/[planId].tsx +++ b/frontend/pages/results/[planId].tsx @@ -10,7 +10,7 @@ import 'react-phone-input-2/lib/style.css'; import ProfanityFilter from 'profanity-filter'; import { ReservationDialog } from '@/components/ReservationDialog'; import { BackgroundGradient } from '@/components/BackgroundGradient'; -import { Star, MapPin, Phone, Trophy, Medal, Timer, Share2, Plus } from 'lucide-react'; +import { Star, MapPin, Phone, Trophy, Medal, Timer, Share2, Plus, Ticket, Map as MapIcon, ExternalLink } from 'lucide-react'; type EventRow = { id: string; @@ -21,6 +21,10 @@ type EventRow = { hours?: string; contact?: { phone?: string }; needs_reservation?: boolean; + tickets_required?: boolean; + external_url?: string; + seatmap_url?: string; + source_type?: string; }; interface VotingStatus { @@ -86,20 +90,40 @@ function useResults(planId: string) { selectedWinner = top[Math.floor(Math.random() * top.length)] || eventsArr[0]; } + // Build enriched events list with ticketing and venue details + const events = (data.events || []).map((event: any) => { + const meta = event.metadata || {}; + let hours: string | undefined; + if (Array.isArray(meta.opening_hours)) { + hours = meta.opening_hours.join(' • '); + } else if (typeof meta.opening_hours === 'string') { + hours = meta.opening_hours; + } else if (meta.hours) { + hours = meta.hours; + } + const phone = meta.phone || meta.display_phone || ''; + return { + id: event.id, + name: event.name, + votes: event.votes, + percentage: event.percentage, + image_url: event.image_url, + hours, + contact: { phone }, + tickets_required: !!event.tickets_required, + external_url: event.external_url || meta.purchase_url, + seatmap_url: meta.seatmap_url, + source_type: event.source_type + } as EventRow; + }); + return { planId, topic: data.plan.topic, groupSize: data.plan.group_size, zip: data.plan.zip_code, - winningEvent: selectedWinner ? { - id: selectedWinner.id, - name: selectedWinner.name, - votes: selectedWinner.votes, - image_url: selectedWinner.image_url, - hours: "2 hours", - contact: { phone: '(555) 123-4567' }, - needs_reservation: selectedWinner.needs_reservation ?? true - } : undefined, + // If preselected winner exists, override with enriched event shape + winningEvent: selectedWinner ? events.find(e => e.id === selectedWinner.id) || events[0] : (events[0] || undefined), plan: { userName: data.plan.host_name, phoneNumber: data.plan.host_phone, @@ -109,15 +133,7 @@ function useResults(planId: string) { }, totalVotes: data.totalVotes, participants: data.participants, - allEvents: data.events.map((event: any) => ({ - id: event.id, - name: event.name, - votes: event.votes, - percentage: event.percentage, - image_url: event.image_url, - hours: "2 hours", - contact: { phone: '(555) 123-4567' } - })) + allEvents: events }; }, refetchInterval: 3000, @@ -181,6 +197,50 @@ export default function Results() { [results] ); + const vendorLabel = (s?: string) => { + const key = (s || '').toLowerCase(); + const map: Record = { + ticketmaster: 'Ticketmaster', + eventbrite: 'Eventbrite', + google: 'Google Places', + yelp: 'Yelp', + local: 'Meetup/Local' + }; + return map[key] || (s || ''); + }; + + const WinningCTAs = () => { + const w = results?.winningEvent; + if (!w) return null; + const showTickets = w.tickets_required && !!w.external_url; + const showSeatmap = !!w.seatmap_url; + if (!showTickets && !showSeatmap) return null; + return ( +
+ {showTickets && ( + + Get Tickets + + )} + {showSeatmap && ( + + Seat Map + + )} +
+ ); + }; + const validateName = (name: string): boolean => { const trimmedName = name.trim(); @@ -534,6 +594,30 @@ export default function Results() { {/* Action Buttons */}
+ {winnerEvent.tickets_required && results?.winningEvent?.external_url && ( + + Get Tickets + + )} + {results?.winningEvent?.seatmap_url && ( + + Seat Map + + )} {winnerEvent.needs_reservation && ( {event.votes} {event.votes === 1 ? 'vote' : 'votes'} {event.percentage?.toFixed(0) || 0}% + {event.source_type && ( + + {vendorLabel(event.source_type)} + + )}
diff --git a/frontend/pages/vote/[planId].tsx b/frontend/pages/vote/[planId].tsx index 0782fd2..60f93fb 100644 --- a/frontend/pages/vote/[planId].tsx +++ b/frontend/pages/vote/[planId].tsx @@ -50,6 +50,9 @@ interface EventCard { reviewCount?: number; phone?: string; hours?: string; + tickets_required?: boolean; + reservations_accepted?: boolean; + source_type?: string; } const VotePage: React.FC = () => { @@ -194,9 +197,9 @@ const VotePage: React.FC = () => { metadata = e.metadata || {}; } - return { - id: e.id, - name: e.name, + return { + id: e.id, + name: e.name, description: e.description || metadata.description || "Experience the best local vibes. Perfect for fun activities and memorable moments.", image_url: e.image_url || metadata.image_url || `https://picsum.photos/600/400?random=${e.id?.slice(-6)}`, // Use image_url from backend start_time: e.start_time || metadata.start_time, @@ -209,6 +212,7 @@ const VotePage: React.FC = () => { price: metadata.price || e.price || 'Free', category: e.category || metadata.category, source: e.source || metadata.source, + source_type: e.source_type || e.source || metadata.source, external_id: e.external_id || metadata.external_id, external_url: e.external_url || metadata.external_url, organizer: metadata.organizer || e.organizer || 'Local Organizer', @@ -217,6 +221,8 @@ const VotePage: React.FC = () => { is_free: metadata.is_free || e.is_free, is_featured: metadata.is_featured || e.is_featured, metadata: metadata, + tickets_required: e.tickets_required || metadata.tickets_required, + reservations_accepted: e.reservations_accepted || metadata.reservations_accepted, // Legacy fields for backwards compatibility rating: metadata.rating || e.rating || Math.floor(Math.random() * 2) + 4, reviewCount: metadata.review_count || e.reviewCount || Math.floor(Math.random() * 100) + 20,