● Zion Boggan zionboggan.com ↗

Initial import

b3af3d3   Zion Boggan committed on Jun 27, 2026 (1 month ago)
backend/migrations/20260101000012_seatmap.sql +3 -0
@@ -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;
backend/src/model/event.rs +4 -0
@@ -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,
}
backend/src/sources/ticketmaster.rs +4 -0
@@ -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(),
})
}
backend/src/store/event.rs +6 -4
@@ -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)
backend/tests/hash_golden.rs +1 -0
@@ -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!({}),
}
}
frontend/components/dashboard/DropCard.tsx +2 -0
@@ -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"
frontend/components/dashboard/SeatView.tsx +89 -0
@@ -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>
+ );
+}
frontend/components/ui/dialog.tsx +47 -0
@@ -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>
+ );
+}
frontend/lib/types.ts +1 -0
@@ -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;
}