| @@ -0,0 +1,3 @@ | ||
| + | -- Static seat-map image URL (Ticketmaster Discovery `seatmap.staticUrl`). | |
| + | -- Phase 1 Tier-2 seat view: a venue seat map shown on the drop card. | |
| + | ALTER TABLE events ADD COLUMN IF NOT EXISTS seatmap_url TEXT; |
| @@ -32,6 +32,7 @@ pub struct EventCandidate { | ||
| pub currency: Option<String>, | ||
| pub inventory_remaining: Option<u32>, | ||
| pub buy_url: String, | ||
| + | pub seatmap_url: Option<String>, | |
| pub raw: Value, | ||
| } | ||
| @@ -60,6 +61,7 @@ pub struct EventRow { | ||
| pub currency: Option<String>, | ||
| pub inventory_remaining: Option<i32>, | ||
| pub buy_url: String, | ||
| + | pub seatmap_url: Option<String>, | |
| pub content_hash: String, | ||
| pub first_seen_at: DateTime<Utc>, | ||
| pub updated_at: DateTime<Utc>, | ||
| @@ -90,6 +92,7 @@ pub struct Event { | ||
| pub currency: Option<String>, | ||
| pub inventory_remaining: Option<i32>, | ||
| pub buy_url: String, | ||
| + | pub seatmap_url: Option<String>, | |
| pub first_seen_at: DateTime<Utc>, | ||
| pub updated_at: DateTime<Utc>, | ||
| } | ||
| @@ -119,6 +122,7 @@ impl From<EventRow> for Event { | ||
| currency: r.currency, | ||
| inventory_remaining: r.inventory_remaining, | ||
| buy_url: r.buy_url, | ||
| + | seatmap_url: r.seatmap_url, | |
| first_seen_at: r.first_seen_at, | ||
| updated_at: r.updated_at, | ||
| } |
| @@ -233,6 +233,10 @@ fn normalize(ev: &Value) -> Option<EventCandidate> { | ||
| currency, | ||
| inventory_remaining: None, | ||
| buy_url, | ||
| + | seatmap_url: ev | |
| + | .pointer("/seatmap/staticUrl") | |
| + | .and_then(Value::as_str) | |
| + | .map(String::from), | |
| raw: ev.clone(), | ||
| }) | ||
| } |
| @@ -45,7 +45,7 @@ pub struct EventFilter { | ||
| const EVENT_COLS: &str = "id, source_id, source_event_id, title, subtitle, category, sport, \ | ||
| starts_at, venue_name, city, country, latitude, longitude, image_url, on_sale_at, \ | ||
| presale_on_sale_at, status, price_min, price_max, currency, inventory_remaining, \ | ||
| - | buy_url, content_hash, first_seen_at, updated_at"; | |
| + | buy_url, seatmap_url, content_hash, first_seen_at, updated_at"; | |
| /// Access object for the `events` and `price_history` tables. | ||
| #[derive(Clone)] | ||
| @@ -107,8 +107,8 @@ impl EventStore { | ||
| "INSERT INTO events (source_id, source_event_id, title, subtitle, category, sport, \ | ||
| starts_at, venue_name, city, country, latitude, longitude, image_url, on_sale_at, \ | ||
| presale_on_sale_at, status, price_min, price_max, currency, inventory_remaining, \ | ||
| - | buy_url, raw, content_hash) \ | |
| - | VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) \ | |
| + | buy_url, seatmap_url, raw, content_hash) \ | |
| + | VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) \ | |
| ON CONFLICT (source_id, source_event_id) DO UPDATE SET source_event_id = EXCLUDED.source_event_id \ | ||
| RETURNING id", | ||
| ) | ||
| @@ -133,6 +133,7 @@ impl EventStore { | ||
| .bind(&c.currency) | ||
| .bind(c.inventory_remaining.map(inv_to_i32)) | ||
| .bind(&c.buy_url) | ||
| + | .bind(&c.seatmap_url) | |
| .bind(&c.raw) | ||
| .bind(hash) | ||
| .fetch_one(&self.pool) | ||
| @@ -146,7 +147,7 @@ impl EventStore { | ||
| "UPDATE events SET title=$2, subtitle=$3, category=$4, sport=$5, starts_at=$6, \ | ||
| venue_name=$7, city=$8, country=$9, latitude=$10, longitude=$11, image_url=$12, \ | ||
| on_sale_at=$13, presale_on_sale_at=$14, status=$15, price_min=$16, price_max=$17, \ | ||
| - | currency=$18, inventory_remaining=$19, buy_url=$20, raw=$21, content_hash=$22 \ | |
| + | currency=$18, inventory_remaining=$19, buy_url=$20, seatmap_url=$21, raw=$22, content_hash=$23 \ | |
| WHERE id=$1", | ||
| ) | ||
| .bind(id) | ||
| @@ -169,6 +170,7 @@ impl EventStore { | ||
| .bind(&c.currency) | ||
| .bind(c.inventory_remaining.map(inv_to_i32)) | ||
| .bind(&c.buy_url) | ||
| + | .bind(&c.seatmap_url) | |
| .bind(&c.raw) | ||
| .bind(hash) | ||
| .execute(&self.pool) |
| @@ -39,6 +39,7 @@ fn vector_candidate() -> EventCandidate { | ||
| currency: Some("USD".to_string()), | ||
| inventory_remaining: Some(15234), | ||
| buy_url: "https://www.ticketmaster.com/event/Z1p2q3r4s5t6".to_string(), | ||
| + | seatmap_url: None, | |
| raw: json!({}), | ||
| } | ||
| } |
| @@ -5,6 +5,7 @@ import { motion, useReducedMotion } from "framer-motion"; | ||
| import { ArrowUpRight, MapPin, Ticket } from "lucide-react"; | ||
| import { StatusPill } from "@/components/ui/status-pill"; | ||
| import { SourceChip } from "@/components/shared/SourceChip"; | ||
| + | import { SeatView } from "./SeatView"; | |
| import { RelativeTime } from "@/components/shared/RelativeTime"; | ||
| import { dropCardVariants, flashAccentKeyframes } from "@/lib/motion"; | ||
| import { useUiStore } from "@/stores/ui"; | ||
| @@ -76,6 +77,7 @@ export function DropCard({ drop }: { drop: Drop }) { | ||
| {formatPriceRange(event.price_min, event.price_max, event.currency)} | ||
| </span> | ||
| <SourceChip id={event.source_id} className="ml-auto" /> | ||
| + | <SeatView event={event} /> | |
| <a | ||
| href={`${event.buy_url}${event.buy_url.includes("?") ? "&" : "?"}drophawk=1`} | ||
| target="_blank" |
| @@ -0,0 +1,89 @@ | ||
| + | "use client"; | |
| + | ||
| + | import Image from "next/image"; | |
| + | import { Armchair, ExternalLink, ImageOff } from "lucide-react"; | |
| + | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; | |
| + | import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; | |
| + | import { cn } from "@/lib/utils"; | |
| + | import type { TicketEvent } from "@/lib/types"; | |
| + | ||
| + | /** | |
| + | * Seat view (Phase 1, Tier 1 + 2): | |
| + | * - Tier 2: Ticketmaster's static seat-map image (event.seatmap_url) when present. | |
| + | * - Tier 1: a "view from seat" link tuned to surface seat-view sites for the venue. | |
| + | */ | |
| + | export function SeatView({ event }: { event: TicketEvent }) { | |
| + | const venueQuery = encodeURIComponent(`${event.venue_name ?? event.title} view from seat`); | |
| + | const viewFromSeatUrl = `https://www.google.com/search?q=${venueQuery}`; | |
| + | ||
| + | return ( | |
| + | <Dialog> | |
| + | <Tooltip> | |
| + | <TooltipTrigger asChild> | |
| + | <DialogTrigger | |
| + | className={cn( | |
| + | "inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius)]", | |
| + | "border border-[var(--color-border-default)] text-[var(--color-fg-tertiary)]", | |
| + | "transition-colors hover:border-[var(--color-accent-teal)] hover:text-[var(--color-accent-teal)]", | |
| + | "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent-teal)]", | |
| + | )} | |
| + | aria-label="View seats" | |
| + | > | |
| + | <Armchair className="h-4 w-4" aria-hidden /> | |
| + | </DialogTrigger> | |
| + | </TooltipTrigger> | |
| + | <TooltipContent>View seats</TooltipContent> | |
| + | </Tooltip> | |
| + | ||
| + | <DialogContent title="Seats"> | |
| + | <div className="space-y-3 p-4"> | |
| + | <p className="text-sm font-medium text-[var(--color-fg-primary)]">{event.title}</p> | |
| + | {event.venue_name ? ( | |
| + | <p className="-mt-2 text-xs text-[var(--color-fg-tertiary)]"> | |
| + | {[event.venue_name, event.city].filter(Boolean).join(" · ")} | |
| + | </p> | |
| + | ) : null} | |
| + | ||
| + | {event.seatmap_url ? ( | |
| + | <div className="relative overflow-hidden rounded-[var(--radius)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-inset)]"> | |
| + | <Image | |
| + | src={event.seatmap_url} | |
| + | alt={`Seat map for ${event.title}`} | |
| + | width={520} | |
| + | height={360} | |
| + | unoptimized | |
| + | className="h-auto w-full object-contain" | |
| + | /> | |
| + | </div> | |
| + | ) : ( | |
| + | <div className="flex flex-col items-center gap-2 rounded-[var(--radius)] border border-dashed border-[var(--color-border-subtle)] py-8 text-center"> | |
| + | <ImageOff className="h-5 w-5 text-[var(--color-fg-tertiary)]" aria-hidden /> | |
| + | <p className="text-xs text-[var(--color-fg-tertiary)]"> | |
| + | No seat map from the source for this event. | |
| + | </p> | |
| + | </div> | |
| + | )} | |
| + | ||
| + | <div className="flex gap-2"> | |
| + | <a | |
| + | href={viewFromSeatUrl} | |
| + | target="_blank" | |
| + | rel="noopener noreferrer" | |
| + | className="inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-[var(--radius)] border border-[var(--color-border-default)] text-xs font-medium text-[var(--color-fg-secondary)] transition-colors hover:border-[var(--color-accent-teal)] hover:text-[var(--color-accent-teal)]" | |
| + | > | |
| + | View from seat <ExternalLink className="h-3.5 w-3.5" aria-hidden /> | |
| + | </a> | |
| + | <a | |
| + | href={`${event.buy_url}${event.buy_url.includes("?") ? "&" : "?"}drophawk=1`} | |
| + | target="_blank" | |
| + | rel="noopener noreferrer" | |
| + | className="inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-[var(--radius)] bg-[var(--color-accent-teal)] text-xs font-semibold text-[var(--color-bg-inset)] transition-colors hover:bg-[var(--color-accent-teal-hover)]" | |
| + | > | |
| + | Pick seats <ExternalLink className="h-3.5 w-3.5" aria-hidden /> | |
| + | </a> | |
| + | </div> | |
| + | </div> | |
| + | </DialogContent> | |
| + | </Dialog> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,47 @@ | ||
| + | "use client"; | |
| + | ||
| + | import * as DialogPrimitive from "@radix-ui/react-dialog"; | |
| + | import { X } from "lucide-react"; | |
| + | import { cn } from "@/lib/utils"; | |
| + | ||
| + | export const Dialog = DialogPrimitive.Root; | |
| + | export const DialogTrigger = DialogPrimitive.Trigger; | |
| + | export const DialogClose = DialogPrimitive.Close; | |
| + | ||
| + | export function DialogContent({ | |
| + | className, | |
| + | children, | |
| + | title, | |
| + | ...props | |
| + | }: React.ComponentProps<typeof DialogPrimitive.Content> & { title?: string }) { | |
| + | return ( | |
| + | <DialogPrimitive.Portal> | |
| + | <DialogPrimitive.Overlay className="fixed inset-0 z-[var(--z-modal)] bg-black/55 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> | |
| + | <DialogPrimitive.Content | |
| + | className={cn( | |
| + | "fixed left-1/2 top-1/2 z-[var(--z-modal)] w-[min(92vw,520px)] -translate-x-1/2 -translate-y-1/2", | |
| + | "ring-frame overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-default)]", | |
| + | "bg-[var(--color-bg-overlay)] shadow-[var(--shadow-pop)] focus:outline-none", | |
| + | "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", | |
| + | className, | |
| + | )} | |
| + | {...props} | |
| + | > | |
| + | {title ? ( | |
| + | <div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] px-4 py-3"> | |
| + | <DialogPrimitive.Title className="text-sm font-semibold text-[var(--color-fg-primary)]"> | |
| + | {title} | |
| + | </DialogPrimitive.Title> | |
| + | <DialogPrimitive.Close | |
| + | className="rounded-[var(--radius-sm)] text-[var(--color-fg-tertiary)] transition-colors hover:text-[var(--color-fg-primary)]" | |
| + | aria-label="Close" | |
| + | > | |
| + | <X className="h-4 w-4" aria-hidden /> | |
| + | </DialogPrimitive.Close> | |
| + | </div> | |
| + | ) : null} | |
| + | {children} | |
| + | </DialogPrimitive.Content> | |
| + | </DialogPrimitive.Portal> | |
| + | ); | |
| + | } |
| @@ -71,6 +71,7 @@ export interface TicketEvent { | ||
| currency: string; | ||
| inventory_remaining: number | null; | ||
| buy_url: string; | ||
| + | seatmap_url: string | null; | |
| first_seen_at: string; | ||
| updated_at: string; | ||
| } |