+ {/* Month labels */}
+
+ {MONTHS.map((m) => (
+
+ {m}
+
+ ))}
+
+
+ {/* Scrubber bar */}
+
{
+ isDraggingRef.current = true;
+ setPlaying(false);
+ handleScrubberInteraction(e.clientX);
+ }}
+ onTouchStart={(e) => {
+ isDraggingRef.current = true;
+ setPlaying(false);
+ if (e.touches[0]) handleScrubberInteraction(e.touches[0].clientX);
+ }}
+ >
+ {/* Track bg */}
+
+
+ {/* Filled track */}
+
1 ? (dayIndex / (totalDays - 1)) * 100 : 0}%` }}
+ />
+
+ {/* Milestone ticks — abbreviated to prevent overlap */}
+ {milestonePositions.map((m) => {
+ if (m.dayIndex < 0) return null;
+ const pct = (m.dayIndex / (totalDays - 1)) * 100;
+ const abbrev: Record
= {
+ "Bud Break": "Bud",
+ "Flowering": "Bloom",
+ "Véraison": "Vér.",
+ "Harvest": "Harv.",
+ };
+ return (
+
+
+ {abbrev[m.label] ?? m.label}
+
+
+
+ );
+ })}
+
+ {/* Thumb */}
+ 1 ? (dayIndex / (totalDays - 1)) * 100 : 0}%`,
+ transform: "translate(-50%, -50%)",
+ }}
+ />
+
+
+ {/* Play/Pause */}
+
+
+
+ Day {dayIndex + 1} / {totalDays}
+
+
+
+ )}
+
+ {/* ── Loading / Error state ── */}
+ {loading && (
+
+
+ Loading {selectedYear} weather data...
+
+
+ )}
+ {error && (
+
+ )}
+
+ {/* ── Year Picker ── */}
+
+
+ {YEARS.map((year) => (
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/winebob/src/components/shared/Avatar.tsx b/winebob/src/components/shared/Avatar.tsx
new file mode 100644
index 0000000..f027d9f
--- /dev/null
+++ b/winebob/src/components/shared/Avatar.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+/**
+ * Pre-defined bubblehead avatars for wine tasting profiles.
+ * Each avatar is a simple SVG illustration with unique characteristics.
+ * Users pick one at registration; guests get one assigned randomly.
+ */
+
+type AvatarProps = {
+ avatarId: number; // 0-11
+ size?: number; // px, default 48
+ className?: string;
+};
+
+// Each avatar: skin tone, hair color, hair style, accessory, bg color
+const AVATARS = [
+ { bg: "#FFD6D6", skin: "#F4C5A0", hair: "#4A2810", accent: "#74070E", style: "sommelier" },
+ { bg: "#D6EAFF", skin: "#E8B88A", hair: "#1A1A2E", accent: "#2B5EA7", style: "glasses" },
+ { bg: "#E8F5D6", skin: "#C68E5B", hair: "#2D1B0E", accent: "#5A8F3C", style: "curly" },
+ { bg: "#FFF0D6", skin: "#F4C5A0", hair: "#8B4513", accent: "#C9A96E", style: "ponytail" },
+ { bg: "#F0D6FF", skin: "#D4A574", hair: "#1A1A1A", accent: "#7B4BB3", style: "short" },
+ { bg: "#FFE8D6", skin: "#FDDCB5", hair: "#D4A03C", accent: "#E07B3C", style: "wavy" },
+ { bg: "#D6FFF0", skin: "#8D6E4C", hair: "#0D0D0D", accent: "#3C8F7B", style: "bun" },
+ { bg: "#FFD6E8", skin: "#F4C5A0", hair: "#6B2D3E", accent: "#C44D7B", style: "bob" },
+ { bg: "#FFFBD6", skin: "#C68E5B", hair: "#3D2B1F", accent: "#A89032", style: "beard" },
+ { bg: "#D6E8FF", skin: "#FDDCB5", hair: "#5C3317", accent: "#4A7BBF", style: "cap" },
+ { bg: "#E8D6FF", skin: "#D4A574", hair: "#2D1B0E", accent: "#6B4D8A", style: "mohawk" },
+ { bg: "#D6FFE8", skin: "#F4C5A0", hair: "#1A1A2E", accent: "#4A8F5C", style: "long" },
+];
+
+function AvatarSVG({ avatar, size }: { avatar: typeof AVATARS[0]; size: number }) {
+ const s = size;
+ const cx = s / 2;
+ const headR = s * 0.30;
+ const bodyY = s * 0.72;
+
+ return (
+
+ );
+}
+
+export function Avatar({ avatarId, size = 48, className = "" }: AvatarProps) {
+ const avatar = AVATARS[avatarId % AVATARS.length];
+ return (
+
+ );
+}
+
+/** Get a deterministic avatar ID from a string (name, odder ID, etc.) */
+export function avatarIdFromString(str: string): number {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
+ }
+ return Math.abs(hash) % AVATARS.length;
+}
+
+/** Row of avatar circles, overlapping (like Paytin's Quick Send) */
+export function AvatarStack({ ids, size = 32, max = 5 }: { ids: number[]; size?: number; max?: number }) {
+ const shown = ids.slice(0, max);
+ const extra = ids.length - max;
+
+ return (
+
+ {shown.map((id, i) => (
+
+ ))}
+ {extra > 0 && (
+
+ +{extra}
+
+ )}
+
+ );
+}
+
+export { AVATARS };
diff --git a/winebob/src/components/shared/BottomTabBar.tsx b/winebob/src/components/shared/BottomTabBar.tsx
new file mode 100644
index 0000000..84c89c4
--- /dev/null
+++ b/winebob/src/components/shared/BottomTabBar.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import { Wine, Library, Radio, User } from "lucide-react";
+
+const tabs = [
+ { href: "/arena", label: "Tastings", icon: Wine },
+ { href: "/wines", label: "Wines", icon: Library },
+ { href: "/live", label: "Live", icon: Radio },
+ { href: "/profile", label: "Profile", icon: User },
+] as const;
+
+/** Routes where the tab bar should be hidden (focused flows) */
+const HIDDEN_ROUTES = ["/arena/create", "/arena/event/", "/play/", "/join/", "/live/"];
+
+export function BottomTabBar() {
+ const pathname = usePathname();
+
+ // Hide on focused flows — but NOT on /live itself (only /live/[id])
+ const shouldHide = HIDDEN_ROUTES.some((r) => {
+ if (r === "/live/") return pathname.startsWith("/live/");
+ return pathname.startsWith(r);
+ });
+ // Keep tab bar visible on exact /live page
+ if (shouldHide && pathname !== "/live") return null;
+
+ return (
+
+ );
+}
diff --git a/winebob/src/components/shared/MapLayerDrawer.tsx b/winebob/src/components/shared/MapLayerDrawer.tsx
new file mode 100644
index 0000000..a1c834b
--- /dev/null
+++ b/winebob/src/components/shared/MapLayerDrawer.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import React, { useState, useRef, useEffect } from "react";
+import { Layers, X } from "lucide-react";
+
+export type MapLayer = {
+ id: string;
+ name: string;
+ description: string;
+ icon: React.ReactNode;
+ group: "explore" | "social" | "tools";
+ exclusive?: string;
+ enabled: boolean;
+ available: boolean;
+ availableHint?: string;
+};
+
+type Props = {
+ layers: MapLayer[];
+ onToggle: (layerId: string) => void;
+ className?: string;
+};
+
+const GROUP_ORDER: MapLayer["group"][] = ["explore", "social", "tools"];
+const GROUP_LABELS: Record
= {
+ explore: "EXPLORE",
+ social: "SOCIAL",
+ tools: "TOOLS",
+};
+
+function Toggle({
+ on,
+ disabled,
+ onPress,
+}: {
+ on: boolean;
+ disabled: boolean;
+ onPress: () => void;
+}) {
+ return (
+
+ );
+}
+
+export function MapLayerDrawer({ layers, onToggle, className = "" }: Props) {
+ const [open, setOpen] = useState(false);
+ const panelRef = useRef(null);
+
+ const activeCount = layers.filter((l) => l.enabled).length;
+
+ // Close on outside click
+ useEffect(() => {
+ if (!open) return;
+ function handleClick(e: MouseEvent) {
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [open]);
+
+ // Group layers
+ const grouped = GROUP_ORDER.map((group) => ({
+ group,
+ label: GROUP_LABELS[group],
+ items: layers.filter((l) => l.group === group),
+ })).filter((g) => g.items.length > 0);
+
+ return (
+
+ {/* Panel */}
+
+
+ {/* Panel header */}
+
+
+ Map Layers
+
+
+
+
+ {/* Layer groups */}
+
+ {grouped.map(({ group, label, items }) => (
+
+ {/* Group header */}
+
+ {label}
+
+
+
+ {items.map((layer) => (
+
+ {/* Icon */}
+
+ {layer.icon}
+
+
+ {/* Text */}
+
+
+ {layer.name}
+
+
+ {layer.description}
+
+ {!layer.available && layer.availableHint && (
+
+ {layer.availableHint}
+
+ )}
+
+
+ {/* Toggle */}
+
onToggle(layer.id)}
+ />
+
+ ))}
+
+
+ ))}
+
+
+
+
+ {/* FAB */}
+
+
+ );
+}
diff --git a/winebob/src/components/shared/TourInfoCard.tsx b/winebob/src/components/shared/TourInfoCard.tsx
new file mode 100644
index 0000000..12ae215
--- /dev/null
+++ b/winebob/src/components/shared/TourInfoCard.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { Wine, Grape, ArrowRight } from "lucide-react";
+import Link from "next/link";
+import type { TourStop } from "./WineRegionMap";
+
+type Props = {
+ stop: TourStop;
+ /** Region name for the "Explore wines" link */
+ region?: string;
+ /** Called when user taps "Next" to advance tour manually */
+ onNext?: () => void;
+};
+
+export function TourInfoCard({ stop, region, onNext }: Props) {
+ return (
+
+ {/* Header */}
+
+
+ {stop.name}
+
+
+ {stop.tagline}
+
+
+
+ {/* Grapes */}
+ {stop.grapes.length > 0 && (
+
+
+ {stop.grapes.map((g) => (
+
+ {g}
+
+ ))}
+
+ )}
+
+ {/* Notable wines */}
+ {stop.notableWines.length > 0 && (
+
+
+
+ Notable wines
+
+ {stop.notableWines.slice(0, 2).map((w) => (
+
{w}
+ ))}
+
+ )}
+
+ {/* Actions */}
+
+ {region && (
+
+ Explore wines
+
+ )}
+ {onNext && (
+
+ )}
+
+
+ );
+}
diff --git a/winebob/src/components/shared/UniversePortal.tsx b/winebob/src/components/shared/UniversePortal.tsx
new file mode 100644
index 0000000..6367a43
--- /dev/null
+++ b/winebob/src/components/shared/UniversePortal.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { Trophy, Library, Radio, User, X, Sparkles } from "lucide-react";
+
+const UNIVERSES = [
+ {
+ id: "arena",
+ label: "Arena",
+ description: "Blind tastings & competitions",
+ href: "/arena",
+ icon: Trophy,
+ gradient: "from-[#8A1E2A] to-[#5A0810]",
+ glow: "rgba(138, 30, 42, 0.4)",
+ accent: "#F4E4E6",
+ },
+ {
+ id: "cellar",
+ label: "Explore",
+ description: "Browse wines & regions",
+ href: "/wines",
+ icon: Library,
+ gradient: "from-[#2A2420] to-[#0C0A08]",
+ glow: "rgba(200, 162, 85, 0.3)",
+ accent: "#C8A255",
+ },
+ {
+ id: "live",
+ label: "Live",
+ description: "Real-time sommelier events",
+ href: "/live",
+ icon: Radio,
+ gradient: "from-[#1A0A0C] to-[#0F0D0B]",
+ glow: "rgba(220, 40, 50, 0.35)",
+ accent: "#FF4444",
+ },
+ {
+ id: "profile",
+ label: "Profile",
+ description: "Your stats & settings",
+ href: "/profile",
+ icon: User,
+ gradient: "from-[#E8E4E0] to-[#D8D0C8]",
+ glow: "rgba(140, 130, 120, 0.2)",
+ accent: "#8E8278",
+ },
+] as const;
+
+function getCurrentUniverse(pathname: string): string {
+ if (pathname.startsWith("/arena") || pathname.startsWith("/play") || pathname.startsWith("/join")) return "arena";
+ if (pathname.startsWith("/wines")) return "cellar";
+ if (pathname.startsWith("/live") || pathname.startsWith("/sommeliers")) return "live";
+ if (pathname.startsWith("/profile")) return "profile";
+ return "arena";
+}
+
+export function UniversePortal() {
+ const [isOpen, setIsOpen] = useState(false);
+ const pathname = usePathname();
+ const router = useRouter();
+ const current = getCurrentUniverse(pathname);
+
+ // Don't show on landing page or auth pages
+ if (pathname === "/" || pathname.startsWith("/login") || pathname.startsWith("/api")) return null;
+
+ const currentUniverse = UNIVERSES.find((u) => u.id === current);
+ const CurrentIcon = currentUniverse?.icon ?? Sparkles;
+
+ function navigateTo(href: string) {
+ setIsOpen(false);
+ router.push(href);
+ }
+
+ return (
+ <>
+ {/* ══════════ FLOATING PORTAL BUTTON ══════════ */}
+
+
+ {/* ══════════ UNIVERSE SWITCHER OVERLAY ══════════ */}
+ {isOpen && (
+ setIsOpen(false)}
+ >
+ {/* Backdrop */}
+
+
+ {/* Panel */}
+
e.stopPropagation()}
+ >
+ {/* Close hint */}
+
+
+ {/* Universe cards */}
+
+ {UNIVERSES.map((universe) => {
+ const Icon = universe.icon;
+ const isActive = universe.id === current;
+
+ return (
+
+ );
+ })}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/winebob/src/components/shared/WineRegionMap.tsx b/winebob/src/components/shared/WineRegionMap.tsx
new file mode 100644
index 0000000..5792703
--- /dev/null
+++ b/winebob/src/components/shared/WineRegionMap.tsx
@@ -0,0 +1,699 @@
+"use client";
+
+import { useEffect, useRef, useCallback } from "react";
+import mapboxgl from "mapbox-gl";
+import "mapbox-gl/dist/mapbox-gl.css";
+import { wineRegions } from "@/data/wineRegions";
+
+const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
+
+/** Tour stop content — shown as an info card during cinematic tour */
+export type TourStop = {
+ name: string;
+ coords: [number, number];
+ tagline: string;
+ grapes: string[];
+ notableWines: string[];
+};
+
+/** Sub-cities within each wine region for hopping + tour content */
+const REGION_SUB_CITIES: Record = {
+ "Bordeaux": [
+ { name: "Saint-Émilion", coords: [-0.15, 44.89], tagline: "Medieval village with limestone plateaux producing rich, Merlot-dominant blends", grapes: ["Merlot", "Cabernet Franc"], notableWines: ["Château Cheval Blanc", "Château Ausone", "Château Angélus"] },
+ { name: "Pauillac", coords: [-0.75, 45.20], tagline: "Home to three First Growths — the crown jewels of Bordeaux", grapes: ["Cabernet Sauvignon", "Merlot"], notableWines: ["Château Lafite Rothschild", "Château Mouton Rothschild", "Château Latour"] },
+ { name: "Margaux", coords: [-0.67, 45.04], tagline: "Elegant, perfumed wines from gravelly terroir along the Gironde", grapes: ["Cabernet Sauvignon", "Merlot"], notableWines: ["Château Margaux", "Château Palmer", "Château Rauzan-Ségla"] },
+ { name: "Sauternes", coords: [-0.35, 44.53], tagline: "Golden dessert wines shaped by noble rot (botrytis) from misty mornings", grapes: ["Sémillon", "Sauvignon Blanc"], notableWines: ["Château d'Yquem", "Château Suduiraut", "Château Climens"] },
+ { name: "Pessac-Léognan", coords: [-0.68, 44.77], tagline: "Birthplace of Bordeaux wine — gravel soils producing both reds and whites", grapes: ["Cabernet Sauvignon", "Sauvignon Blanc"], notableWines: ["Château Haut-Brion", "Château Smith Haut Lafitte", "Domaine de Chevalier"] },
+ ],
+ "Burgundy": [
+ { name: "Beaune", coords: [4.84, 47.02], tagline: "Wine capital of Burgundy with its famous Hospices charity auction", grapes: ["Pinot Noir", "Chardonnay"], notableWines: ["Bouchard Père & Fils", "Joseph Drouhin", "Louis Jadot"] },
+ { name: "Nuits-Saint-Georges", coords: [4.95, 47.14], tagline: "Powerful, structured Pinot Noir from the heart of the Côte de Nuits", grapes: ["Pinot Noir"], notableWines: ["Domaine de la Romanée-Conti", "Henri Jayer", "Domaine Leroy"] },
+ { name: "Chablis", coords: [3.80, 47.81], tagline: "Pure, mineral Chardonnay from ancient Kimmeridgian limestone", grapes: ["Chardonnay"], notableWines: ["Domaine Raveneau", "Vincent Dauvissat", "William Fèvre"] },
+ { name: "Meursault", coords: [4.77, 46.98], tagline: "Rich, buttery white Burgundy — the gold standard of Chardonnay", grapes: ["Chardonnay"], notableWines: ["Domaine Coche-Dury", "Domaine des Comtes Lafon", "Domaine Roulot"] },
+ { name: "Gevrey-Chambertin", coords: [4.97, 47.23], tagline: "Nine Grand Cru vineyards producing Burgundy's most powerful reds", grapes: ["Pinot Noir"], notableWines: ["Domaine Armand Rousseau", "Domaine Denis Mortet", "Domaine Trapet"] },
+ ],
+ "Champagne": [
+ { name: "Reims", coords: [3.88, 49.25], tagline: "Cathedral city with vast underground chalk cellars — Montagne de Reims", grapes: ["Pinot Noir", "Chardonnay"], notableWines: ["Krug", "Veuve Clicquot", "Louis Roederer"] },
+ { name: "Épernay", coords: [3.95, 49.04], tagline: "Avenue de Champagne — the most expensive street in the world per square meter", grapes: ["Chardonnay", "Pinot Noir", "Pinot Meunier"], notableWines: ["Moët & Chandon", "Dom Pérignon", "Perrier-Jouët"] },
+ { name: "Ay", coords: [3.99, 49.06], tagline: "Grand Cru village on the Marne producing powerful Pinot Noir-based cuvées", grapes: ["Pinot Noir"], notableWines: ["Bollinger", "Deutz", "Gosset"] },
+ ],
+ "Tuscany": [
+ { name: "Montalcino", coords: [11.49, 43.06], tagline: "Sun-drenched hilltop producing Italy's most age-worthy red — Brunello", grapes: ["Sangiovese"], notableWines: ["Biondi-Santi", "Casanova di Neri", "Il Poggione"] },
+ { name: "Montepulciano", coords: [11.78, 43.10], tagline: "Renaissance town with Vino Nobile — Sangiovese at its most elegant", grapes: ["Sangiovese", "Canaiolo"], notableWines: ["Avignonesi", "Boscarelli", "Poliziano"] },
+ { name: "Chianti", coords: [11.25, 43.47], tagline: "Rolling hills between Florence and Siena — the heart of Tuscan wine", grapes: ["Sangiovese"], notableWines: ["Fontodi", "Fèlsina", "Castello di Ama"] },
+ { name: "Bolgheri", coords: [10.61, 43.23], tagline: "Coastal Super Tuscan revolution — Bordeaux varieties on Mediterranean soil", grapes: ["Cabernet Sauvignon", "Merlot"], notableWines: ["Sassicaia", "Ornellaia", "Masseto"] },
+ { name: "San Gimignano", coords: [11.04, 43.47], tagline: "Medieval towers and crisp Vernaccia — Tuscany's signature white wine", grapes: ["Vernaccia"], notableWines: ["Panizzi", "Teruzzi & Puthod", "Montenidoli"] },
+ ],
+ "Piedmont": [
+ { name: "Barolo", coords: [7.94, 44.61], tagline: "The King of wines — powerful Nebbiolo needing decades to reveal its depth", grapes: ["Nebbiolo"], notableWines: ["Giacomo Conterno", "Bruno Giacosa", "Bartolo Mascarello"] },
+ { name: "Barbaresco", coords: [8.08, 44.73], tagline: "Barolo's elegant sibling — earlier-drinking Nebbiolo with silky tannins", grapes: ["Nebbiolo"], notableWines: ["Gaja", "Bruno Giacosa", "Produttori del Barbaresco"] },
+ { name: "Asti", coords: [8.21, 44.90], tagline: "Sweet, sparkling Moscato d'Asti — Piedmont's joyful, low-alcohol fizz", grapes: ["Moscato Bianco"], notableWines: ["Paolo Saracco", "Vietti", "La Spinetta"] },
+ { name: "Alba", coords: [8.03, 44.70], tagline: "Truffle capital and gateway to the Langhe — Barbera and Dolcetto country", grapes: ["Barbera", "Dolcetto"], notableWines: ["Elio Altare", "Sandrone", "Aldo Conterno"] },
+ ],
+ "Rioja": [
+ { name: "Haro", coords: [-2.85, 42.58], tagline: "Barrio de la Estación — a railway quarter with legendary bodegas", grapes: ["Tempranillo", "Garnacha"], notableWines: ["López de Heredia", "La Rioja Alta", "CVNE"] },
+ { name: "Logroño", coords: [-2.45, 42.47], tagline: "Regional capital famed for its tapas bars on Calle Laurel", grapes: ["Tempranillo"], notableWines: ["Marqués de Murrieta", "Bodegas Ontañón", "Campo Viejo"] },
+ { name: "Laguardia", coords: [-2.58, 42.55], tagline: "Medieval hilltop village in Rioja Alavesa with underground cellars", grapes: ["Tempranillo", "Viura"], notableWines: ["Artadi", "Remírez de Ganuza", "Bodegas Ysios"] },
+ ],
+ "Napa Valley": [
+ { name: "St. Helena", coords: [-122.47, 38.51], tagline: "Charming main street lined with world-class tasting rooms", grapes: ["Cabernet Sauvignon"], notableWines: ["Spottswoode", "Duckhorn", "Beringer"] },
+ { name: "Yountville", coords: [-122.36, 38.40], tagline: "Culinary epicenter — home to The French Laundry and boutique wineries", grapes: ["Cabernet Sauvignon", "Merlot"], notableWines: ["Dominus", "Cliff Lede", "Jessup Cellars"] },
+ { name: "Calistoga", coords: [-122.58, 38.58], tagline: "Hot springs town at the valley's warm northern end — bold, ripe reds", grapes: ["Cabernet Sauvignon", "Petite Sirah"], notableWines: ["Château Montelena", "Araujo", "Eisele Vineyard"] },
+ { name: "Rutherford", coords: [-122.42, 38.46], tagline: "Famous 'Rutherford Dust' — the benchmark terroir for Napa Cabernet", grapes: ["Cabernet Sauvignon"], notableWines: ["Caymus", "Inglenook", "Scarecrow"] },
+ ],
+ "Douro Valley": [
+ { name: "Pinhão", coords: [-7.55, 41.19], tagline: "Dramatic terraced vineyards carved into schist slopes above the Douro river", grapes: ["Touriga Nacional", "Tinta Roriz"], notableWines: ["Quinta do Noval", "Quinta do Crasto", "Niepoort"] },
+ { name: "Peso da Régua", coords: [-7.79, 41.16], tagline: "Historic port wine trading hub — gateway to the upper Douro", grapes: ["Touriga Nacional", "Touriga Franca"], notableWines: ["Ramos Pinto", "Quinta do Vallado", "Kopke"] },
+ ],
+ "Mosel": [
+ { name: "Bernkastel-Kues", coords: [7.07, 49.92], tagline: "Steep slate slopes producing Germany's most prestigious Rieslings", grapes: ["Riesling"], notableWines: ["Joh. Jos. Prüm", "Dr. Loosen", "Willi Schaefer"] },
+ { name: "Piesport", coords: [6.92, 49.88], tagline: "South-facing amphitheater of vines — Goldtröpfchen (little gold drops)", grapes: ["Riesling"], notableWines: ["Haart", "St. Urbans-Hof", "Julian Haart"] },
+ { name: "Trittenheim", coords: [6.90, 49.83], tagline: "Dramatic river bend with the Apotheke vineyard rising from the water", grapes: ["Riesling"], notableWines: ["Grans-Fassian", "Clüsserath-Weiler", "Eifel-Pfeiffer"] },
+ ],
+ "Rhone Valley": [
+ { name: "Châteauneuf-du-Pape", coords: [4.83, 44.06], tagline: "13 permitted grape varieties and sun-baked galets roulés stones", grapes: ["Grenache", "Syrah", "Mourvèdre"], notableWines: ["Château Rayas", "Beaucastel", "Clos des Papes"] },
+ { name: "Hermitage", coords: [4.84, 45.07], tagline: "The granite hill that produces France's most powerful Syrah", grapes: ["Syrah"], notableWines: ["Jean-Louis Chave", "Jaboulet La Chapelle", "Chapoutier"] },
+ { name: "Côte-Rôtie", coords: [4.81, 45.47], tagline: "The 'roasted slope' — steep terraces producing perfumed, elegant Syrah", grapes: ["Syrah", "Viognier"], notableWines: ["Guigal La Mouline", "René Rostaing", "Stéphane Ogier"] },
+ { name: "Gigondas", coords: [5.00, 44.17], tagline: "Lace-carved Dentelles mountains and rich Grenache-based reds", grapes: ["Grenache", "Syrah"], notableWines: ["Domaine Santa Duc", "Domaine du Cayron", "Château de Saint Cosme"] },
+ ],
+ "Loire Valley": [
+ { name: "Tours", coords: [0.69, 47.39], tagline: "Gateway city to France's garden — Touraine's diverse wine country", grapes: ["Chenin Blanc", "Cabernet Franc"], notableWines: ["Domaine de la Taille aux Loups", "François Chidaine", "Vincent Carême"] },
+ { name: "Sancerre", coords: [2.84, 47.33], tagline: "Hilltop village producing the world's benchmark Sauvignon Blanc", grapes: ["Sauvignon Blanc"], notableWines: ["Domaine Vacheron", "François Cotat", "Lucien Crochet"] },
+ { name: "Vouvray", coords: [0.80, 47.41], tagline: "Chenin Blanc in every style — dry, off-dry, sweet, and sparkling", grapes: ["Chenin Blanc"], notableWines: ["Domaine Huet", "Domaine du Clos Naudin", "François Pinon"] },
+ { name: "Chinon", coords: [0.24, 47.17], tagline: "Cabernet Franc at its most elegant — violet-scented reds from tuffeau soil", grapes: ["Cabernet Franc"], notableWines: ["Charles Joguet", "Bernard Baudry", "Domaine Alliet"] },
+ { name: "Angers", coords: [-0.55, 47.47], tagline: "Capital of Anjou — sweet Coteaux du Layon and dry Savennières", grapes: ["Chenin Blanc"], notableWines: ["Domaine des Baumard", "Nicolas Joly", "Domaine FL"] },
+ ],
+ "Alsace": [
+ { name: "Colmar", coords: [7.36, 48.08], tagline: "Fairy-tale half-timbered town — heart of the Alsace wine route", grapes: ["Riesling", "Gewurztraminer"], notableWines: ["Domaine Weinbach", "Zind-Humbrecht", "Albert Boxler"] },
+ { name: "Riquewihr", coords: [7.30, 48.17], tagline: "One of France's most beautiful villages surrounded by Grand Cru vineyards", grapes: ["Riesling", "Pinot Gris"], notableWines: ["Hugel", "Trimbach", "Dopff au Moulin"] },
+ { name: "Strasbourg", coords: [7.75, 48.57], tagline: "European capital with Alsatian winstubs and the oldest wine cooperative", grapes: ["Riesling", "Sylvaner"], notableWines: ["Cave de Turckheim", "Wolfberger", "Arthur Metz"] },
+ { name: "Kaysersberg", coords: [7.26, 48.14], tagline: "Birthplace of Albert Schweitzer — steep Grand Cru Schlossberg above", grapes: ["Riesling"], notableWines: ["Domaine Weinbach", "Albert Mann", "Marc Tempé"] },
+ ],
+ "Provence": [
+ { name: "Aix-en-Provence", coords: [5.45, 43.53], tagline: "Cultural capital surrounded by rosé producers and Cézanne's landscapes", grapes: ["Grenache", "Cinsault", "Syrah"], notableWines: ["Château Simone", "Domaine de Trévallon", "Château Vignelaure"] },
+ { name: "Bandol", coords: [5.75, 43.14], tagline: "Mourvèdre-based reds and rosés from terraced Mediterranean hillsides", grapes: ["Mourvèdre"], notableWines: ["Domaine Tempier", "Château Pibarnon", "Château Pradeaux"] },
+ { name: "Cassis", coords: [5.54, 43.21], tagline: "Tiny fishing port with rare, mineral white wines — Marseillais' favorite", grapes: ["Marsanne", "Clairette"], notableWines: ["Clos Sainte Magdeleine", "Château de Fontcreuse", "Domaine du Bagnol"] },
+ { name: "Nice", coords: [7.26, 43.70], tagline: "Riviera hillside vineyards of Bellet — one of France's smallest AOCs", grapes: ["Braquet", "Rolle"], notableWines: ["Château de Bellet", "Château de Crémat", "Domaine de Toasc"] },
+ ],
+ "Veneto": [
+ { name: "Verona", coords: [10.99, 45.44], tagline: "City of Romeo & Juliet — gateway to Valpolicella and Amarone", grapes: ["Corvina", "Rondinella"], notableWines: ["Allegrini", "Bertani", "Masi"] },
+ { name: "Valpolicella", coords: [10.89, 45.52], tagline: "Cherry orchards and dried-grape Amarone — Veneto's most complex reds", grapes: ["Corvina", "Rondinella", "Molinara"], notableWines: ["Giuseppe Quintarelli", "Dal Forno Romano", "Allegrini"] },
+ { name: "Soave", coords: [11.25, 45.39], tagline: "Volcanic soils producing Italy's finest Garganega — crisp and mineral", grapes: ["Garganega"], notableWines: ["Pieropan", "Inama", "Gini"] },
+ { name: "Conegliano", coords: [12.30, 45.89], tagline: "Steep hills of Prosecco Superiore DOCG — Italy's sparkling wine heartland", grapes: ["Glera"], notableWines: ["Bisol", "Nino Franco", "Bortolomiol"] },
+ ],
+ "Sicily": [
+ { name: "Etna", coords: [15.00, 37.75], tagline: "Volcanic vineyards at 1000m altitude — Italy's most exciting wine frontier", grapes: ["Nerello Mascalese", "Carricante"], notableWines: ["Benanti", "Passopisciaro", "Tenuta delle Terre Nere"] },
+ { name: "Marsala", coords: [12.44, 37.80], tagline: "Fortified wine tradition revived — amber nectar from western Sicily", grapes: ["Grillo", "Catarratto"], notableWines: ["Marco De Bartoli", "Florio", "Pellegrino"] },
+ { name: "Noto", coords: [15.07, 36.89], tagline: "Baroque city with sun-drenched Nero d'Avola vineyards", grapes: ["Nero d'Avola"], notableWines: ["Planeta", "Feudo Maccari", "Marabino"] },
+ { name: "Palermo", coords: [13.36, 38.12], tagline: "Street food capital with nearby Monreale vineyards and indigenous grapes", grapes: ["Perricone", "Catarratto"], notableWines: ["Guiliana", "Alessandro di Camporeale", "Cusumano"] },
+ ],
+ "Ribera del Duero": [
+ { name: "Peñafiel", coords: [-4.11, 41.60], tagline: "Castle-topped town with Spain's wine museum — Tempranillo heartland", grapes: ["Tempranillo"], notableWines: ["Vega Sicilia", "Protos", "Pesquera"] },
+ { name: "Aranda de Duero", coords: [-3.69, 41.67], tagline: "Underground medieval cellars and lamb roasted over vine cuttings", grapes: ["Tempranillo"], notableWines: ["Dominio de Pingus", "Arzuaga", "Pago de los Capellanes"] },
+ { name: "Roa", coords: [-3.93, 41.69], tagline: "High-altitude plateau vineyards producing concentrated, powerful reds", grapes: ["Tempranillo"], notableWines: ["Aalto", "Emilio Moro", "Bodegas Alión"] },
+ ],
+ "Priorat": [
+ { name: "Gratallops", coords: [0.77, 41.17], tagline: "Steep llicorella slate slopes — the epicenter of Priorat's renaissance", grapes: ["Garnacha", "Cariñena"], notableWines: ["Álvaro Palacios L'Ermita", "Clos Mogador", "Clos Erasmus"] },
+ { name: "Porrera", coords: [0.88, 41.19], tagline: "Ancient Cariñena vines clinging to black slate at extreme altitudes", grapes: ["Cariñena", "Garnacha"], notableWines: ["Cims de Porrera", "Val Llach", "Mas d'en Gil"] },
+ { name: "Falset", coords: [0.82, 41.15], tagline: "Market town gateway to Priorat and the neighboring Montsant DO", grapes: ["Garnacha", "Syrah"], notableWines: ["Celler de Capçanes", "Cellers Fuentes", "Ficaria Vins"] },
+ ],
+ "Alentejo": [
+ { name: "Évora", coords: [-7.91, 38.57], tagline: "UNESCO World Heritage city — cork oak plains and clay amphora winemaking", grapes: ["Aragonez", "Trincadeira"], notableWines: ["Herdade do Esporão", "João Portugal Ramos", "Cartuxa"] },
+ { name: "Estremoz", coords: [-7.59, 38.84], tagline: "Marble town with high-altitude vineyards and fresh, mineral whites", grapes: ["Antão Vaz", "Arinto"], notableWines: ["Quinta do Mouro", "Herdade do Mouchão", "Susana Esteban"] },
+ { name: "Reguengos de Monsaraz", coords: [-7.53, 38.43], tagline: "Lakeside vineyards near the stunning medieval village of Monsaraz", grapes: ["Aragonez", "Alicante Bouschet"], notableWines: ["Herdade do Esporão", "Ervideira", "Herdade da Calada"] },
+ ],
+ "Rheingau": [
+ { name: "Rüdesheim", coords: [7.93, 49.98], tagline: "Tourist gateway with steep Rhine-facing slopes and the Drosselgasse", grapes: ["Riesling"], notableWines: ["Georg Breuer", "Leitz", "Schloss Johannisberg"] },
+ { name: "Eltville", coords: [8.12, 50.03], tagline: "Rose gardens and riverside Riesling — birthplace of Gutenberg's printer", grapes: ["Riesling"], notableWines: ["Robert Weil", "Langwerth von Simmern", "J.B. Becker"] },
+ { name: "Johannisberg", coords: [8.00, 50.00], tagline: "Schloss Johannisberg — where late-harvest Riesling was accidentally invented", grapes: ["Riesling"], notableWines: ["Schloss Johannisberg", "Prinz von Hessen", "Domdechant Werner"] },
+ ],
+ "Sonoma": [
+ { name: "Healdsburg", coords: [-122.87, 38.61], tagline: "Charming plaza town at the crossroads of three premier AVAs", grapes: ["Pinot Noir", "Zinfandel", "Chardonnay"], notableWines: ["Ridge", "Seghesio", "Jordan"] },
+ { name: "Sonoma", coords: [-122.46, 38.29], tagline: "Historic town plaza where California wine began — Buena Vista 1857", grapes: ["Pinot Noir", "Chardonnay"], notableWines: ["Buena Vista", "Hanzell", "Ravenswood"] },
+ { name: "Sebastopol", coords: [-122.82, 38.40], tagline: "Cool-climate Sonoma Coast — foggy mornings perfect for Pinot and Chardonnay", grapes: ["Pinot Noir", "Chardonnay"], notableWines: ["Littorai", "Freeman", "Iron Horse"] },
+ { name: "Glen Ellen", coords: [-122.53, 38.36], tagline: "Jack London's 'Valley of the Moon' — rustic charm and old-vine Zinfandel", grapes: ["Zinfandel", "Cabernet Sauvignon"], notableWines: ["Benziger", "B.R. Cohn", "Laurel Glen"] },
+ ],
+ "Willamette Valley": [
+ { name: "Dundee", coords: [-123.01, 45.28], tagline: "Red volcanic Jory soil — Oregon's original Pinot Noir heartland", grapes: ["Pinot Noir"], notableWines: ["Domaine Drouhin", "Domaine Serene", "Archery Summit"] },
+ { name: "McMinnville", coords: [-123.20, 45.21], tagline: "Basalt-bedded foothills producing structured, age-worthy Pinot Noir", grapes: ["Pinot Noir"], notableWines: ["Eyrie Vineyards", "Antica Terra", "Maysara"] },
+ { name: "Carlton", coords: [-123.18, 45.29], tagline: "Small-town tasting rooms with some of Oregon's most sought-after Pinots", grapes: ["Pinot Noir", "Pinot Gris"], notableWines: ["Ken Wright", "Penner-Ash", "Lemelson"] },
+ ],
+ "Mendoza": [
+ { name: "Luján de Cuyo", coords: [-68.87, -33.04], tagline: "First region officially recognized for Malbec — high-altitude old vines", grapes: ["Malbec"], notableWines: ["Catena Zapata", "Achaval-Ferrer", "Luigi Bosca"] },
+ { name: "Valle de Uco", coords: [-69.25, -33.63], tagline: "Andes foothills at 1200m+ altitude — Argentina's most exciting terroir", grapes: ["Malbec", "Cabernet Franc"], notableWines: ["Zuccardi", "Salentein", "Clos de los Siete"] },
+ { name: "Maipú", coords: [-68.75, -32.94], tagline: "Historic wine district with olive groves — Mendoza's first vineyards", grapes: ["Malbec", "Bonarda"], notableWines: ["Trapiche", "Familia Zuccardi", "Norton"] },
+ ],
+ "Maipo Valley": [
+ { name: "Santiago", coords: [-70.65, -33.45], tagline: "Capital city with world-class wineries just minutes from downtown", grapes: ["Cabernet Sauvignon"], notableWines: ["Concha y Toro Don Melchor", "Santa Rita", "Cousiño Macul"] },
+ { name: "Buin", coords: [-70.74, -33.73], tagline: "Heart of Alto Maipo — Chile's finest Cabernet Sauvignon territory", grapes: ["Cabernet Sauvignon", "Carmenère"], notableWines: ["Almaviva", "Seña", "Santa Alicia"] },
+ { name: "Pirque", coords: [-70.58, -33.64], tagline: "Andean-cooled vineyards producing elegant, complex Bordeaux-style blends", grapes: ["Cabernet Sauvignon", "Carmenère"], notableWines: ["Antiyal", "Haras de Pirque", "Viña Maipo"] },
+ ],
+ "Colchagua Valley": [
+ { name: "Santa Cruz", coords: [-71.37, -34.64], tagline: "Wine country capital with Colchagua Museum and harvest festival", grapes: ["Carmenère", "Cabernet Sauvignon"], notableWines: ["Montes", "Casa Lapostolle", "MontGras"] },
+ { name: "Marchigüe", coords: [-71.62, -34.39], tagline: "Coastal influence producing Chile's finest Syrah", grapes: ["Syrah", "Carmenère"], notableWines: ["Viña Vik", "Casa Silva", "Emiliana"] },
+ { name: "Lolol", coords: [-71.64, -34.73], tagline: "Traditional village with dry-farmed old vines and emerging artisan wineries", grapes: ["Carignan", "País"], notableWines: ["Polkura", "Boya", "Viu Manent"] },
+ ],
+ "Barossa Valley": [
+ { name: "Tanunda", coords: [138.96, -34.53], tagline: "German heritage town surrounded by 150-year-old Shiraz vines", grapes: ["Shiraz"], notableWines: ["Penfolds", "Henschke", "Peter Lehmann"] },
+ { name: "Angaston", coords: [139.05, -34.50], tagline: "Eastern ridge with cooler sites — Yalumba's historic home since 1849", grapes: ["Shiraz", "Viognier"], notableWines: ["Yalumba", "Saltram", "Mountadam"] },
+ { name: "Nuriootpa", coords: [138.99, -34.48], tagline: "Valley floor hub with iconic Australian wineries and cellar doors", grapes: ["Shiraz", "Grenache"], notableWines: ["Penfolds Grange", "Torbreck", "Kaesler"] },
+ ],
+ "Margaret River": [
+ { name: "Margaret River", coords: [115.04, -33.95], tagline: "Surf town surrounded by premium estates — Australia's Bordeaux", grapes: ["Cabernet Sauvignon", "Chardonnay"], notableWines: ["Leeuwin Estate", "Cullen", "Vasse Felix"] },
+ { name: "Yallingup", coords: [115.03, -33.65], tagline: "Northern Margaret River with limestone caves and maritime-influenced vines", grapes: ["Cabernet Sauvignon", "Sauvignon Blanc"], notableWines: ["Cape Mentelle", "Windance", "Amelia Park"] },
+ { name: "Cowaramup", coords: [115.09, -33.85], tagline: "Cow statue-lined streets and a cluster of boutique family wineries", grapes: ["Cabernet Sauvignon", "Shiraz"], notableWines: ["Voyager Estate", "Brookland Valley", "Howard Park"] },
+ ],
+ "Marlborough": [
+ { name: "Blenheim", coords: [173.96, -41.51], tagline: "Sunny wine capital — the epicenter of New Zealand Sauvignon Blanc", grapes: ["Sauvignon Blanc"], notableWines: ["Cloudy Bay", "Villa Maria", "Brancott Estate"] },
+ { name: "Renwick", coords: [173.83, -41.50], tagline: "Wairau Valley sub-region with intense, citrus-driven Sauvignon Blanc", grapes: ["Sauvignon Blanc", "Pinot Noir"], notableWines: ["Greywacke", "Framingham", "Allan Scott"] },
+ { name: "Seddon", coords: [174.07, -41.66], tagline: "Awatere Valley — windswept, cooler climate producing herbaceous, mineral wines", grapes: ["Sauvignon Blanc", "Pinot Gris"], notableWines: ["Yealands", "Vavasour", "Nautilus"] },
+ ],
+ "Stellenbosch": [
+ { name: "Stellenbosch", coords: [18.86, -33.93], tagline: "University town with Cape Dutch architecture and 200+ wine estates", grapes: ["Cabernet Sauvignon", "Pinotage"], notableWines: ["Kanonkop", "Rust en Vrede", "Meerlust"] },
+ { name: "Franschhoek", coords: [19.12, -33.91], tagline: "French Huguenot valley — South Africa's culinary and wine capital", grapes: ["Chenin Blanc", "Semillon", "Syrah"], notableWines: ["Boekenhoutskloof", "La Motte", "Mullineux"] },
+ { name: "Paarl", coords: [18.97, -33.73], tagline: "Granite domes and warm slopes — home to KWV and Fairview", grapes: ["Shiraz", "Chenin Blanc"], notableWines: ["Fairview", "Glen Carlou", "Nederburg"] },
+ ],
+};
+
+type WineRegionMapProps = {
+ onRegionClick?: (region: string, country: string) => void;
+ regionCounts?: Record;
+ height?: string;
+ className?: string;
+ exploreRegion?: string | null;
+ /** Fly to specific coordinates (city hopping) */
+ flyToCoords?: [number, number] | null;
+ /** Trigger a cinematic tour of a region's sub-cities */
+ tourRegion?: string | null;
+ /** Called when tour ends */
+ onTourEnd?: () => void;
+ /** Toggle satellite imagery view */
+ satellite?: boolean;
+ /** Called with current tour stop info (null = in-flight between stops) */
+ onTourStop?: (stop: TourStop | null) => void;
+ /** Expose the internal Mapbox map instance to parent */
+ mapRef?: React.RefObject;
+};
+
+const STYLE_STANDARD = "mapbox://styles/mapbox/standard";
+const STYLE_SATELLITE = "mapbox://styles/mapbox/standard-satellite";
+
+/** Get sub-cities for a region */
+export function getRegionCities(region: string) {
+ return REGION_SUB_CITIES[region] ?? [];
+}
+
+/* City centers for each region */
+export const REGION_CITIES: Record = {
+ "Bordeaux": [-0.58, 44.84], "Burgundy": [4.84, 47.02], "Champagne": [3.96, 49.25],
+ "Rhone Valley": [4.83, 44.93], "Loire Valley": [0.69, 47.38], "Alsace": [7.35, 48.08],
+ "Provence": [5.93, 43.53], "Piedmont": [7.68, 44.69], "Tuscany": [11.25, 43.77],
+ "Veneto": [11.87, 45.44], "Sicily": [13.36, 37.60], "Rioja": [-2.73, 42.47],
+ "Ribera del Duero": [-3.69, 41.63], "Priorat": [0.75, 41.20], "Douro Valley": [-7.79, 41.16],
+ "Alentejo": [-7.91, 38.57], "Mosel": [6.63, 49.73], "Rheingau": [8.06, 50.01],
+ "Napa Valley": [-122.31, 38.50], "Sonoma": [-122.72, 38.44], "Willamette Valley": [-123.09, 45.07],
+ "Mendoza": [-68.83, -32.89], "Maipo Valley": [-70.60, -33.73], "Colchagua Valley": [-71.22, -34.66],
+ "Barossa Valley": [138.95, -34.56], "Margaret River": [115.04, -33.95],
+ "Marlborough": [173.95, -41.51], "Stellenbosch": [18.86, -33.93],
+};
+
+export function WineRegionMap({ onRegionClick, regionCounts, height = "100%", className = "", exploreRegion, flyToCoords, tourRegion, onTourEnd, satellite = false, onTourStop, mapRef }: WineRegionMapProps) {
+ const mapContainer = useRef(null);
+ const map = useRef(null);
+ const popup = useRef(null);
+ const mapLoaded = useRef(false);
+ const exploreRegionRef = useRef(exploreRegion);
+ const tourAbort = useRef(null);
+ const isMobileRef = useRef(false);
+
+ // Refs for values used inside map event closures
+ const onRegionClickRef = useRef(onRegionClick);
+ onRegionClickRef.current = onRegionClick;
+ const regionCountsRef = useRef(regionCounts);
+ regionCountsRef.current = regionCounts;
+ const onTourEndRef = useRef(onTourEnd);
+ onTourEndRef.current = onTourEnd;
+ const onTourStopRef = useRef(onTourStop);
+ onTourStopRef.current = onTourStop;
+
+ // City hopping — only flyTo, don't touch region visibility
+ useEffect(() => {
+ if (!flyToCoords || !map.current || !mapLoaded.current) return;
+ map.current.flyTo({ center: flyToCoords, zoom: 13, pitch: 50, duration: 1200 });
+ }, [flyToCoords]);
+
+ // ── Cinematic tour ──
+ const runTour = useCallback(async (regionName: string, signal: AbortSignal) => {
+ if (!map.current || !mapLoaded.current) return;
+ const cities = REGION_SUB_CITIES[regionName];
+ if (!cities || cities.length === 0) return;
+
+ // Zoom out to overview first
+ const regionCenter = REGION_CITIES[regionName] ?? cities[0].coords;
+ onTourStopRef.current?.(null); // in-flight
+ await flyAndWait(map.current, { center: regionCenter, zoom: 10, pitch: 60, bearing: -20, duration: 2500 }, signal);
+
+ // Sweep through each sub-city
+ for (let i = 0; i < cities.length; i++) {
+ if (signal.aborted) break;
+ const city = cities[i];
+ const bearing = -20 + (i * 40); // Rotate camera as we hop
+ onTourStopRef.current?.(null); // in-flight
+ await flyAndWait(map.current!, {
+ center: city.coords,
+ zoom: 14,
+ pitch: 60,
+ bearing,
+ duration: 3500,
+ essential: true,
+ }, signal);
+ if (signal.aborted) break;
+ // Show info card after camera settles
+ onTourStopRef.current?.(city);
+ // Dwell — enough time to read the card
+ await delay(5500, signal);
+ }
+
+ // Clear card before returning to overview
+ onTourStopRef.current?.(null);
+
+ // Return to region overview
+ if (!signal.aborted && map.current) {
+ await flyAndWait(map.current, { center: regionCenter, zoom: 11, pitch: 45, bearing: 0, duration: 2500 }, signal);
+ }
+
+ onTourStopRef.current?.(null);
+ onTourEndRef.current?.();
+ }, []);
+
+ useEffect(() => {
+ if (!tourRegion) {
+ tourAbort.current?.abort();
+ tourAbort.current = null;
+ return;
+ }
+ // Cancel any running tour
+ tourAbort.current?.abort();
+ const ctrl = new AbortController();
+ tourAbort.current = ctrl;
+ runTour(tourRegion, ctrl.signal);
+ return () => ctrl.abort();
+ }, [tourRegion, runTour]);
+
+ // ── Add custom layers (called on initial load AND after style swap) ──
+ function addCustomLayers(m: mapboxgl.Map) {
+ // Wine region polygons (bottom slot — below roads & buildings)
+ if (!m.getSource("wine-regions")) {
+ m.addSource("wine-regions", {
+ type: "geojson",
+ data: wineRegions as GeoJSON.FeatureCollection,
+ });
+ }
+
+ if (!m.getLayer("wine-regions-fill")) {
+ m.addLayer({
+ id: "wine-regions-fill", type: "fill", slot: "middle", source: "wine-regions",
+ paint: {
+ "fill-color": ["get", "color"],
+ "fill-opacity": ["case", ["boolean", ["feature-state", "hover"], false], 0.55, 0.35],
+ "fill-emissive-strength": 1.0,
+ },
+ } as mapboxgl.LayerSpecification);
+ }
+
+ if (!m.getLayer("wine-regions-border")) {
+ m.addLayer({
+ id: "wine-regions-border", type: "line", slot: "middle", source: "wine-regions",
+ paint: {
+ "line-color": ["get", "color"],
+ "line-width": ["case", ["boolean", ["feature-state", "hover"], false], 3, 2],
+ "line-opacity": 0.85, "line-emissive-strength": 1.0,
+ },
+ } as mapboxgl.LayerSpecification);
+ }
+
+ if (!m.getLayer("wine-regions-label")) {
+ m.addLayer({
+ id: "wine-regions-label", type: "symbol", slot: "top", source: "wine-regions",
+ layout: {
+ "text-field": ["get", "name"],
+ "text-size": ["interpolate", ["linear"], ["zoom"], 3, 11, 5, 14, 8, 17],
+ "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
+ "text-allow-overlap": false,
+ },
+ paint: {
+ "text-color": "#74070E",
+ "text-opacity": ["interpolate", ["linear"], ["zoom"], 3, 0.8, 5, 1.0],
+ "text-halo-color": "#FFFFFF", "text-halo-width": 2.5, "text-emissive-strength": 1.0,
+ },
+ } as mapboxgl.LayerSpecification);
+ }
+
+ // Add our own streets vector source — Standard's "composite" doesn't reliably
+ // expose poi_label for custom layers. We add mapbox-streets-v8 explicitly.
+ if (!m.getSource("wb-streets")) {
+ m.addSource("wb-streets", {
+ type: "vector",
+ url: "mapbox://mapbox.mapbox-streets-v8",
+ });
+ }
+
+ // POI layers (top slot — must be above Standard's buildings/roads to be visible)
+ const poiDefs: { id: string; cls: string; color: string; rank: number }[] = [
+ { id: "poi-food", cls: "food_and_drink", color: "#74070E", rank: 3 },
+ { id: "poi-hotel", cls: "lodging", color: "#8B6914", rank: 3 },
+ { id: "poi-shops", cls: "food_and_drink_stores", color: "#6B3A2A", rank: 3 },
+ ];
+ const labelDefs: { id: string; cls: string; color: string; rank: number }[] = [
+ { id: "poi-food-label", cls: "food_and_drink", color: "#5A0408", rank: 2 },
+ { id: "poi-hotel-label", cls: "lodging", color: "#6B5010", rank: 2 },
+ ];
+
+ for (const d of poiDefs) {
+ if (m.getLayer(d.id)) continue;
+ m.addLayer({
+ id: d.id, type: "circle", slot: "top", source: "wb-streets", "source-layer": "poi_label",
+ filter: ["all", ["==", ["get", "class"], d.cls], ["<=", ["get", "filterrank"], d.rank]],
+ minzoom: 8,
+ paint: {
+ "circle-color": d.color,
+ "circle-radius": ["interpolate", ["linear"], ["zoom"], 8, 4, 10, 7, 12, 9, 14, 12],
+ "circle-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.7, 10, 0.9, 12, 1],
+ "circle-stroke-color": "#FFFFFF",
+ "circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 8, 1.5, 10, 2, 12, 2.5],
+ "circle-stroke-opacity": 1,
+ "circle-emissive-strength": 1.0,
+ },
+ } as mapboxgl.LayerSpecification);
+ }
+
+ for (const d of labelDefs) {
+ if (m.getLayer(d.id)) continue;
+ m.addLayer({
+ id: d.id, type: "symbol", slot: "top", source: "wb-streets", "source-layer": "poi_label",
+ filter: ["all", ["==", ["get", "class"], d.cls], ["<=", ["get", "filterrank"], d.rank]],
+ minzoom: 10,
+ layout: {
+ "text-field": ["get", "name"],
+ "text-size": ["interpolate", ["linear"], ["zoom"], 10, 11, 14, 14],
+ "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
+ "text-offset": [0, 1.4], "text-anchor": "top", "text-allow-overlap": false,
+ },
+ paint: {
+ "text-color": d.color,
+ "text-opacity": ["interpolate", ["linear"], ["zoom"], 10, 0.8, 12, 1],
+ "text-halo-color": "#FFFFFF", "text-halo-width": 2, "text-emissive-strength": 1.0,
+ },
+ } as mapboxgl.LayerSpecification);
+ }
+
+ // Restore region visibility state
+ if (exploreRegionRef.current) {
+ setRegionVisibility(false);
+ }
+ }
+
+ useEffect(() => {
+ if (!mapContainer.current || !MAPBOX_TOKEN) return;
+
+ mapboxgl.accessToken = MAPBOX_TOKEN;
+
+ isMobileRef.current = window.matchMedia("(max-width: 1024px)").matches || navigator.maxTouchPoints > 0;
+
+ map.current = new mapboxgl.Map({
+ container: mapContainer.current,
+ style: STYLE_STANDARD,
+ config: {
+ basemap: {
+ lightPreset: "dawn",
+ showPointOfInterestLabels: false, // We render our own wine-relevant POIs
+ showRoadLabels: false, // Remove road number clutter
+ showPlaceLabels: true, // Keep country/city names for orientation
+ showTransitLabels: false,
+ },
+ } as Record>,
+ center: [12, 44],
+ zoom: 3.5,
+ minZoom: 1.5,
+ maxZoom: 17,
+ attributionControl: false,
+ pitch: 30,
+ });
+
+ popup.current = new mapboxgl.Popup({
+ closeButton: false,
+ closeOnClick: true,
+ offset: 8,
+ className: "wb-popup",
+ });
+
+ // Re-add custom layers after every style swap (Standard <-> Satellite)
+ map.current.on("style.load", () => {
+ if (!map.current) return;
+
+ // Add terrain (desktop only)
+ if (!isMobileRef.current && !map.current.getSource("mapbox-dem")) {
+ try {
+ map.current.addSource("mapbox-dem", {
+ type: "raster-dem",
+ url: "mapbox://mapbox.mapbox-terrain-dem-v1",
+ tileSize: 512,
+ maxzoom: 14,
+ });
+ map.current.setTerrain({ source: "mapbox-dem", exaggeration: 1.5 });
+ } catch { /* terrain not supported */ }
+ }
+
+ addCustomLayers(map.current);
+ mapLoaded.current = true;
+ });
+
+ map.current.on("load", () => {
+ if (!map.current) return;
+ mapLoaded.current = true;
+
+ // Expose map instance to parent via mapRef
+ if (mapRef) {
+ (mapRef as React.MutableRefObject).current = map.current;
+ }
+
+ let hoveredId: string | number | null = null;
+
+ map.current.on("mousemove", "wine-regions-fill", (e) => {
+ if (!map.current || !e.features?.length) return;
+ map.current.getCanvas().style.cursor = "pointer";
+
+ const newId = e.features[0].id ?? null;
+
+ if (newId !== hoveredId) {
+ if (hoveredId !== null) {
+ map.current.setFeatureState({ source: "wine-regions", id: hoveredId }, { hover: false });
+ }
+ hoveredId = newId;
+ if (hoveredId !== null) {
+ map.current.setFeatureState({ source: "wine-regions", id: hoveredId }, { hover: true });
+ }
+
+ const props = e.features[0].properties;
+ if (props && popup.current && map.current) {
+ const count = regionCountsRef.current?.[props.name] ?? 0;
+ popup.current
+ .setHTML(`
+
+
${props.name}
+
${props.country} · ${props.grapes}
+ ${count > 0 ? `
${count} wines
` : ""}
+
+ `)
+ .addTo(map.current);
+ }
+ }
+
+ popup.current?.setLngLat(e.lngLat);
+ });
+
+ map.current.on("mouseleave", "wine-regions-fill", () => {
+ if (!map.current) return;
+ map.current.getCanvas().style.cursor = "";
+ if (hoveredId !== null) {
+ map.current.setFeatureState({ source: "wine-regions", id: hoveredId }, { hover: false });
+ }
+ hoveredId = null;
+ popup.current?.remove();
+ });
+
+ map.current.on("click", "wine-regions-fill", (e) => {
+ if (!e.features?.length) return;
+ const props = e.features[0].properties;
+ if (props) onRegionClickRef.current?.(props.name, props.country);
+ });
+
+ // ── POI interactions ──
+ const poiLayers = ["poi-food", "poi-hotel", "poi-shops"];
+
+ const poiMeta: Record = {
+ "poi-food": { icon: "🍽️", label: "Restaurant", accent: "#74070E" },
+ "poi-hotel": { icon: "🏨", label: "Hotel", accent: "#C8A255" },
+ "poi-shops": { icon: "🍷", label: "Wine Shop", accent: "#8B5A4A" },
+ };
+
+ for (const layerId of poiLayers) {
+ map.current.on("mouseenter", layerId, () => {
+ if (map.current) map.current.getCanvas().style.cursor = "pointer";
+ });
+ map.current.on("mouseleave", layerId, () => {
+ if (map.current) map.current.getCanvas().style.cursor = "";
+ popup.current?.remove();
+ });
+
+ map.current.on("click", layerId, (e) => {
+ if (!map.current || !e.features?.length) return;
+ const p = e.features[0].properties as Record;
+ const name = p?.name ?? "Unknown";
+ const cat = p?.category_en ?? p?.type ?? p?.class ?? "";
+ const meta = poiMeta[layerId];
+ const address = p?.address ?? "";
+
+ popup.current
+ ?.setLngLat(e.lngLat)
+ .setHTML(`
+
+
+
${meta.icon}
+
+
${name}
+
${cat || meta.label}
+
+
+ ${address ? `
${address}
` : ""}
+
+ `)
+ .addTo(map.current!);
+ });
+ }
+ });
+
+ return () => { popup.current?.remove(); tourAbort.current?.abort(); map.current?.remove(); map.current = null; mapLoaded.current = false; if (mapRef) { (mapRef as React.MutableRefObject).current = null; } };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // ── Satellite toggle: swap style, layers re-added via style.load handler ──
+ const prevSatRef = useRef(satellite);
+ useEffect(() => {
+ if (!map.current || satellite === prevSatRef.current) return;
+ prevSatRef.current = satellite;
+ map.current.setStyle(satellite ? STYLE_SATELLITE : STYLE_STANDARD);
+ }, [satellite]);
+
+ /* Helper: hide or show region layers */
+ function setRegionVisibility(visible: boolean) {
+ if (!map.current || !mapLoaded.current) return;
+ try {
+ map.current.setPaintProperty("wine-regions-fill", "fill-opacity", visible ? ["case", ["boolean", ["feature-state", "hover"], false], 0.55, 0.35] : 0);
+ map.current.setLayoutProperty("wine-regions-fill", "visibility", visible ? "visible" : "none");
+ map.current.setPaintProperty("wine-regions-border", "line-opacity", visible ? 0.85 : 0);
+ map.current.setLayoutProperty("wine-regions-border", "visibility", visible ? "visible" : "none");
+ map.current.setLayoutProperty("wine-regions-label", "visibility", visible ? "visible" : "none");
+ } catch {}
+ }
+
+ // Explore region: fly + hide polygons
+ const prevExploreRef = useRef(null);
+ exploreRegionRef.current = exploreRegion;
+ useEffect(() => {
+ if (!map.current || !mapLoaded.current) return;
+ if (exploreRegion === prevExploreRef.current) return;
+ prevExploreRef.current = exploreRegion;
+
+ if (!exploreRegion) {
+ setRegionVisibility(true);
+ map.current.flyTo({ center: [12, 44], zoom: 3.5, pitch: 30, bearing: 0, duration: 1200 });
+ return;
+ }
+
+ setRegionVisibility(false);
+
+ const subCities = REGION_SUB_CITIES[exploreRegion];
+ const firstCity = subCities?.[0]?.coords;
+ const regionCity = REGION_CITIES[exploreRegion];
+ const target = firstCity ?? regionCity;
+
+ if (target) {
+ map.current.flyTo({ center: target, zoom: 13, pitch: 50, duration: 2000, essential: true });
+ } else {
+ const feature = wineRegions.features.find((f) => f.properties.name === exploreRegion);
+ if (feature) {
+ const coords = feature.geometry.coordinates[0];
+ let sumLng = 0, sumLat = 0;
+ for (const [lng, lat] of coords) { sumLng += lng; sumLat += lat; }
+ map.current.flyTo({ center: [sumLng / coords.length, sumLat / coords.length], zoom: 12, pitch: 50, duration: 2000, essential: true });
+ }
+ }
+ }, [exploreRegion]);
+
+ // ── Fallback without token ──
+ if (!MAPBOX_TOKEN) {
+ const regions = wineRegions.features.map((f) => f.properties);
+ return (
+
+
+ Wine Regions of the World
+
+
+ {regions.map((r) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+// ── Helpers for cinematic tours ──
+
+/** Promisified flyTo that resolves when animation ends */
+function flyAndWait(m: mapboxgl.Map, opts: Parameters[0], signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ if (signal.aborted) { resolve(); return; }
+ const onAbort = () => { m.stop(); resolve(); };
+ signal.addEventListener("abort", onAbort, { once: true });
+ m.once("moveend", () => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ });
+ m.flyTo(opts);
+ });
+}
+
+/** Abortable delay */
+function delay(ms: number, signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ if (signal.aborted) { resolve(); return; }
+ const timer = setTimeout(resolve, ms);
+ signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
+ });
+}
diff --git a/winebob/src/data/regionFlavors.ts b/winebob/src/data/regionFlavors.ts
new file mode 100644
index 0000000..7f2219a
--- /dev/null
+++ b/winebob/src/data/regionFlavors.ts
@@ -0,0 +1,73 @@
+export type FlavorProfile = {
+ region: string;
+ country: string;
+ acidity: number; // 0-1
+ tannin: number;
+ body: number;
+ fruit: number;
+ earth: number;
+ floral: number;
+};
+
+export const FLAVOR_AXES: (keyof Omit)[] = [
+ "acidity", "tannin", "body", "fruit", "earth", "floral",
+];
+
+export const FLAVOR_LABELS: Record = {
+ acidity: "Acidity",
+ tannin: "Tannin",
+ body: "Body",
+ fruit: "Fruit",
+ earth: "Earth",
+ floral: "Floral",
+};
+
+export const REGION_FLAVORS: FlavorProfile[] = [
+ // ── France ──
+ { region: "Bordeaux", country: "France", acidity: 0.6, tannin: 0.8, body: 0.8, fruit: 0.6, earth: 0.5, floral: 0.3 },
+ { region: "Burgundy", country: "France", acidity: 0.8, tannin: 0.5, body: 0.5, fruit: 0.7, earth: 0.7, floral: 0.6 },
+ { region: "Champagne", country: "France", acidity: 0.9, tannin: 0.1, body: 0.3, fruit: 0.6, earth: 0.3, floral: 0.7 },
+ { region: "Rhone Valley", country: "France", acidity: 0.5, tannin: 0.7, body: 0.85, fruit: 0.7, earth: 0.6, floral: 0.4 },
+ { region: "Loire Valley", country: "France", acidity: 0.85, tannin: 0.2, body: 0.35, fruit: 0.7, earth: 0.4, floral: 0.75 },
+ { region: "Alsace", country: "France", acidity: 0.8, tannin: 0.1, body: 0.4, fruit: 0.7, earth: 0.3, floral: 0.85 },
+ { region: "Provence", country: "France", acidity: 0.65, tannin: 0.2, body: 0.35, fruit: 0.75, earth: 0.25, floral: 0.8 },
+
+ // ── Italy ──
+ { region: "Tuscany", country: "Italy", acidity: 0.75, tannin: 0.75, body: 0.7, fruit: 0.6, earth: 0.6, floral: 0.35 },
+ { region: "Piedmont", country: "Italy", acidity: 0.8, tannin: 0.9, body: 0.8, fruit: 0.5, earth: 0.7, floral: 0.5 },
+ { region: "Veneto", country: "Italy", acidity: 0.6, tannin: 0.6, body: 0.75, fruit: 0.75, earth: 0.4, floral: 0.3 },
+ { region: "Sicily", country: "Italy", acidity: 0.55, tannin: 0.55, body: 0.7, fruit: 0.8, earth: 0.5, floral: 0.35 },
+
+ // ── Spain ──
+ { region: "Rioja", country: "Spain", acidity: 0.6, tannin: 0.65, body: 0.7, fruit: 0.6, earth: 0.55, floral: 0.35 },
+ { region: "Ribera del Duero", country: "Spain", acidity: 0.55, tannin: 0.8, body: 0.85, fruit: 0.55, earth: 0.6, floral: 0.25 },
+ { region: "Priorat", country: "Spain", acidity: 0.5, tannin: 0.75, body: 0.9, fruit: 0.65, earth: 0.7, floral: 0.2 },
+
+ // ── Portugal ──
+ { region: "Douro Valley", country: "Portugal", acidity: 0.5, tannin: 0.7, body: 0.85, fruit: 0.75, earth: 0.55, floral: 0.25 },
+ { region: "Alentejo", country: "Portugal", acidity: 0.45, tannin: 0.6, body: 0.8, fruit: 0.7, earth: 0.5, floral: 0.3 },
+
+ // ── Germany ──
+ { region: "Mosel", country: "Germany", acidity: 0.95, tannin: 0.05, body: 0.2, fruit: 0.8, earth: 0.4, floral: 0.8 },
+ { region: "Rheingau", country: "Germany", acidity: 0.85, tannin: 0.05, body: 0.35, fruit: 0.75, earth: 0.45, floral: 0.7 },
+
+ // ── USA ──
+ { region: "Napa Valley", country: "USA", acidity: 0.5, tannin: 0.8, body: 0.9, fruit: 0.85, earth: 0.3, floral: 0.2 },
+ { region: "Sonoma", country: "USA", acidity: 0.6, tannin: 0.6, body: 0.7, fruit: 0.8, earth: 0.35, floral: 0.4 },
+ { region: "Willamette Valley", country: "USA", acidity: 0.8, tannin: 0.4, body: 0.45, fruit: 0.7, earth: 0.6, floral: 0.55 },
+
+ // ── South America ──
+ { region: "Mendoza", country: "Argentina", acidity: 0.5, tannin: 0.7, body: 0.85, fruit: 0.8, earth: 0.4, floral: 0.3 },
+ { region: "Maipo Valley", country: "Chile", acidity: 0.55, tannin: 0.75, body: 0.8, fruit: 0.7, earth: 0.45, floral: 0.25 },
+ { region: "Colchagua Valley", country: "Chile", acidity: 0.5, tannin: 0.65, body: 0.8, fruit: 0.8, earth: 0.35, floral: 0.3 },
+
+ // ── Australia ──
+ { region: "Barossa Valley", country: "Australia", acidity: 0.45, tannin: 0.75, body: 0.9, fruit: 0.85, earth: 0.45, floral: 0.2 },
+ { region: "Margaret River", country: "Australia", acidity: 0.6, tannin: 0.7, body: 0.75, fruit: 0.7, earth: 0.4, floral: 0.35 },
+
+ // ── New Zealand ──
+ { region: "Marlborough", country: "New Zealand", acidity: 0.9, tannin: 0.05, body: 0.3, fruit: 0.9, earth: 0.2, floral: 0.8 },
+
+ // ── South Africa ──
+ { region: "Stellenbosch", country: "South Africa", acidity: 0.55, tannin: 0.7, body: 0.8, fruit: 0.7, earth: 0.5, floral: 0.3 },
+];
diff --git a/winebob/src/data/wineRegions.ts b/winebob/src/data/wineRegions.ts
new file mode 100644
index 0000000..23824b5
--- /dev/null
+++ b/winebob/src/data/wineRegions.ts
@@ -0,0 +1,712 @@
+export type WineRegionFeature = {
+ type: "Feature";
+ properties: {
+ name: string;
+ country: string;
+ grapes: string;
+ wineCount: number;
+ color: string;
+ };
+ geometry: {
+ type: "Polygon";
+ coordinates: number[][][];
+ };
+};
+
+export type WineRegionCollection = {
+ type: "FeatureCollection";
+ features: WineRegionFeature[];
+};
+
+export const wineRegions: WineRegionCollection = {
+ type: "FeatureCollection",
+ features: [
+ // =====================
+ // FRANCE
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Bordeaux",
+ country: "France",
+ grapes: "Cabernet Sauvignon, Merlot, Cabernet Franc, Petit Verdot, Malbec",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-1.2, 44.5],
+ [-0.1, 44.5],
+ [-0.1, 45.3],
+ [-0.6, 45.6],
+ [-1.2, 45.3],
+ [-1.2, 44.5],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Burgundy",
+ country: "France",
+ grapes: "Pinot Noir, Chardonnay, Gamay, Aligote",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [4.2, 46.0],
+ [4.9, 46.0],
+ [4.9, 47.4],
+ [4.5, 47.5],
+ [4.2, 47.2],
+ [4.2, 46.0],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Champagne",
+ country: "France",
+ grapes: "Chardonnay, Pinot Noir, Pinot Meunier",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [3.3, 48.8],
+ [4.3, 48.8],
+ [4.3, 49.4],
+ [3.8, 49.5],
+ [3.3, 49.3],
+ [3.3, 48.8],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Rhone Valley",
+ country: "France",
+ grapes: "Syrah, Grenache, Mourvedre, Viognier, Marsanne",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [4.4, 43.7],
+ [5.0, 43.7],
+ [5.0, 45.5],
+ [4.7, 45.6],
+ [4.4, 45.4],
+ [4.4, 43.7],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Loire Valley",
+ country: "France",
+ grapes: "Sauvignon Blanc, Chenin Blanc, Cabernet Franc, Melon de Bourgogne",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-1.5, 47.0],
+ [2.5, 47.0],
+ [2.5, 47.6],
+ [1.0, 47.8],
+ [-1.5, 47.5],
+ [-1.5, 47.0],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Alsace",
+ country: "France",
+ grapes: "Riesling, Gewurztraminer, Pinot Gris, Muscat, Pinot Blanc",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [7.1, 47.9],
+ [7.6, 47.9],
+ [7.6, 48.9],
+ [7.4, 49.0],
+ [7.1, 48.8],
+ [7.1, 47.9],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Provence",
+ country: "France",
+ grapes: "Grenache, Syrah, Mourvedre, Cinsault, Rolle",
+ wineCount: 0,
+ color: "#74070E",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [5.5, 43.2],
+ [6.8, 43.2],
+ [6.8, 43.8],
+ [6.2, 43.9],
+ [5.5, 43.7],
+ [5.5, 43.2],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // ITALY
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Piedmont",
+ country: "Italy",
+ grapes: "Nebbiolo, Barbera, Dolcetto, Moscato, Arneis",
+ wineCount: 0,
+ color: "#8B1A22",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [7.5, 44.3],
+ [8.8, 44.3],
+ [8.8, 45.2],
+ [8.2, 45.5],
+ [7.5, 45.2],
+ [7.5, 44.3],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Tuscany",
+ country: "Italy",
+ grapes: "Sangiovese, Cabernet Sauvignon, Merlot, Vernaccia, Trebbiano",
+ wineCount: 0,
+ color: "#8B1A22",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [10.5, 42.4],
+ [12.2, 42.4],
+ [12.2, 43.8],
+ [11.4, 44.0],
+ [10.5, 43.6],
+ [10.5, 42.4],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Veneto",
+ country: "Italy",
+ grapes: "Corvina, Garganega, Glera, Rondinella, Pinot Grigio",
+ wineCount: 0,
+ color: "#8B1A22",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [10.8, 45.0],
+ [12.5, 45.0],
+ [12.5, 46.0],
+ [11.8, 46.3],
+ [10.8, 46.0],
+ [10.8, 45.0],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Sicily",
+ country: "Italy",
+ grapes: "Nero d'Avola, Nerello Mascalese, Grillo, Catarratto, Carricante",
+ wineCount: 0,
+ color: "#8B1A22",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [12.4, 36.6],
+ [15.7, 36.6],
+ [15.7, 38.3],
+ [14.0, 38.4],
+ [12.4, 37.8],
+ [12.4, 36.6],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // SPAIN
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Rioja",
+ country: "Spain",
+ grapes: "Tempranillo, Garnacha, Graciano, Mazuelo, Viura",
+ wineCount: 0,
+ color: "#A03030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-3.1, 42.2],
+ [-1.8, 42.2],
+ [-1.8, 42.7],
+ [-2.3, 42.8],
+ [-3.1, 42.6],
+ [-3.1, 42.2],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Ribera del Duero",
+ country: "Spain",
+ grapes: "Tempranillo, Cabernet Sauvignon, Merlot, Malbec",
+ wineCount: 0,
+ color: "#A03030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-4.3, 41.4],
+ [-3.2, 41.4],
+ [-3.2, 41.9],
+ [-3.6, 42.0],
+ [-4.3, 41.8],
+ [-4.3, 41.4],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Priorat",
+ country: "Spain",
+ grapes: "Garnacha, Carinena, Cabernet Sauvignon, Syrah, Merlot",
+ wineCount: 0,
+ color: "#A03030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [0.6, 41.1],
+ [0.95, 41.1],
+ [0.95, 41.35],
+ [0.8, 41.4],
+ [0.6, 41.3],
+ [0.6, 41.1],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // PORTUGAL
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Douro Valley",
+ country: "Portugal",
+ grapes: "Touriga Nacional, Touriga Franca, Tinta Roriz, Tinta Barroca, Tinta Cao",
+ wineCount: 0,
+ color: "#704020",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-7.9, 41.0],
+ [-6.8, 41.0],
+ [-6.8, 41.5],
+ [-7.3, 41.6],
+ [-7.9, 41.4],
+ [-7.9, 41.0],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Alentejo",
+ country: "Portugal",
+ grapes: "Aragonez, Trincadeira, Alicante Bouschet, Antao Vaz",
+ wineCount: 0,
+ color: "#704020",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-8.4, 37.8],
+ [-7.2, 37.8],
+ [-7.2, 39.0],
+ [-7.8, 39.2],
+ [-8.4, 38.8],
+ [-8.4, 37.8],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // GERMANY
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Mosel",
+ country: "Germany",
+ grapes: "Riesling, Muller-Thurgau, Elbling, Pinot Blanc",
+ wineCount: 0,
+ color: "#506030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [6.5, 49.4],
+ [7.2, 49.4],
+ [7.2, 50.2],
+ [6.9, 50.4],
+ [6.5, 50.1],
+ [6.5, 49.4],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Rheingau",
+ country: "Germany",
+ grapes: "Riesling, Spatburgunder (Pinot Noir)",
+ wineCount: 0,
+ color: "#506030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [7.8, 49.9],
+ [8.2, 49.9],
+ [8.2, 50.15],
+ [8.05, 50.2],
+ [7.8, 50.1],
+ [7.8, 49.9],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // USA
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Napa Valley",
+ country: "USA",
+ grapes: "Cabernet Sauvignon, Merlot, Chardonnay, Pinot Noir, Sauvignon Blanc",
+ wineCount: 0,
+ color: "#305080",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-122.6, 38.2],
+ [-122.2, 38.2],
+ [-122.2, 38.7],
+ [-122.35, 38.75],
+ [-122.6, 38.6],
+ [-122.6, 38.2],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Sonoma",
+ country: "USA",
+ grapes: "Pinot Noir, Chardonnay, Cabernet Sauvignon, Zinfandel, Sauvignon Blanc",
+ wineCount: 0,
+ color: "#305080",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-123.2, 38.2],
+ [-122.6, 38.2],
+ [-122.6, 38.85],
+ [-122.9, 38.9],
+ [-123.2, 38.7],
+ [-123.2, 38.2],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Willamette Valley",
+ country: "USA",
+ grapes: "Pinot Noir, Pinot Gris, Chardonnay, Riesling",
+ wineCount: 0,
+ color: "#305080",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-123.6, 44.8],
+ [-122.5, 44.8],
+ [-122.5, 45.6],
+ [-123.0, 45.7],
+ [-123.6, 45.4],
+ [-123.6, 44.8],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // ARGENTINA
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Mendoza",
+ country: "Argentina",
+ grapes: "Malbec, Cabernet Sauvignon, Bonarda, Torrontes, Chardonnay",
+ wineCount: 0,
+ color: "#604080",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-69.5, -34.5],
+ [-67.5, -34.5],
+ [-67.5, -32.5],
+ [-68.3, -32.0],
+ [-69.5, -32.8],
+ [-69.5, -34.5],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // CHILE
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Maipo Valley",
+ country: "Chile",
+ grapes: "Cabernet Sauvignon, Merlot, Carmenere, Syrah",
+ wineCount: 0,
+ color: "#604080",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-71.3, -34.0],
+ [-70.4, -34.0],
+ [-70.4, -33.3],
+ [-70.8, -33.2],
+ [-71.3, -33.5],
+ [-71.3, -34.0],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Colchagua Valley",
+ country: "Chile",
+ grapes: "Carmenere, Cabernet Sauvignon, Syrah, Malbec, Merlot",
+ wineCount: 0,
+ color: "#604080",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-71.8, -34.8],
+ [-70.8, -34.8],
+ [-70.8, -34.3],
+ [-71.2, -34.2],
+ [-71.8, -34.4],
+ [-71.8, -34.8],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // AUSTRALIA
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Barossa Valley",
+ country: "Australia",
+ grapes: "Shiraz, Cabernet Sauvignon, Grenache, Riesling, Semillon",
+ wineCount: 0,
+ color: "#806030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [138.5, -34.8],
+ [139.1, -34.8],
+ [139.1, -34.3],
+ [138.85, -34.2],
+ [138.5, -34.4],
+ [138.5, -34.8],
+ ],
+ ],
+ },
+ },
+ {
+ type: "Feature",
+ properties: {
+ name: "Margaret River",
+ country: "Australia",
+ grapes: "Cabernet Sauvignon, Chardonnay, Sauvignon Blanc, Semillon, Merlot",
+ wineCount: 0,
+ color: "#806030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [114.9, -34.2],
+ [115.5, -34.2],
+ [115.5, -33.5],
+ [115.2, -33.4],
+ [114.9, -33.7],
+ [114.9, -34.2],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // NEW ZEALAND
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Marlborough",
+ country: "New Zealand",
+ grapes: "Sauvignon Blanc, Pinot Noir, Chardonnay, Pinot Gris, Riesling",
+ wineCount: 0,
+ color: "#308050",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [173.4, -41.8],
+ [174.2, -41.8],
+ [174.2, -41.2],
+ [173.8, -41.1],
+ [173.4, -41.4],
+ [173.4, -41.8],
+ ],
+ ],
+ },
+ },
+
+ // =====================
+ // SOUTH AFRICA
+ // =====================
+ {
+ type: "Feature",
+ properties: {
+ name: "Stellenbosch",
+ country: "South Africa",
+ grapes: "Cabernet Sauvignon, Pinotage, Merlot, Shiraz, Chenin Blanc",
+ wineCount: 0,
+ color: "#805030",
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [18.7, -34.1],
+ [19.1, -34.1],
+ [19.1, -33.8],
+ [18.95, -33.75],
+ [18.7, -33.85],
+ [18.7, -34.1],
+ ],
+ ],
+ },
+ },
+ ],
+};
diff --git a/winebob/src/hooks/useMapLayers.ts b/winebob/src/hooks/useMapLayers.ts
new file mode 100644
index 0000000..516b49b
--- /dev/null
+++ b/winebob/src/hooks/useMapLayers.ts
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState, useCallback, useEffect } from "react";
+import type { MapLayer } from "@/components/shared/MapLayerDrawer";
+
+const STORAGE_KEY = "winebob-map-layers";
+
+/** Default layer definitions — all disabled initially. */
+const DEFAULT_LAYERS: Omit[] = [
+ {
+ id: "vintage-weather",
+ name: "Vintage Weather",
+ description: "Replay a vintage's growing season",
+ group: "explore",
+ exclusive: "heavy-overlay",
+ enabled: false,
+ available: true,
+ },
+ {
+ id: "flavor-genome",
+ name: "Flavor Genome",
+ description: "Taste profiles by region",
+ group: "explore",
+ exclusive: "heavy-overlay",
+ enabled: false,
+ available: true,
+ },
+ {
+ id: "live-heatmap",
+ name: "Live Activity",
+ description: "See what people are drinking",
+ group: "social",
+ enabled: false,
+ available: true,
+ },
+ {
+ id: "draw-flight",
+ name: "Draw a Flight",
+ description: "Trace a path, get a tasting flight",
+ group: "tools",
+ enabled: false,
+ available: true,
+ },
+];
+
+function loadPersistedState(): Record {
+ if (typeof window === "undefined") return {};
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw) as Record;
+ } catch {
+ // Ignore parse errors
+ }
+ return {};
+}
+
+function persistState(layers: Omit[]) {
+ if (typeof window === "undefined") return;
+ const map: Record = {};
+ for (const l of layers) {
+ map[l.id] = l.enabled;
+ }
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+ } catch {
+ // Ignore quota errors
+ }
+}
+
+export function useMapLayers() {
+ const [layers, setLayers] = useState[]>(() => {
+ const persisted = loadPersistedState();
+ if (Object.keys(persisted).length === 0) return DEFAULT_LAYERS;
+ return DEFAULT_LAYERS.map((l) => ({
+ ...l,
+ enabled: persisted[l.id] ?? l.enabled,
+ }));
+ });
+
+ // Persist whenever layers change
+ useEffect(() => {
+ persistState(layers);
+ }, [layers]);
+
+ const toggle = useCallback((layerId: string) => {
+ setLayers((prev) => {
+ const target = prev.find((l) => l.id === layerId);
+ if (!target || !target.available) return prev;
+
+ const enabling = !target.enabled;
+
+ return prev.map((l) => {
+ if (l.id === layerId) {
+ return { ...l, enabled: enabling };
+ }
+ // Handle mutual exclusion: if enabling and the target has an exclusive
+ // group, disable other layers in the same exclusive group.
+ if (
+ enabling &&
+ target.exclusive &&
+ l.exclusive === target.exclusive &&
+ l.id !== layerId
+ ) {
+ return { ...l, enabled: false };
+ }
+ return l;
+ });
+ });
+ }, []);
+
+ const isActive = useCallback(
+ (layerId: string) => {
+ return layers.find((l) => l.id === layerId)?.enabled ?? false;
+ },
+ [layers],
+ );
+
+ return { layers, toggle, isActive };
+}
diff --git a/winebob/src/lib/AuthProvider.tsx b/winebob/src/lib/AuthProvider.tsx
new file mode 100644
index 0000000..bb80a62
--- /dev/null
+++ b/winebob/src/lib/AuthProvider.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/winebob/src/lib/actions.ts b/winebob/src/lib/actions.ts
new file mode 100644
index 0000000..8e9cf2b
--- /dev/null
+++ b/winebob/src/lib/actions.ts
@@ -0,0 +1,694 @@
+"use server";
+
+import { prisma } from "@/lib/db";
+import { requireAuth, auth } from "@/lib/auth";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import {
+ trackWineSearch,
+ trackWineView,
+ trackWineFavorite,
+ trackEvent,
+} from "@/lib/analytics";
+
+// ============ JOIN CODE GENERATION ============
+
+function generateJoinCode(): string {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/1/O/0 confusion
+ let code = "";
+ for (let i = 0; i < 6; i++) {
+ code += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return code;
+}
+
+async function uniqueJoinCode(): Promise {
+ for (let i = 0; i < 10; i++) {
+ const code = generateJoinCode();
+ const existing = await prisma.blindTastingEvent.findUnique({
+ where: { joinCode: code },
+ });
+ if (!existing) return code;
+ }
+ throw new Error("Failed to generate unique join code");
+}
+
+// ============ HOST: EVENT MANAGEMENT ============
+
+export async function createEvent(data: {
+ title: string;
+ description?: string;
+ templateId?: string;
+ guessFields: string[];
+ scoringConfig: Record;
+ difficulty: string;
+ timePerWine?: number;
+ wineIds: string[];
+}) {
+ const session = await requireAuth();
+ const joinCode = await uniqueJoinCode();
+
+ // Validate that all wine IDs actually exist before creating the event
+ if (data.wineIds.length > 0) {
+ const existingWines = await prisma.wine.findMany({
+ where: { id: { in: data.wineIds } },
+ select: { id: true },
+ });
+ const existingIds = new Set(existingWines.map((w) => w.id));
+ const invalid = data.wineIds.filter((id) => !existingIds.has(id));
+ if (invalid.length > 0) {
+ throw new Error(`Invalid wine IDs: ${invalid.join(", ")}`);
+ }
+ }
+
+ const event = await prisma.blindTastingEvent.create({
+ data: {
+ hostId: session.user.id,
+ title: data.title,
+ description: data.description,
+ templateId: data.templateId,
+ joinCode,
+ guessFields: data.guessFields,
+ scoringConfig: data.scoringConfig,
+ difficulty: data.difficulty,
+ timePerWine: data.timePerWine,
+ status: "draft",
+ },
+ });
+
+ // Add wines separately (Neon HTTP adapter doesn't support nested creates / implicit transactions)
+ if (data.wineIds.length > 0) {
+ await Promise.all(
+ data.wineIds.map((wineId, i) =>
+ prisma.blindWine.create({
+ data: {
+ eventId: event.id,
+ wineId,
+ position: i + 1,
+ },
+ })
+ )
+ );
+ }
+
+ // Increment template usage count
+ if (data.templateId) {
+ await prisma.eventTemplate.update({
+ where: { id: data.templateId },
+ data: { usageCount: { increment: 1 } },
+ });
+ }
+
+ revalidatePath("/arena");
+ redirect(`/arena/event/${event.id}`);
+}
+
+export async function updateEventStatus(
+ eventId: string,
+ status: "lobby" | "live" | "revealing" | "completed"
+) {
+ const session = await requireAuth();
+
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { id: eventId },
+ });
+ if (!event || event.hostId !== session.user.id) {
+ throw new Error("Not authorized");
+ }
+
+ await prisma.blindTastingEvent.update({
+ where: { id: eventId },
+ data: {
+ status,
+ ...(status === "live" ? { startsAt: new Date() } : {}),
+ ...(status === "completed" ? { completedAt: new Date() } : {}),
+ },
+ });
+
+ revalidatePath(`/arena/event/${eventId}`);
+}
+
+export async function advanceWine(eventId: string) {
+ const session = await requireAuth();
+
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { id: eventId },
+ include: { wines: { orderBy: { position: "asc" } } },
+ });
+ if (!event || event.hostId !== session.user.id) {
+ throw new Error("Not authorized");
+ }
+
+ const nextPosition = event.currentWine + 1;
+ if (nextPosition > event.wines.length) {
+ throw new Error("No more wines");
+ }
+
+ await prisma.blindTastingEvent.update({
+ where: { id: eventId },
+ data: { currentWine: nextPosition },
+ });
+
+ revalidatePath(`/arena/event/${eventId}`);
+}
+
+export async function revealWine(eventId: string, position: number) {
+ const session = await requireAuth();
+
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { id: eventId },
+ });
+ if (!event || event.hostId !== session.user.id) {
+ throw new Error("Not authorized");
+ }
+
+ // Find the specific blind wine first, then update it (avoids updateMany which
+ // can trigger implicit transactions on some adapters)
+ const blindWine = await prisma.blindWine.findFirst({
+ where: { eventId, position },
+ });
+ if (blindWine) {
+ await prisma.blindWine.update({
+ where: { id: blindWine.id },
+ data: { revealed: true },
+ });
+ }
+
+ revalidatePath(`/arena/event/${eventId}`);
+}
+
+// ============ GUEST: JOIN & GUESS ============
+
+export async function joinEvent(data: {
+ joinCode: string;
+ displayName: string;
+ birthYear: number;
+ city?: string;
+ country?: string;
+ locationLat?: number;
+ locationLng?: number;
+ consentGiven: boolean;
+}) {
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { joinCode: data.joinCode.toUpperCase() },
+ });
+
+ if (!event) {
+ throw new Error("Event not found. Check your code and try again.");
+ }
+
+ if (event.status === "completed") {
+ throw new Error("This event has already ended.");
+ }
+
+ const guest = await prisma.guestParticipant.create({
+ data: {
+ eventId: event.id,
+ displayName: data.displayName,
+ birthYear: data.birthYear,
+ city: data.city,
+ country: data.country,
+ locationLat: data.locationLat,
+ locationLng: data.locationLng,
+ consentGiven: data.consentGiven,
+ consentAt: data.consentGiven ? new Date() : null,
+ },
+ });
+
+ return { eventId: event.id, guestId: guest.id };
+}
+
+export async function submitGuess(data: {
+ eventId: string;
+ guestId: string;
+ winePosition: number;
+ guessedGrape?: string;
+ guessedRegion?: string;
+ guessedCountry?: string;
+ guessedVintage?: number;
+ guessedProducer?: string;
+ guessedType?: string;
+ guessedPrice?: number;
+ notes?: string;
+ timeElapsed?: number;
+}) {
+ // 1. Verify the guest actually belongs to this event
+ const guest = await prisma.guestParticipant.findFirst({
+ where: { id: data.guestId, eventId: data.eventId },
+ });
+ if (!guest) {
+ throw new Error("Guest not found in this event");
+ }
+
+ // 2. Verify the event is live and the wine is not already revealed (submission lock)
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { id: data.eventId },
+ });
+ if (!event || event.status !== "live") {
+ throw new Error("Event is not accepting guesses");
+ }
+
+ const blindWine = await prisma.blindWine.findFirst({
+ where: { eventId: data.eventId, position: data.winePosition },
+ });
+ if (!blindWine) {
+ throw new Error("Wine position does not exist");
+ }
+ if (blindWine.revealed) {
+ throw new Error("This wine has already been revealed — guesses are locked");
+ }
+
+ // 3. Submit/update the guess
+ const guess = await prisma.blindGuess.upsert({
+ where: {
+ eventId_guestId_winePosition: {
+ eventId: data.eventId,
+ guestId: data.guestId,
+ winePosition: data.winePosition,
+ },
+ },
+ create: {
+ eventId: data.eventId,
+ guestId: data.guestId,
+ winePosition: data.winePosition,
+ guessedGrape: data.guessedGrape,
+ guessedRegion: data.guessedRegion,
+ guessedCountry: data.guessedCountry,
+ guessedVintage: data.guessedVintage,
+ guessedProducer: data.guessedProducer,
+ guessedType: data.guessedType,
+ guessedPrice: data.guessedPrice,
+ notes: data.notes,
+ timeElapsed: data.timeElapsed,
+ },
+ update: {
+ guessedGrape: data.guessedGrape,
+ guessedRegion: data.guessedRegion,
+ guessedCountry: data.guessedCountry,
+ guessedVintage: data.guessedVintage,
+ guessedProducer: data.guessedProducer,
+ guessedType: data.guessedType,
+ guessedPrice: data.guessedPrice,
+ notes: data.notes,
+ timeElapsed: data.timeElapsed,
+ },
+ });
+
+ return guess;
+}
+
+// ============ SCORING ============
+
+export async function scoreEvent(eventId: string) {
+ const session = await requireAuth();
+
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { id: eventId },
+ });
+ if (!event || event.hostId !== session.user.id) {
+ throw new Error("Not authorized");
+ }
+
+ // Fetch wines, actual wine data, and guesses separately to avoid nested
+ // includes which can trigger implicit transactions on the Neon HTTP adapter.
+ const blindWines = await prisma.blindWine.findMany({
+ where: { eventId },
+ orderBy: { position: "asc" },
+ });
+
+ const wineIds = blindWines.map((bw) => bw.wineId);
+ const wines = await prisma.wine.findMany({
+ where: { id: { in: wineIds } },
+ });
+ const wineMap = new Map(wines.map((w) => [w.id, w]));
+
+ const guesses = await prisma.blindGuess.findMany({
+ where: { eventId },
+ });
+
+ const weights = (event.scoringConfig as Record) ?? {
+ grape: 25,
+ region: 20,
+ country: 15,
+ vintage: 15,
+ producer: 15,
+ type: 10,
+ };
+
+ // Score each guess and update individually
+ await Promise.all(
+ guesses.map((guess) => {
+ const blindWine = blindWines.find((w) => w.position === guess.winePosition);
+ if (!blindWine) return Promise.resolve();
+ const actual = wineMap.get(blindWine.wineId);
+ if (!actual) return Promise.resolve();
+
+ let score = 0;
+
+ // Helper: normalize strings for comparison (trim, lowercase)
+ const norm = (s: string) => s.trim().toLowerCase();
+
+ // Grape: match if guessed grape is one of the actual grapes (exact match per grape)
+ if (weights.grape && guess.guessedGrape) {
+ const guessNorm = norm(guess.guessedGrape);
+ if (actual.grapes.some((g: string) => norm(g) === guessNorm)) {
+ score += weights.grape;
+ }
+ }
+
+ // Region: exact match (normalized)
+ if (weights.region && guess.guessedRegion) {
+ if (norm(actual.region) === norm(guess.guessedRegion)) {
+ score += weights.region;
+ }
+ }
+
+ // Country: exact match (normalized)
+ if (weights.country && guess.guessedCountry) {
+ if (norm(actual.country) === norm(guess.guessedCountry)) {
+ score += weights.country;
+ }
+ }
+
+ // Vintage: exact year match, or half points for ±1 year
+ if (weights.vintage && guess.guessedVintage && actual.vintage) {
+ if (actual.vintage === guess.guessedVintage) {
+ score += weights.vintage;
+ } else if (Math.abs(actual.vintage - guess.guessedVintage) === 1) {
+ score += Math.round(weights.vintage / 2);
+ }
+ }
+
+ // Producer: exact match (normalized)
+ if (weights.producer && guess.guessedProducer) {
+ if (norm(actual.producer) === norm(guess.guessedProducer)) {
+ score += weights.producer;
+ }
+ }
+
+ // Type: exact match (normalized) — red, white, rosé etc.
+ if (weights.type && guess.guessedType) {
+ if (norm(actual.type) === norm(guess.guessedType)) {
+ score += weights.type;
+ }
+ }
+
+ return prisma.blindGuess.update({
+ where: { id: guess.id },
+ data: { score },
+ });
+ })
+ );
+
+ revalidatePath(`/arena/event/${eventId}`);
+}
+
+// ============ DATA FETCHING ============
+
+export async function getEventByJoinCode(joinCode: string) {
+ return prisma.blindTastingEvent.findUnique({
+ where: { joinCode: joinCode.toUpperCase() },
+ include: {
+ host: { select: { displayName: true, name: true, image: true } },
+ wines: { orderBy: { position: "asc" } },
+ guests: { select: { id: true, displayName: true } },
+ },
+ });
+}
+
+export async function getEventById(id: string) {
+ // Fetch event with flat relations first, then enrich blind wines with
+ // actual wine data separately — avoids nested includes which trigger
+ // implicit transactions on the Neon HTTP adapter.
+ const event = await prisma.blindTastingEvent.findUnique({
+ where: { id },
+ include: {
+ host: { select: { displayName: true, name: true, image: true } },
+ wines: { orderBy: { position: "asc" } },
+ guests: true,
+ guesses: true,
+ },
+ });
+ if (!event) return null;
+
+ // Fetch the actual Wine records for each blind wine
+ const wineIds = event.wines.map((bw) => bw.wineId);
+ const wines = wineIds.length > 0
+ ? await prisma.wine.findMany({ where: { id: { in: wineIds } } })
+ : [];
+ const wineMap = new Map(wines.map((w) => [w.id, w]));
+
+ return {
+ ...event,
+ wines: event.wines.map((bw) => ({
+ ...bw,
+ wine: wineMap.get(bw.wineId) ?? null,
+ })),
+ };
+}
+
+export async function getTemplates() {
+ return prisma.eventTemplate.findMany({
+ where: { isPublic: true },
+ orderBy: [{ featured: "desc" }, { usageCount: "desc" }],
+ });
+}
+
+export async function searchWines(query: string) {
+ if (!query || query.trim().length < 2) return [];
+
+ const results = await prisma.wine.findMany({
+ where: {
+ OR: [
+ { name: { contains: query, mode: "insensitive" } },
+ { producer: { contains: query, mode: "insensitive" } },
+ { region: { contains: query, mode: "insensitive" } },
+ { grapes: { hasSome: [query] } },
+ ],
+ },
+ take: 20,
+ });
+
+ // Fire-and-forget: track search after results are ready
+ const session = await auth().catch(() => null);
+ trackWineSearch(session?.user?.id ?? null, query, results.length).catch(() => {});
+
+ return results;
+}
+
+export async function getWineRegionCounts(): Promise> {
+ const wines = await prisma.wine.findMany({
+ where: { isPublic: true },
+ select: { region: true },
+ });
+ const counts: Record = {};
+ for (const w of wines) {
+ counts[w.region] = (counts[w.region] ?? 0) + 1;
+ }
+ return counts;
+}
+
+export async function getBrowseWines() {
+ const wines = await prisma.wine.findMany({
+ where: { isPublic: true },
+ take: 30,
+ orderBy: { name: "asc" },
+ });
+
+ // Fire-and-forget: track browse view
+ const session = await auth().catch(() => null);
+ trackWineView(session?.user?.id ?? null, "", "browse").catch(() => {});
+
+ return wines;
+}
+
+// ============ WINE LIBRARY ============
+
+export async function getWineLibrary(filters?: {
+ type?: string;
+ country?: string;
+ priceRange?: string;
+ search?: string;
+ page?: number;
+}) {
+ const PAGE_SIZE = 24;
+ const page = filters?.page ?? 1;
+
+ const where: Record = { isPublic: true };
+ if (filters?.type) where.type = filters.type;
+ if (filters?.country) where.country = filters.country;
+ if (filters?.priceRange) where.priceRange = filters.priceRange;
+ if (filters?.search) {
+ where.OR = [
+ { name: { contains: filters.search, mode: "insensitive" } },
+ { producer: { contains: filters.search, mode: "insensitive" } },
+ { region: { contains: filters.search, mode: "insensitive" } },
+ ];
+ }
+
+ const [wines, total] = await Promise.all([
+ prisma.wine.findMany({
+ where,
+ take: PAGE_SIZE,
+ skip: (page - 1) * PAGE_SIZE,
+ orderBy: { name: "asc" },
+ }),
+ prisma.wine.count({ where }),
+ ]);
+
+ // Fire-and-forget: track library search with filters
+ const session = await auth().catch(() => null);
+ trackWineSearch(session?.user?.id ?? null, filters?.search ?? "", total, {
+ type: filters?.type,
+ country: filters?.country,
+ priceRange: filters?.priceRange,
+ page,
+ }).catch(() => {});
+
+ return { wines, total, pages: Math.ceil(total / PAGE_SIZE), page };
+}
+
+export async function getWineById(id: string) {
+ const wine = await prisma.wine.findUnique({ where: { id } });
+
+ if (wine) {
+ // Fire-and-forget: track view after data is fetched
+ const session = await auth().catch(() => null);
+ trackWineView(session?.user?.id ?? null, id, "detail").catch(() => {});
+ }
+
+ return wine;
+}
+
+export async function getWineCountries() {
+ const wines = await prisma.wine.findMany({
+ where: { isPublic: true },
+ select: { country: true },
+ distinct: ["country"],
+ orderBy: { country: "asc" },
+ });
+ return wines.map((w) => w.country);
+}
+
+export async function addWine(data: {
+ name: string;
+ producer: string;
+ vintage?: number;
+ grapes: string[];
+ region: string;
+ country: string;
+ appellation?: string;
+ type: string;
+ description?: string;
+ labelImage?: string;
+ priceRange?: string;
+ abv?: number;
+ tastingNotes?: string;
+ foodPairing?: string;
+}) {
+ const session = await requireAuth();
+
+ const wine = await prisma.wine.create({
+ data: {
+ ...data,
+ vintage: data.vintage ?? null,
+ addedById: session.user.id,
+ isPublic: true,
+ },
+ });
+
+ // Fire-and-forget: track wine addition
+ trackEvent({
+ eventType: "wine_add",
+ userId: session.user.id,
+ wineId: wine.id,
+ metadata: { producer: data.producer, region: data.region, country: data.country },
+ }).catch(() => {});
+
+ return wine;
+}
+
+export async function toggleFavorite(wineId: string) {
+ const session = await requireAuth();
+
+ const existing = await prisma.wineFavorite.findUnique({
+ where: { userId_wineId: { userId: session.user.id, wineId } },
+ });
+
+ if (existing) {
+ await prisma.wineFavorite.delete({ where: { id: existing.id } });
+
+ // Fire-and-forget: track favorite removal
+ trackEvent({
+ eventType: "wine_unfavorite",
+ userId: session.user.id,
+ wineId,
+ metadata: { action: "remove" },
+ }).catch(() => {});
+
+ return { favorited: false };
+ }
+
+ await prisma.wineFavorite.create({
+ data: { userId: session.user.id, wineId },
+ });
+
+ // Fire-and-forget: track favorite addition
+ trackWineFavorite(session.user.id, wineId).catch(() => {});
+
+ return { favorited: true };
+}
+
+export async function getUserFavorites() {
+ const session = await requireAuth();
+
+ const favs = await prisma.wineFavorite.findMany({
+ where: { userId: session.user.id },
+ select: { wineId: true },
+ });
+ return new Set(favs.map((f) => f.wineId));
+}
+
+export async function getMyWines() {
+ const session = await requireAuth();
+
+ return prisma.wine.findMany({
+ where: { addedById: session.user.id },
+ orderBy: { createdAt: "desc" },
+ });
+}
+
+export async function getMyCellar() {
+ const session = await requireAuth();
+
+ const favorites = await prisma.wineFavorite.findMany({
+ where: { userId: session.user.id },
+ orderBy: { createdAt: "desc" },
+ });
+
+ if (favorites.length === 0) return [];
+
+ const wineIds = favorites.map((f) => f.wineId);
+ const wines = await prisma.wine.findMany({
+ where: { id: { in: wineIds } },
+ });
+
+ const wineMap = new Map(wines.map((w) => [w.id, w]));
+ return favorites.map((f) => ({
+ ...f,
+ wine: wineMap.get(f.wineId) ?? null,
+ }));
+}
+
+export async function getHostEvents() {
+ const session = await requireAuth();
+
+ return prisma.blindTastingEvent.findMany({
+ where: { hostId: session.user.id },
+ orderBy: { createdAt: "desc" },
+ include: {
+ wines: true,
+ guests: { select: { id: true } },
+ },
+ });
+}
diff --git a/winebob/src/lib/aggregation.ts b/winebob/src/lib/aggregation.ts
new file mode 100644
index 0000000..c883d98
--- /dev/null
+++ b/winebob/src/lib/aggregation.ts
@@ -0,0 +1,595 @@
+import { prisma } from "@/lib/db";
+
+/**
+ * Aggregate wine popularity for a given date (YYYY-MM-DD).
+ * Counts wine_view, wine_favorite, wine_taste, wine_checkin, wine_wishlist
+ * events per wineId for the given date. Upserts on the unique [wineId, date] constraint.
+ * Returns the count of rows upserted.
+ */
+export async function aggregateWinePopularity(date: Date): Promise {
+ const dayStart = new Date(date);
+ dayStart.setUTCHours(0, 0, 0, 0);
+ const dayEnd = new Date(dayStart);
+ dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
+
+ const eventTypes = [
+ "wine_view",
+ "wine_favorite",
+ "wine_taste",
+ "wine_checkin",
+ "wine_wishlist",
+ ] as const;
+
+ const grouped = await prisma.wineEvent.groupBy({
+ by: ["wineId", "eventType"],
+ where: {
+ createdAt: { gte: dayStart, lt: dayEnd },
+ eventType: { in: [...eventTypes] },
+ wineId: { not: null },
+ },
+ _count: { id: true },
+ });
+
+ // Pivot into per-wine counts
+ const wineMap = new Map<
+ string,
+ {
+ views: number;
+ favorites: number;
+ tastings: number;
+ checkIns: number;
+ wishlistAdds: number;
+ }
+ >();
+
+ for (const row of grouped) {
+ const wineId = row.wineId!;
+ if (!wineMap.has(wineId)) {
+ wineMap.set(wineId, {
+ views: 0,
+ favorites: 0,
+ tastings: 0,
+ checkIns: 0,
+ wishlistAdds: 0,
+ });
+ }
+ const entry = wineMap.get(wineId)!;
+ switch (row.eventType) {
+ case "wine_view":
+ entry.views = row._count.id;
+ break;
+ case "wine_favorite":
+ entry.favorites = row._count.id;
+ break;
+ case "wine_taste":
+ entry.tastings = row._count.id;
+ break;
+ case "wine_checkin":
+ entry.checkIns = row._count.id;
+ break;
+ case "wine_wishlist":
+ entry.wishlistAdds = row._count.id;
+ break;
+ }
+ }
+
+ // Compute avg rating from wine_taste events that have a rating in metadata
+ const tasteEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: dayStart, lt: dayEnd },
+ eventType: "wine_taste",
+ wineId: { not: null },
+ },
+ select: { wineId: true, metadata: true },
+ });
+
+ const ratingMap = new Map();
+ for (const evt of tasteEvents) {
+ if (!evt.wineId) continue;
+ const meta = evt.metadata as Record | null;
+ const rating = meta?.rating;
+ if (typeof rating === "number") {
+ const entry = ratingMap.get(evt.wineId) ?? { sum: 0, count: 0 };
+ entry.sum += rating;
+ entry.count += 1;
+ ratingMap.set(evt.wineId, entry);
+ }
+ }
+
+ let upserted = 0;
+ for (const [wineId, counts] of wineMap) {
+ const ratingEntry = ratingMap.get(wineId);
+ const avgRating = ratingEntry
+ ? ratingEntry.sum / ratingEntry.count
+ : null;
+
+ await prisma.winePopularity.upsert({
+ where: { wineId_date: { wineId, date: dayStart } },
+ create: {
+ wineId,
+ date: dayStart,
+ views: counts.views,
+ favorites: counts.favorites,
+ tastings: counts.tastings,
+ checkIns: counts.checkIns,
+ wishlistAdds: counts.wishlistAdds,
+ avgRating,
+ },
+ update: {
+ views: counts.views,
+ favorites: counts.favorites,
+ tastings: counts.tastings,
+ checkIns: counts.checkIns,
+ wishlistAdds: counts.wishlistAdds,
+ avgRating,
+ },
+ });
+ upserted++;
+ }
+
+ return upserted;
+}
+
+/**
+ * Aggregate region trends for a given week (starting Monday).
+ * Counts region_explore and wine_checkin events per region for the week.
+ * Upserts on [region, weekStart].
+ * Returns the count of rows upserted.
+ */
+export async function aggregateRegionTrends(
+ weekStart: Date
+): Promise {
+ const start = new Date(weekStart);
+ start.setUTCHours(0, 0, 0, 0);
+ const end = new Date(start);
+ end.setUTCDate(end.getUTCDate() + 7);
+
+ // region_explore events: region is in metadata.region
+ const exploreEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "region_explore",
+ },
+ select: { metadata: true },
+ });
+
+ const regionMap = new Map<
+ string,
+ {
+ country: string;
+ searches: number;
+ explorations: number;
+ checkIns: number;
+ newWines: number;
+ }
+ >();
+
+ for (const evt of exploreEvents) {
+ const meta = evt.metadata as Record | null;
+ const region = meta?.region as string | undefined;
+ const country = (meta?.country as string) ?? "Unknown";
+ if (!region) continue;
+ if (!regionMap.has(region)) {
+ regionMap.set(region, {
+ country,
+ searches: 0,
+ explorations: 0,
+ checkIns: 0,
+ newWines: 0,
+ });
+ }
+ regionMap.get(region)!.explorations++;
+ }
+
+ // wine_search events with region in metadata
+ const searchEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "wine_search",
+ },
+ select: { metadata: true },
+ });
+
+ for (const evt of searchEvents) {
+ const meta = evt.metadata as Record | null;
+ const region = meta?.region as string | undefined;
+ const country = (meta?.country as string) ?? "Unknown";
+ if (!region) continue;
+ if (!regionMap.has(region)) {
+ regionMap.set(region, {
+ country,
+ searches: 0,
+ explorations: 0,
+ checkIns: 0,
+ newWines: 0,
+ });
+ }
+ regionMap.get(region)!.searches++;
+ }
+
+ // wine_checkin events: join with Wine to get region
+ const checkinEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "wine_checkin",
+ wineId: { not: null },
+ },
+ select: {
+ wine: { select: { region: true, country: true } },
+ },
+ });
+
+ for (const evt of checkinEvents) {
+ if (!evt.wine) continue;
+ const region = evt.wine.region;
+ const country = evt.wine.country;
+ if (!regionMap.has(region)) {
+ regionMap.set(region, {
+ country,
+ searches: 0,
+ explorations: 0,
+ checkIns: 0,
+ newWines: 0,
+ });
+ }
+ regionMap.get(region)!.checkIns++;
+ }
+
+ // Count new wines added per region in this week
+ const newWines = await prisma.wine.groupBy({
+ by: ["region"],
+ where: {
+ createdAt: { gte: start, lt: end },
+ },
+ _count: { id: true },
+ });
+
+ for (const row of newWines) {
+ if (regionMap.has(row.region)) {
+ regionMap.get(row.region)!.newWines = row._count.id;
+ }
+ }
+
+ let upserted = 0;
+ for (const [region, data] of regionMap) {
+ await prisma.regionTrend.upsert({
+ where: { region_weekStart: { region, weekStart: start } },
+ create: {
+ region,
+ country: data.country,
+ weekStart: start,
+ searches: data.searches,
+ explorations: data.explorations,
+ checkIns: data.checkIns,
+ newWines: data.newWines,
+ },
+ update: {
+ searches: data.searches,
+ explorations: data.explorations,
+ checkIns: data.checkIns,
+ newWines: data.newWines,
+ },
+ });
+ upserted++;
+ }
+
+ return upserted;
+}
+
+/**
+ * Aggregate grape trends for a given week.
+ * Queries WineEvent -> Wine -> grapes array, unnests/flattens grapes.
+ * Upserts on [grape, weekStart].
+ * Returns the count of rows upserted.
+ */
+export async function aggregateGrapeTrends(
+ weekStart: Date
+): Promise {
+ const start = new Date(weekStart);
+ start.setUTCHours(0, 0, 0, 0);
+ const end = new Date(start);
+ end.setUTCDate(end.getUTCDate() + 7);
+
+ const relevantEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: {
+ in: ["wine_taste", "wine_favorite", "wine_view", "wine_search"],
+ },
+ wineId: { not: null },
+ },
+ select: {
+ eventType: true,
+ metadata: true,
+ wine: { select: { grapes: true } },
+ },
+ });
+
+ const grapeMap = new Map<
+ string,
+ { searches: number; tastings: number; favorites: number; ratingSum: number; ratingCount: number }
+ >();
+
+ for (const evt of relevantEvents) {
+ const grapes = evt.wine?.grapes ?? [];
+ for (const grape of grapes) {
+ if (!grapeMap.has(grape)) {
+ grapeMap.set(grape, {
+ searches: 0,
+ tastings: 0,
+ favorites: 0,
+ ratingSum: 0,
+ ratingCount: 0,
+ });
+ }
+ const entry = grapeMap.get(grape)!;
+ switch (evt.eventType) {
+ case "wine_search":
+ entry.searches++;
+ break;
+ case "wine_taste": {
+ entry.tastings++;
+ const meta = evt.metadata as Record | null;
+ const rating = meta?.rating;
+ if (typeof rating === "number") {
+ entry.ratingSum += rating;
+ entry.ratingCount++;
+ }
+ break;
+ }
+ case "wine_favorite":
+ entry.favorites++;
+ break;
+ // wine_view counted but not mapped to a specific field
+ }
+ }
+ }
+
+ let upserted = 0;
+ for (const [grape, data] of grapeMap) {
+ const avgRating =
+ data.ratingCount > 0 ? data.ratingSum / data.ratingCount : null;
+
+ await prisma.grapeTrend.upsert({
+ where: { grape_weekStart: { grape, weekStart: start } },
+ create: {
+ grape,
+ weekStart: start,
+ searches: data.searches,
+ tastings: data.tastings,
+ favorites: data.favorites,
+ avgRating,
+ },
+ update: {
+ searches: data.searches,
+ tastings: data.tastings,
+ favorites: data.favorites,
+ avgRating,
+ },
+ });
+ upserted++;
+ }
+
+ return upserted;
+}
+
+/**
+ * Aggregate producer insights for a given month (first of month).
+ * Counts producer_follow events, aggregates ratings from wine_taste events.
+ * Upserts on [producerName, month].
+ * Returns the count of rows upserted.
+ */
+export async function aggregateProducerInsights(
+ month: Date
+): Promise {
+ const start = new Date(month);
+ start.setUTCDate(1);
+ start.setUTCHours(0, 0, 0, 0);
+ const end = new Date(start);
+ end.setUTCMonth(end.getUTCMonth() + 1);
+
+ // producer_follow events: producer name is in metadata.producerName
+ const followEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "producer_follow",
+ },
+ select: { metadata: true },
+ });
+
+ const producerMap = new Map<
+ string,
+ {
+ followerCount: number;
+ wineRatings: number;
+ ratingSum: number;
+ checkInVolume: number;
+ wishlistAdds: number;
+ }
+ >();
+
+ for (const evt of followEvents) {
+ const meta = evt.metadata as Record | null;
+ const producer = meta?.producerName as string | undefined;
+ if (!producer) continue;
+ if (!producerMap.has(producer)) {
+ producerMap.set(producer, {
+ followerCount: 0,
+ wineRatings: 0,
+ ratingSum: 0,
+ checkInVolume: 0,
+ wishlistAdds: 0,
+ });
+ }
+ producerMap.get(producer)!.followerCount++;
+ }
+
+ // wine_taste events: join with Wine to get producer, extract rating from metadata
+ const tasteEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "wine_taste",
+ wineId: { not: null },
+ },
+ select: {
+ metadata: true,
+ wine: { select: { producer: true } },
+ },
+ });
+
+ for (const evt of tasteEvents) {
+ if (!evt.wine) continue;
+ const producer = evt.wine.producer;
+ if (!producerMap.has(producer)) {
+ producerMap.set(producer, {
+ followerCount: 0,
+ wineRatings: 0,
+ ratingSum: 0,
+ checkInVolume: 0,
+ wishlistAdds: 0,
+ });
+ }
+ const entry = producerMap.get(producer)!;
+ entry.wineRatings++;
+ const meta = evt.metadata as Record | null;
+ const rating = meta?.rating;
+ if (typeof rating === "number") {
+ entry.ratingSum += rating;
+ }
+ }
+
+ // wine_checkin events: join with Wine to get producer
+ const checkinEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "wine_checkin",
+ wineId: { not: null },
+ },
+ select: {
+ wine: { select: { producer: true } },
+ },
+ });
+
+ for (const evt of checkinEvents) {
+ if (!evt.wine) continue;
+ const producer = evt.wine.producer;
+ if (!producerMap.has(producer)) {
+ producerMap.set(producer, {
+ followerCount: 0,
+ wineRatings: 0,
+ ratingSum: 0,
+ checkInVolume: 0,
+ wishlistAdds: 0,
+ });
+ }
+ producerMap.get(producer)!.checkInVolume++;
+ }
+
+ // wine_wishlist events: join with Wine to get producer
+ const wishlistEvents = await prisma.wineEvent.findMany({
+ where: {
+ createdAt: { gte: start, lt: end },
+ eventType: "wine_wishlist",
+ wineId: { not: null },
+ },
+ select: {
+ wine: { select: { producer: true } },
+ },
+ });
+
+ for (const evt of wishlistEvents) {
+ if (!evt.wine) continue;
+ const producer = evt.wine.producer;
+ if (!producerMap.has(producer)) {
+ producerMap.set(producer, {
+ followerCount: 0,
+ wineRatings: 0,
+ ratingSum: 0,
+ checkInVolume: 0,
+ wishlistAdds: 0,
+ });
+ }
+ producerMap.get(producer)!.wishlistAdds++;
+ }
+
+ let upserted = 0;
+ for (const [producerName, data] of producerMap) {
+ const avgRating =
+ data.wineRatings > 0 ? data.ratingSum / data.wineRatings : null;
+
+ await prisma.producerInsight.upsert({
+ where: {
+ producerName_month: { producerName, month: start },
+ },
+ create: {
+ producerName,
+ month: start,
+ followerCount: data.followerCount,
+ wineRatings: data.wineRatings,
+ avgRating,
+ checkInVolume: data.checkInVolume,
+ wishlistAdds: data.wishlistAdds,
+ },
+ update: {
+ followerCount: data.followerCount,
+ wineRatings: data.wineRatings,
+ avgRating,
+ checkInVolume: data.checkInVolume,
+ wishlistAdds: data.wishlistAdds,
+ },
+ });
+ upserted++;
+ }
+
+ return upserted;
+}
+
+/**
+ * Helper: get the Monday of the current week (UTC).
+ */
+function getCurrentWeekStart(): Date {
+ const now = new Date();
+ const day = now.getUTCDay(); // 0=Sun, 1=Mon, ...
+ const diff = day === 0 ? 6 : day - 1; // days since Monday
+ const monday = new Date(now);
+ monday.setUTCDate(now.getUTCDate() - diff);
+ monday.setUTCHours(0, 0, 0, 0);
+ return monday;
+}
+
+/**
+ * Helper: get the first day of the current month (UTC).
+ */
+function getCurrentMonthStart(): Date {
+ const now = new Date();
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
+}
+
+/**
+ * Run all aggregations for the current period.
+ * - Wine popularity: today
+ * - Region trends: current week (Monday)
+ * - Grape trends: current week (Monday)
+ * - Producer insights: current month (1st)
+ */
+export async function runAllAggregations(): Promise<{
+ wine: number;
+ region: number;
+ grape: number;
+ producer: number;
+}> {
+ const today = new Date();
+ today.setUTCHours(0, 0, 0, 0);
+
+ const weekStart = getCurrentWeekStart();
+ const monthStart = getCurrentMonthStart();
+
+ const [wine, region, grape, producer] = await Promise.all([
+ aggregateWinePopularity(today),
+ aggregateRegionTrends(weekStart),
+ aggregateGrapeTrends(weekStart),
+ aggregateProducerInsights(monthStart),
+ ]);
+
+ return { wine, region, grape, producer };
+}
diff --git a/winebob/src/lib/analytics.ts b/winebob/src/lib/analytics.ts
new file mode 100644
index 0000000..00e0851
--- /dev/null
+++ b/winebob/src/lib/analytics.ts
@@ -0,0 +1,177 @@
+"use server";
+
+import { prisma } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
+
+// ============ CONSENT CHECK ============
+
+type ConsentLevel = "basic" | "enhanced";
+
+async function shouldTrack(
+ userId: string | null | undefined,
+ level: ConsentLevel = "basic"
+): Promise {
+ // Anonymous users: allow basic analytics only
+ if (!userId) return level === "basic";
+
+ try {
+ const consent = await prisma.userConsent.findUnique({
+ where: { userId },
+ });
+
+ // No consent record => default to allowing basic analytics
+ if (!consent) return level === "basic";
+
+ if (level === "enhanced") return consent.enhancedConsent;
+ return consent.analyticsConsent;
+ } catch {
+ // If we can't check consent, default to allowing basic analytics only
+ return level === "basic";
+ }
+}
+
+// ============ CORE EVENT LOGGER ============
+
+export async function trackEvent(params: {
+ eventType: string;
+ userId?: string | null;
+ wineId?: string | null;
+ metadata?: Record;
+ sessionId?: string;
+}): Promise {
+ try {
+ const allowed = await shouldTrack(params.userId);
+ if (!allowed) return;
+
+ await prisma.wineEvent.create({
+ data: {
+ eventType: params.eventType,
+ userId: params.userId ?? null,
+ wineId: params.wineId ?? null,
+ metadata: params.metadata
+ ? (params.metadata as Prisma.InputJsonValue)
+ : Prisma.DbNull,
+ sessionId: params.sessionId ?? null,
+ },
+ });
+ } catch (err) {
+ console.error("[analytics] Failed to track event:", err);
+ }
+}
+
+// ============ CONVENIENCE WRAPPERS ============
+// All use fire-and-forget pattern: call trackEvent without awaiting,
+// errors are caught silently inside trackEvent.
+
+export async function trackWineView(
+ userId: string | null,
+ wineId: string,
+ source?: string
+): Promise {
+ trackEvent({
+ eventType: "wine_view",
+ userId,
+ wineId,
+ metadata: source ? { source } : undefined,
+ }).catch(() => {});
+}
+
+export async function trackWineSearch(
+ userId: string | null,
+ query: string,
+ resultCount: number,
+ filters?: Record
+): Promise {
+ trackEvent({
+ eventType: "wine_search",
+ userId,
+ metadata: { query, resultCount, ...filters },
+ }).catch(() => {});
+}
+
+export async function trackWineFavorite(
+ userId: string,
+ wineId: string,
+ rating?: number
+): Promise {
+ trackEvent({
+ eventType: "wine_favorite",
+ userId,
+ wineId,
+ metadata: rating !== undefined ? { rating } : undefined,
+ }).catch(() => {});
+}
+
+export async function trackWineTaste(
+ userId: string,
+ wineId: string,
+ rating?: number,
+ eventId?: string
+): Promise {
+ trackEvent({
+ eventType: "wine_taste",
+ userId,
+ wineId,
+ metadata: { ...(rating !== undefined && { rating }), ...(eventId && { eventId }) },
+ }).catch(() => {});
+}
+
+export async function trackWineCheckin(
+ userId: string,
+ wineId: string,
+ city?: string,
+ country?: string
+): Promise {
+ trackEvent({
+ eventType: "wine_checkin",
+ userId,
+ wineId,
+ metadata: { ...(city && { city }), ...(country && { country }) },
+ }).catch(() => {});
+}
+
+export async function trackWineWishlist(
+ userId: string,
+ wineId: string
+): Promise {
+ trackEvent({
+ eventType: "wine_wishlist",
+ userId,
+ wineId,
+ }).catch(() => {});
+}
+
+export async function trackRegionExplore(
+ userId: string | null,
+ region: string,
+ country: string
+): Promise {
+ trackEvent({
+ eventType: "region_explore",
+ userId,
+ metadata: { region, country },
+ }).catch(() => {});
+}
+
+export async function trackLayerToggle(
+ userId: string | null,
+ layerName: string,
+ enabled: boolean
+): Promise {
+ trackEvent({
+ eventType: "layer_toggle",
+ userId,
+ metadata: { layerName, enabled },
+ }).catch(() => {});
+}
+
+export async function trackProducerFollow(
+ userId: string,
+ producerName: string
+): Promise {
+ trackEvent({
+ eventType: "producer_follow",
+ userId,
+ metadata: { producerName },
+ }).catch(() => {});
+}
diff --git a/winebob/src/lib/auth.ts b/winebob/src/lib/auth.ts
new file mode 100644
index 0000000..e211117
--- /dev/null
+++ b/winebob/src/lib/auth.ts
@@ -0,0 +1,23 @@
+import { getServerSession } from "next-auth";
+import type { Session } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+
+/**
+ * Get the current session on the server side.
+ * Use this in Server Components and Server Actions.
+ */
+export async function auth(): Promise {
+ return getServerSession(authOptions);
+}
+
+/**
+ * Require authentication. Throws if not authenticated.
+ * Use in Server Actions that need a user.
+ */
+export async function requireAuth(): Promise {
+ const session = await auth();
+ if (!session?.user?.id) {
+ throw new Error("Unauthorized");
+ }
+ return session as Session & { user: { id: string; email: string } };
+}
diff --git a/winebob/src/lib/consent.ts b/winebob/src/lib/consent.ts
new file mode 100644
index 0000000..d240e0b
--- /dev/null
+++ b/winebob/src/lib/consent.ts
@@ -0,0 +1,279 @@
+"use server";
+
+import { prisma } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+
+// ── Types ──
+
+export type UserConsentData = {
+ analyticsConsent: boolean;
+ enhancedConsent: boolean;
+ researchConsent: boolean;
+ consentUpdatedAt: Date;
+};
+
+export type UserDataExport = {
+ user: { id: string; email: string; createdAt: Date };
+ events: Array<{
+ eventType: string;
+ wineId?: string;
+ metadata?: unknown;
+ createdAt: Date;
+ }>;
+ favorites: Array<{
+ wineId: string;
+ rating?: number;
+ createdAt: Date;
+ }>;
+ tastings: Array<{
+ wineId: string;
+ rating?: number;
+ tastedAt: Date;
+ }>;
+ checkIns: Array<{
+ wineId: string;
+ lat: number;
+ lng: number;
+ createdAt: Date;
+ }>;
+ wishlist: Array<{
+ wineId: string;
+ priority: number;
+ createdAt: Date;
+ }>;
+ producerFollows: Array<{
+ producerName: string;
+ region: string | null;
+ country: string | null;
+ createdAt: Date;
+ }>;
+ tastingFlights: Array<{
+ name: string | null;
+ path: unknown;
+ wines: unknown;
+ createdAt: Date;
+ }>;
+ exportedAt: Date;
+};
+
+// ── Helpers ──
+
+function toConsentData(consent: {
+ analyticsConsent: boolean;
+ enhancedConsent: boolean;
+ researchConsent: boolean;
+ consentUpdatedAt: Date;
+}): UserConsentData {
+ return {
+ analyticsConsent: consent.analyticsConsent,
+ enhancedConsent: consent.enhancedConsent,
+ researchConsent: consent.researchConsent,
+ consentUpdatedAt: consent.consentUpdatedAt,
+ };
+}
+
+// ── Server Actions ──
+
+/**
+ * Get the current user's consent settings.
+ * Creates a default record if none exists.
+ */
+export async function getUserConsent(): Promise {
+ const session = await requireAuth();
+ const userId = session.user.id;
+
+ const existing = await prisma.userConsent.findUnique({ where: { userId } });
+ const consent = existing ?? await prisma.userConsent.create({ data: { userId } });
+
+ return toConsentData(consent);
+}
+
+/**
+ * Update the current user's consent settings.
+ */
+export async function updateConsent(data: {
+ analyticsConsent?: boolean;
+ enhancedConsent?: boolean;
+ researchConsent?: boolean;
+}): Promise {
+ const session = await requireAuth();
+ const userId = session.user.id;
+
+ const existing = await prisma.userConsent.findUnique({ where: { userId } });
+ const consent = existing
+ ? await prisma.userConsent.update({
+ where: { userId },
+ data: {
+ ...(data.analyticsConsent !== undefined && {
+ analyticsConsent: data.analyticsConsent,
+ }),
+ ...(data.enhancedConsent !== undefined && {
+ enhancedConsent: data.enhancedConsent,
+ }),
+ ...(data.researchConsent !== undefined && {
+ researchConsent: data.researchConsent,
+ }),
+ consentUpdatedAt: new Date(),
+ },
+ })
+ : await prisma.userConsent.create({
+ data: {
+ userId,
+ analyticsConsent: data.analyticsConsent ?? true,
+ enhancedConsent: data.enhancedConsent ?? false,
+ researchConsent: data.researchConsent ?? false,
+ consentUpdatedAt: new Date(),
+ },
+ });
+
+ return toConsentData(consent);
+}
+
+/**
+ * Check if a specific consent level is granted for a user.
+ * Used by analytics.ts and other server-side code.
+ */
+export async function hasConsent(
+ userId: string,
+ level: "analytics" | "enhanced" | "research"
+): Promise {
+ const consent = await prisma.userConsent.findUnique({
+ where: { userId },
+ });
+
+ if (!consent) {
+ // Default: analytics is true, others false
+ return level === "analytics";
+ }
+
+ switch (level) {
+ case "analytics":
+ return consent.analyticsConsent;
+ case "enhanced":
+ return consent.enhancedConsent;
+ case "research":
+ return consent.researchConsent;
+ }
+}
+
+/**
+ * Export all of the current user's data (GDPR right of access).
+ */
+export async function exportUserData(): Promise {
+ const session = await requireAuth();
+ const userId = session.user.id;
+
+ const [user, events, favorites, tastings, checkIns, wishlist, producerFollows, tastingFlights] = await Promise.all([
+ prisma.user.findUniqueOrThrow({
+ where: { id: userId },
+ select: { id: true, email: true, createdAt: true },
+ }),
+ prisma.wineEvent.findMany({
+ where: { userId },
+ select: {
+ eventType: true,
+ wineId: true,
+ metadata: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: "desc" },
+ }),
+ prisma.wineFavorite.findMany({
+ where: { userId },
+ select: { wineId: true, rating: true, createdAt: true },
+ orderBy: { createdAt: "desc" },
+ }),
+ prisma.wineTasting.findMany({
+ where: { userId },
+ select: { wineId: true, rating: true, tastedAt: true },
+ orderBy: { tastedAt: "desc" },
+ }),
+ prisma.wineCheckIn.findMany({
+ where: { userId },
+ select: { wineId: true, lat: true, lng: true, createdAt: true },
+ orderBy: { createdAt: "desc" },
+ }),
+ prisma.wineWishlist.findMany({
+ where: { userId },
+ select: { wineId: true, priority: true, createdAt: true },
+ orderBy: { createdAt: "desc" },
+ }),
+ prisma.producerFollow.findMany({
+ where: { userId },
+ select: { producerName: true, region: true, country: true, createdAt: true },
+ orderBy: { createdAt: "desc" },
+ }),
+ prisma.tastingFlight.findMany({
+ where: { userId },
+ select: { name: true, path: true, wines: true, createdAt: true },
+ orderBy: { createdAt: "desc" },
+ }),
+ ]);
+
+ return {
+ user,
+ events: events.map((e) => ({
+ eventType: e.eventType,
+ wineId: e.wineId ?? undefined,
+ metadata: e.metadata ?? undefined,
+ createdAt: e.createdAt,
+ })),
+ favorites: favorites.map((f) => ({
+ wineId: f.wineId,
+ rating: f.rating ?? undefined,
+ createdAt: f.createdAt,
+ })),
+ tastings: tastings.map((t) => ({
+ wineId: t.wineId,
+ rating: t.rating ?? undefined,
+ tastedAt: t.tastedAt,
+ })),
+ checkIns,
+ wishlist,
+ producerFollows,
+ tastingFlights,
+ exportedAt: new Date(),
+ };
+}
+
+/**
+ * Delete ALL of the current user's personal data (GDPR right to erasure).
+ * Removes analytics events, favorites, tastings, wishlist, check-ins,
+ * producer follows, tasting flights, and the consent record itself.
+ */
+export async function deleteUserData(): Promise<{
+ events: number;
+ favorites: number;
+ tastings: number;
+ wishlist: number;
+ checkIns: number;
+ producerFollows: number;
+ tastingFlights: number;
+ consent: number;
+}> {
+ const session = await requireAuth();
+ const userId = session.user.id;
+
+ const [events, favorites, tastings, wishlist, checkIns, producerFollows, tastingFlights, consent] =
+ await Promise.all([
+ prisma.wineEvent.deleteMany({ where: { userId } }),
+ prisma.wineFavorite.deleteMany({ where: { userId } }),
+ prisma.wineTasting.deleteMany({ where: { userId } }),
+ prisma.wineWishlist.deleteMany({ where: { userId } }),
+ prisma.wineCheckIn.deleteMany({ where: { userId } }),
+ prisma.producerFollow.deleteMany({ where: { userId } }),
+ prisma.tastingFlight.deleteMany({ where: { userId } }),
+ prisma.userConsent.deleteMany({ where: { userId } }),
+ ]);
+
+ return {
+ events: events.count,
+ favorites: favorites.count,
+ tastings: tastings.count,
+ wishlist: wishlist.count,
+ checkIns: checkIns.count,
+ producerFollows: producerFollows.count,
+ tastingFlights: tastingFlights.count,
+ consent: consent.count,
+ };
+}
diff --git a/winebob/src/lib/db.ts b/winebob/src/lib/db.ts
new file mode 100644
index 0000000..f7361ac
--- /dev/null
+++ b/winebob/src/lib/db.ts
@@ -0,0 +1,20 @@
+import { PrismaNeonHttp } from "@prisma/adapter-neon";
+import { PrismaClient } from "@/generated/prisma/client";
+
+function createPrismaClient() {
+ const dbUrl = process.env.DATABASE_URL;
+ if (!dbUrl) {
+ throw new Error("DATABASE_URL is not set");
+ }
+ const adapter = new PrismaNeonHttp(dbUrl, {});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return new PrismaClient({ adapter } as any);
+}
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: ReturnType | undefined;
+};
+
+export const prisma = globalForPrisma.prisma ?? createPrismaClient();
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/winebob/src/lib/importers/normalize.ts b/winebob/src/lib/importers/normalize.ts
new file mode 100644
index 0000000..fd870eb
--- /dev/null
+++ b/winebob/src/lib/importers/normalize.ts
@@ -0,0 +1,209 @@
+/**
+ * Shared normalization utilities for wine data imports.
+ */
+
+// Common abbreviation expansions for wine names
+const ABBREVIATION_EXPANSIONS: Record = {
+ "ch.": "Château",
+ "dom.": "Domaine",
+ "st.": "Saint",
+ "ste.": "Sainte",
+ "mt.": "Mount",
+ "ft.": "Fort",
+};
+
+// Common grape alias mappings (alias -> canonical name)
+const GRAPE_ALIASES: Record = {
+ "cab sav": "Cabernet Sauvignon",
+ "cab": "Cabernet Sauvignon",
+ "cabernet": "Cabernet Sauvignon",
+ "cab franc": "Cabernet Franc",
+ "sav blanc": "Sauvignon Blanc",
+ "sauvignon": "Sauvignon Blanc",
+ "pinot grigio": "Pinot Gris",
+ "pinot grigo": "Pinot Gris",
+ "shiraz": "Syrah",
+ "syrah/shiraz": "Syrah",
+ "garnacha": "Grenache",
+ "garnatxa": "Grenache",
+ "spätburgunder": "Pinot Noir",
+ "blauburgunder": "Pinot Noir",
+ "grauburgunder": "Pinot Gris",
+ "weissburgunder": "Pinot Blanc",
+ "weißburgunder": "Pinot Blanc",
+ "tempranillo": "Tempranillo",
+ "tinta roriz": "Tempranillo",
+ "tinto fino": "Tempranillo",
+ "cencibel": "Tempranillo",
+ "aragonez": "Tempranillo",
+ "ull de llebre": "Tempranillo",
+ "sangiovese grosso": "Sangiovese",
+ "brunello": "Sangiovese",
+ "prugnolo gentile": "Sangiovese",
+ "morellino": "Sangiovese",
+ "nielluccio": "Sangiovese",
+ "mourvèdre": "Mourvèdre",
+ "mourvedre": "Mourvèdre",
+ "monastrell": "Mourvèdre",
+ "mataro": "Mourvèdre",
+ "pinot meunier": "Meunier",
+ "meunier": "Meunier",
+ "schwarzriesling": "Meunier",
+ "trebbiano": "Ugni Blanc",
+ "ugni blanc": "Ugni Blanc",
+ "grüner veltliner": "Grüner Veltliner",
+ "gruner veltliner": "Grüner Veltliner",
+ "cariñena": "Carignan",
+ "carignane": "Carignan",
+ "mazuelo": "Carignan",
+ "zinfandel": "Zinfandel",
+ "primitivo": "Zinfandel",
+ "malbec": "Malbec",
+ "côt": "Malbec",
+ "cot": "Malbec",
+ "auxerrois": "Malbec",
+};
+
+/**
+ * Convert a string to proper title case, handling wine-specific conventions.
+ */
+function toProperCase(str: string): string {
+ // Common lowercase words that should stay lowercase (unless first word)
+ const lowerWords = new Set(["de", "di", "du", "da", "des", "del", "della", "delle", "von", "van", "le", "la", "les", "et", "y", "e", "do", "dos"]);
+
+ return str
+ .split(/\s+/)
+ .map((word, index) => {
+ if (index > 0 && lowerWords.has(word.toLowerCase())) {
+ return word.toLowerCase();
+ }
+ // Preserve existing capitalization for words with internal caps (e.g., "McDonald")
+ if (word.length > 1 && word !== word.toLowerCase() && word !== word.toUpperCase()) {
+ return word;
+ }
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+ })
+ .join(" ");
+}
+
+/**
+ * Normalize Unicode accented characters to their base + combining form,
+ * then re-compose. This standardizes different Unicode representations
+ * of the same accented character. Preserves accents for display.
+ */
+function normalizeAccents(str: string): string {
+ // NFC normalization: compose characters into canonical form
+ return str.normalize("NFC");
+}
+
+/**
+ * Strip accents for comparison/deduplication purposes.
+ * "Château" -> "Chateau", "Côtes" -> "Cotes", etc.
+ * Do NOT use this for display — only for fingerprinting/dedup.
+ */
+function stripAccents(str: string): string {
+ return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
+}
+
+/**
+ * Expand common wine abbreviations for display normalization.
+ * "Ch. Margaux" -> "Château Margaux", "Dom. Romanée" -> "Domaine Romanée"
+ */
+function expandAbbreviations(str: string): string {
+ return str.replace(/\b\w+\./g, (match) => {
+ const expansion = ABBREVIATION_EXPANSIONS[match.toLowerCase()];
+ return expansion ?? match;
+ });
+}
+
+/**
+ * Normalize a wine name: trim whitespace, proper capitalization, standardize accents.
+ */
+export function normalizeWineName(name: string): string {
+ if (!name) return "";
+
+ let normalized = name.trim();
+
+ // Remove multiple spaces
+ normalized = normalized.replace(/\s+/g, " ");
+
+ // Expand common abbreviations (Ch. -> Château, Dom. -> Domaine, etc.)
+ normalized = expandAbbreviations(normalized);
+
+ // Standardize accents (preserve for display)
+ normalized = normalizeAccents(normalized);
+
+ // Proper case
+ normalized = toProperCase(normalized);
+
+ return normalized;
+}
+
+/**
+ * Normalize a producer name: similar to wine name normalization.
+ */
+export function normalizeProducerName(name: string): string {
+ if (!name) return "";
+
+ let normalized = name.trim();
+
+ // Remove multiple spaces
+ normalized = normalized.replace(/\s+/g, " ");
+
+ // Expand common abbreviations (Ch. -> Château, Dom. -> Domaine, etc.)
+ normalized = expandAbbreviations(normalized);
+
+ // Standardize accents (preserve for display)
+ normalized = normalizeAccents(normalized);
+
+ // Proper case
+ normalized = toProperCase(normalized);
+
+ return normalized;
+}
+
+/**
+ * Normalize a grape name: map aliases to canonical names, proper case.
+ */
+export function normalizeGrapeName(name: string): string {
+ if (!name) return "";
+
+ let normalized = name.trim();
+ normalized = normalized.replace(/\s+/g, " ");
+ normalized = normalizeAccents(normalized);
+
+ // Check alias mapping (case-insensitive)
+ const alias = GRAPE_ALIASES[normalized.toLowerCase()];
+ if (alias) {
+ return alias;
+ }
+
+ // Proper case if no alias found
+ return toProperCase(normalized);
+}
+
+/**
+ * Generate a deterministic fingerprint for deduplication.
+ * Based on normalized name + producer + optional vintage.
+ */
+export function generateWineFingerprint(
+ name: string,
+ producer: string,
+ vintage?: number | null
+): string {
+ // Strip accents so "Château" and "Chateau" produce the same fingerprint
+ const normalizedName = stripAccents(normalizeWineName(name).toLowerCase());
+ const normalizedProducer = stripAccents(normalizeProducerName(producer).toLowerCase());
+ const vintageStr = vintage != null ? String(vintage) : "nv";
+
+ const input = `${normalizedName}|${normalizedProducer}|${vintageStr}`;
+
+ // Simple deterministic hash (djb2 variant)
+ let hash = 5381;
+ for (let i = 0; i < input.length; i++) {
+ hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0;
+ }
+
+ // Convert to hex string, ensure positive
+ return (hash >>> 0).toString(16).padStart(8, "0");
+}
diff --git a/winebob/src/lib/importers/openfoodfacts.ts b/winebob/src/lib/importers/openfoodfacts.ts
new file mode 100644
index 0000000..16e77ac
--- /dev/null
+++ b/winebob/src/lib/importers/openfoodfacts.ts
@@ -0,0 +1,521 @@
+/**
+ * Open Food Facts Wine Import Pipeline
+ *
+ * Fetches wine products from the Open Food Facts API and imports them
+ * into the Winebob database. Handles pagination, deduplication, rate
+ * limiting, and retry logic.
+ *
+ * Usage:
+ * npx tsx src/lib/importers/openfoodfacts.ts
+ */
+
+import "dotenv/config";
+import { PrismaNeonHttp } from "@prisma/adapter-neon";
+import { PrismaClient } from "../../generated/prisma/client";
+
+// ---------------------------------------------------------------------------
+// Prisma client (standalone — for CLI usage with `npx tsx`)
+// ---------------------------------------------------------------------------
+
+function createPrismaClient(): PrismaClient {
+ const dbUrl = process.env.DATABASE_URL;
+ if (!dbUrl) {
+ throw new Error("DATABASE_URL is not set");
+ }
+ const adapter = new PrismaNeonHttp(dbUrl, {});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return new PrismaClient({ adapter } as any);
+}
+
+const prisma = createPrismaClient();
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface OFFProduct {
+ product_name?: string;
+ brands?: string;
+ code?: string;
+ categories_tags?: string[];
+ origins?: string;
+ countries?: string;
+ labels?: string;
+ quantity?: string;
+ nutriments?: { alcohol_100g?: number };
+ image_url?: string;
+ generic_name?: string;
+ ingredients_text?: string;
+}
+
+interface OFFSearchResponse {
+ count: number;
+ page: number;
+ page_count: number;
+ page_size: number;
+ products: OFFProduct[];
+}
+
+interface ImportStats {
+ fetched: number;
+ created: number;
+ skipped: number;
+ failed: number;
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const BASE_URL = "https://world.openfoodfacts.org";
+const PAGE_SIZE = 100;
+const MAX_PAGES_PER_CATEGORY = 50;
+const BATCH_INSERT_SIZE = 50;
+const REQUEST_DELAY_MS = 1050; // slightly over 1s to respect rate limits
+const MAX_RETRIES = 3;
+const SOURCE = "openfoodfacts";
+const CONFIDENCE = 0.6;
+
+const WINE_CATEGORIES = [
+ "wines",
+ "red wines",
+ "white wines",
+ "rosé wines",
+ "sparkling wines",
+ "champagne",
+ "dessert wines",
+ "fortified wines",
+];
+
+// ---------------------------------------------------------------------------
+// Normalization helpers (inline since normalize.ts does not exist yet)
+// ---------------------------------------------------------------------------
+
+function cleanString(s: string | undefined | null): string {
+ if (!s) return "";
+ return s.replace(/\s+/g, " ").trim();
+}
+
+function titleCase(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/(^|\s)\S/g, (ch) => ch.toUpperCase());
+}
+
+function extractFirstBrand(brands: string | undefined): string {
+ if (!brands) return "";
+ // Brands field often uses comma-separated values
+ const first = brands.split(/[,;]/).map((b) => b.trim()).filter(Boolean)[0];
+ return first ? titleCase(cleanString(first)) : "";
+}
+
+function inferWineType(categoryTags: string[] | undefined): string {
+ if (!categoryTags || categoryTags.length === 0) return "red"; // default
+ const joined = categoryTags.join(" ").toLowerCase();
+ if (joined.includes("sparkling") || joined.includes("champagne") || joined.includes("cava") || joined.includes("prosecco") || joined.includes("cremant")) {
+ return "sparkling";
+ }
+ if (joined.includes("fortified") || joined.includes("port") || joined.includes("sherry") || joined.includes("madeira") || joined.includes("marsala")) {
+ return "fortified";
+ }
+ if (joined.includes("dessert") || joined.includes("sweet") || joined.includes("ice-wine") || joined.includes("sauternes") || joined.includes("tokaji")) {
+ return "dessert";
+ }
+ if (joined.includes("rosé") || joined.includes("rose-wine") || joined.includes("rose ")) {
+ return "rosé";
+ }
+ if (joined.includes("white") || joined.includes("blanc")) {
+ return "white";
+ }
+ if (joined.includes("orange")) {
+ return "orange";
+ }
+ // Default to red
+ return "red";
+}
+
+function extractCountry(countriesField: string | undefined): string {
+ if (!countriesField) return "";
+ // Countries field may be comma-separated or contain "en:france" style tags
+ const cleaned = countriesField
+ .split(/[,;]/)
+ .map((c) => c.replace(/^[a-z]{2}:/, "").trim())
+ .filter(Boolean)[0];
+ return cleaned ? titleCase(cleaned) : "";
+}
+
+function extractRegion(originsField: string | undefined): string {
+ if (!originsField) return "Unknown";
+ const cleaned = originsField
+ .split(/[,;]/)
+ .map((r) => r.replace(/^[a-z]{2}:/, "").trim())
+ .filter(Boolean)[0];
+ return cleaned ? titleCase(cleaned) : "";
+}
+
+function extractGrapes(
+ ingredientsText: string | undefined,
+ genericName: string | undefined,
+): string[] {
+ const source = [ingredientsText, genericName].filter(Boolean).join(" ").toLowerCase();
+ if (!source) return [];
+
+ // Common grape varieties to look for
+ const knownGrapes = [
+ "cabernet sauvignon", "merlot", "pinot noir", "syrah", "shiraz",
+ "tempranillo", "sangiovese", "nebbiolo", "grenache", "garnacha",
+ "malbec", "zinfandel", "primitivo", "mourvèdre", "monastrell",
+ "barbera", "gamay", "carménère", "petit verdot", "cabernet franc",
+ "chardonnay", "sauvignon blanc", "riesling", "pinot grigio",
+ "pinot gris", "gewürztraminer", "viognier", "chenin blanc",
+ "sémillon", "semillon", "muscadet", "muscat", "moscato",
+ "grüner veltliner", "albariño", "albarino", "verdejo",
+ "torrontés", "marsanne", "roussanne", "trebbiano", "vermentino",
+ "garganega", "cortese", "fiano", "greco", "arneis", "prosecco",
+ "glera", "cava", "touriga nacional", "tinta roriz",
+ ];
+
+ const found: string[] = [];
+ for (const grape of knownGrapes) {
+ if (source.includes(grape)) {
+ found.push(titleCase(grape));
+ }
+ }
+ return Array.from(new Set(found));
+}
+
+function extractAbv(nutriments: OFFProduct["nutriments"]): number | null {
+ if (!nutriments?.alcohol_100g) return null;
+ const val = Number(nutriments.alcohol_100g);
+ // Sanity check: ABV should be between 0 and 25 for wine
+ if (isNaN(val) || val < 0 || val > 25) return null;
+ return Math.round(val * 10) / 10; // one decimal
+}
+
+// ---------------------------------------------------------------------------
+// Rate-limited fetcher with retry
+// ---------------------------------------------------------------------------
+
+let lastRequestTime = 0;
+
+async function rateLimitedFetch(url: string): Promise {
+ const now = Date.now();
+ const elapsed = now - lastRequestTime;
+ if (elapsed < REQUEST_DELAY_MS) {
+ await sleep(REQUEST_DELAY_MS - elapsed);
+ }
+ lastRequestTime = Date.now();
+
+ let lastError: Error | null = null;
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ try {
+ const res = await fetch(url, {
+ headers: {
+ "User-Agent": "Winebob Wine App - wine data import - contact@winebob.com",
+ },
+ });
+ if (res.status === 429 || res.status >= 500) {
+ const backoff = Math.pow(2, attempt + 1) * 1000;
+ console.warn(` [retry] HTTP ${res.status} — waiting ${backoff}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
+ await sleep(backoff);
+ continue;
+ }
+ return res;
+ } catch (err) {
+ lastError = err instanceof Error ? err : new Error(String(err));
+ const backoff = Math.pow(2, attempt + 1) * 1000;
+ console.warn(` [retry] Network error — waiting ${backoff}ms (attempt ${attempt + 1}/${MAX_RETRIES}): ${lastError.message}`);
+ await sleep(backoff);
+ }
+ }
+ throw lastError ?? new Error("Fetch failed after retries");
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+// ---------------------------------------------------------------------------
+// Search one page of results
+// ---------------------------------------------------------------------------
+
+async function fetchPage(category: string, page: number): Promise {
+ // Use the Open Food Facts search endpoint with search_terms for broad matching
+ const url =
+ `${BASE_URL}/cgi/search.pl?search_terms=${encodeURIComponent(category)}` +
+ `&page_size=${PAGE_SIZE}&page=${page}&json=true`;
+
+ const res = await rateLimitedFetch(url);
+ if (!res.ok) {
+ throw new Error(`OFF API returned HTTP ${res.status} for category "${category}" page ${page}`);
+ }
+ return (await res.json()) as OFFSearchResponse;
+}
+
+// ---------------------------------------------------------------------------
+// Map OFF product to our Wine create input
+// ---------------------------------------------------------------------------
+
+function mapProduct(product: OFFProduct, importBatchId: string) {
+ const name = cleanString(product.product_name);
+ const producer = extractFirstBrand(product.brands);
+
+ if (!name) return null; // skip unnamed products
+
+ return {
+ name,
+ producer: producer || "",
+ barcode: product.code || null,
+ country: extractCountry(product.countries),
+ region: extractRegion(product.origins),
+ type: inferWineType(product.categories_tags),
+ grapes: extractGrapes(product.ingredients_text, product.generic_name),
+ abv: extractAbv(product.nutriments),
+ labelImage: product.image_url || null,
+ source: SOURCE,
+ confidence: CONFIDENCE,
+ externalIds: product.code ? { openfoodfacts: product.code } : undefined,
+ importBatchId,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Dedup check
+// ---------------------------------------------------------------------------
+
+async function isDuplicate(
+ barcode: string | null,
+ name: string,
+ producer: string,
+): Promise {
+ // Check barcode first (fast path)
+ if (barcode) {
+ const existing = await prisma.wine.findFirst({
+ where: { barcode },
+ select: { id: true },
+ });
+ if (existing) return true;
+ }
+
+ // Check name + producer match
+ const existing = await prisma.wine.findFirst({
+ where: { name, producer },
+ select: { id: true },
+ });
+ return !!existing;
+}
+
+// ---------------------------------------------------------------------------
+// Batch insert wines
+// ---------------------------------------------------------------------------
+
+async function batchInsertWines(
+ wines: ReturnType[],
+): Promise<{ created: number; failed: number }> {
+ let created = 0;
+ let failed = 0;
+
+ // Filter out nulls
+ const valid = wines.filter(
+ (w): w is NonNullable => w !== null,
+ );
+
+ for (const wine of valid) {
+ try {
+ await prisma.wine.create({ data: wine });
+ created++;
+ } catch (err) {
+ failed++;
+ const msg = err instanceof Error ? err.message : String(err);
+ // Log but continue — partial failures are expected
+ if (!msg.includes("Unique constraint")) {
+ console.warn(` [skip] Failed to insert "${wine.name}": ${msg}`);
+ }
+ }
+ }
+ return { created, failed };
+}
+
+// ---------------------------------------------------------------------------
+// Main import function
+// ---------------------------------------------------------------------------
+
+export async function runOpenFoodFactsImport(): Promise<{
+ batchId: string;
+ stats: ImportStats;
+}> {
+ // 1. Create ImportBatch
+ const batch = await prisma.importBatch.create({
+ data: {
+ source: SOURCE,
+ status: "running",
+ createdBy: "system",
+ metadata: { categories: WINE_CATEGORIES, pageSize: PAGE_SIZE, maxPagesPerCategory: MAX_PAGES_PER_CATEGORY },
+ },
+ });
+
+ console.log(`[OFF Import] Started batch ${batch.id}`);
+
+ const stats: ImportStats = { fetched: 0, created: 0, skipped: 0, failed: 0 };
+
+ // Track barcodes seen across all categories to prevent cross-category
+ // duplicates (e.g., a wine appearing in both "wines" and "red wines")
+ const seenBarcodes = new Set();
+
+ try {
+ for (const category of WINE_CATEGORIES) {
+ console.log(`\n[OFF Import] Searching category: "${category}"`);
+ let pendingBatch: ReturnType[] = [];
+
+ for (let page = 1; page <= MAX_PAGES_PER_CATEGORY; page++) {
+ let response: OFFSearchResponse;
+ try {
+ response = await fetchPage(category, page);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(` [error] Failed to fetch page ${page} for "${category}": ${msg}`);
+ stats.failed++;
+ break;
+ }
+
+ const products = response.products ?? [];
+ if (products.length === 0) {
+ console.log(` Page ${page}: no more products. Moving to next category.`);
+ break;
+ }
+
+ stats.fetched += products.length;
+
+ let newCount = 0;
+ let skipCount = 0;
+
+ for (const product of products) {
+ try {
+ const mapped = mapProduct(product, batch.id);
+ if (!mapped) {
+ skipCount++;
+ stats.skipped++;
+ continue;
+ }
+
+ // Cross-category dedup: skip if we've already seen this barcode
+ // in a previous category during this import run
+ if (mapped.barcode && seenBarcodes.has(mapped.barcode)) {
+ skipCount++;
+ stats.skipped++;
+ continue;
+ }
+
+ const dup = await isDuplicate(mapped.barcode, mapped.name, mapped.producer);
+ if (dup) {
+ skipCount++;
+ stats.skipped++;
+ if (mapped.barcode) seenBarcodes.add(mapped.barcode);
+ continue;
+ }
+
+ pendingBatch.push(mapped);
+ newCount++;
+ if (mapped.barcode) seenBarcodes.add(mapped.barcode);
+
+ // Flush batch when it reaches the target size
+ if (pendingBatch.length >= BATCH_INSERT_SIZE) {
+ const result = await batchInsertWines(pendingBatch);
+ stats.created += result.created;
+ stats.failed += result.failed;
+ pendingBatch = [];
+ }
+ } catch (err) {
+ stats.failed++;
+ const msg = err instanceof Error ? err.message : String(err);
+ console.warn(` [skip] Error processing product "${product.product_name}": ${msg}`);
+ }
+ }
+
+ const totalPages = Math.min(
+ MAX_PAGES_PER_CATEGORY,
+ Math.ceil((response.count || 0) / PAGE_SIZE),
+ );
+
+ console.log(
+ ` Page ${page}/${totalPages} for '${category}': ${stats.fetched} products fetched, ${newCount} new wines, ${skipCount} skipped`,
+ );
+
+ // If we've gone past the total available pages, stop
+ if (page * PAGE_SIZE >= (response.count || 0)) {
+ break;
+ }
+ }
+
+ // Flush remaining batch for this category
+ if (pendingBatch.length > 0) {
+ const result = await batchInsertWines(pendingBatch);
+ stats.created += result.created;
+ stats.failed += result.failed;
+ pendingBatch = [];
+ }
+ }
+
+ // 6. Update ImportBatch with final counts
+ await prisma.importBatch.update({
+ where: { id: batch.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ recordsFetched: stats.fetched,
+ recordsCreated: stats.created,
+ recordsSkipped: stats.skipped,
+ recordsFailed: stats.failed,
+ },
+ });
+
+ console.log(`\n[OFF Import] Completed batch ${batch.id}`);
+ console.log(
+ ` Fetched: ${stats.fetched} | Created: ${stats.created} | Skipped: ${stats.skipped} | Failed: ${stats.failed}`,
+ );
+
+ return { batchId: batch.id, stats };
+ } catch (err) {
+ // Cleanup: mark batch as failed
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ console.error(`\n[OFF Import] FATAL: ${errorMessage}`);
+
+ await prisma.importBatch.update({
+ where: { id: batch.id },
+ data: {
+ status: "failed",
+ completedAt: new Date(),
+ recordsFetched: stats.fetched,
+ recordsCreated: stats.created,
+ recordsSkipped: stats.skipped,
+ recordsFailed: stats.failed,
+ errorLog: errorMessage,
+ },
+ });
+
+ throw err;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CLI entry point
+// ---------------------------------------------------------------------------
+
+const isMainModule =
+ typeof process !== "undefined" &&
+ process.argv[1] &&
+ (process.argv[1].endsWith("openfoodfacts.ts") ||
+ process.argv[1].endsWith("openfoodfacts.js"));
+
+if (isMainModule) {
+ runOpenFoodFactsImport()
+ .then(({ batchId, stats }) => {
+ console.log(`\nDone. Batch ID: ${batchId}`);
+ console.log(`Stats: ${JSON.stringify(stats)}`);
+ process.exit(0);
+ })
+ .catch((err) => {
+ console.error("Import failed:", err);
+ process.exit(1);
+ });
+}
diff --git a/winebob/src/lib/importers/ttb.ts b/winebob/src/lib/importers/ttb.ts
new file mode 100644
index 0000000..7a147e6
--- /dev/null
+++ b/winebob/src/lib/importers/ttb.ts
@@ -0,0 +1,726 @@
+/**
+ * US TTB COLA (Certificate of Label Approval) Import Pipeline
+ *
+ * Imports wine label data from the TTB Public COLA Registry into the
+ * Winebob database. Supports both the TTB search API and CSV bulk
+ * download fallback.
+ *
+ * Usage:
+ * npx tsx src/lib/importers/ttb.ts
+ * npx tsx src/lib/importers/ttb.ts --csv /path/to/cola_data.csv
+ *
+ * Can also be imported as a module:
+ * import { runTtbImport } from "@/lib/importers/ttb";
+ */
+
+import "dotenv/config";
+import { createReadStream } from "fs";
+import { createInterface } from "readline";
+import { PrismaNeonHttp } from "@prisma/adapter-neon";
+import { PrismaClient } from "../../generated/prisma/client";
+import {
+ normalizeWineName,
+ normalizeProducerName,
+ normalizeGrapeName,
+ generateWineFingerprint,
+} from "./normalize";
+
+// ---------------------------------------------------------------------------
+// Prisma client (standalone — mirrors wikidata.ts pattern for CLI usage)
+// ---------------------------------------------------------------------------
+
+function createPrismaClient(): PrismaClient {
+ const dbUrl = process.env.DATABASE_URL;
+ if (!dbUrl) {
+ throw new Error("DATABASE_URL is not set");
+ }
+ const adapter = new PrismaNeonHttp(dbUrl, {});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return new PrismaClient({ adapter } as any);
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+// WARNING: The TTB Public COLA API endpoint below is EXPERIMENTAL/UNTESTED.
+// The TTB does not publish a stable public REST API for COLA data. This URL
+// is a best-guess based on their website; it may not exist or may change
+// without notice. The RELIABLE path for bulk import is CSV mode:
+// npx tsx src/lib/importers/ttb.ts --csv /path/to/cola_data.csv
+// CSV bulk downloads are available at: https://www.ttb.gov/foia/xls/frl-spirits-702010.zip
+const TTB_API_URL = "https://www.ttb.gov/public-cola/api/search";
+const RATE_LIMIT_MS = 500;
+const MAX_RETRIES = 3;
+const RETRY_DELAY_MS = 2000;
+const BATCH_SIZE = 100;
+const API_PAGE_SIZE = 100;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface TtbApiRecord {
+ permitId?: string;
+ serialNumber?: string;
+ brandName?: string;
+ fancifulName?: string;
+ classType?: string;
+ appellation?: string;
+ grapePct?: Array<{ grape: string; percentage: number }>;
+ alcoholContent?: string;
+ netContents?: string;
+ origin?: string;
+ vintageDate?: string;
+ approvalDate?: string;
+}
+
+interface TtbApiResponse {
+ totalResults?: number;
+ results?: TtbApiRecord[];
+}
+
+interface ParsedTtbWine {
+ serialNumber: string;
+ name: string;
+ producer: string;
+ vintage: number | null;
+ grapes: string[];
+ region: string;
+ country: string;
+ appellation: string;
+ type: string;
+ abv: number | null;
+ fingerprint: string;
+}
+
+interface ImportStats {
+ recordsFetched: number;
+ recordsCreated: number;
+ recordsUpdated: number;
+ recordsSkipped: number;
+ recordsFailed: number;
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Parse vintage year from a date string or year string.
+ * Handles formats like "2020", "2020-01-01", "01/01/2020", etc.
+ */
+function parseVintageYear(dateStr: string | undefined | null): number | null {
+ if (!dateStr) return null;
+ const trimmed = dateStr.trim();
+ if (!trimmed) return null;
+
+ // Try direct 4-digit year
+ const yearOnly = trimmed.match(/^(\d{4})$/);
+ if (yearOnly) {
+ const year = parseInt(yearOnly[1], 10);
+ if (year >= 1900 && year <= 2100) return year;
+ }
+
+ // Try extracting year from date formats
+ const yearInDate = trimmed.match(/(\d{4})/);
+ if (yearInDate) {
+ const year = parseInt(yearInDate[1], 10);
+ if (year >= 1900 && year <= 2100) return year;
+ }
+
+ return null;
+}
+
+/**
+ * Parse ABV from a string like "12.5%", "12.5", "12.5% by volume", etc.
+ */
+function parseAbv(content: string | undefined | null): number | null {
+ if (!content) return null;
+ const match = content.match(/([\d.]+)\s*%?/);
+ if (match) {
+ const val = parseFloat(match[1]);
+ if (!isNaN(val) && val > 0 && val <= 100) return val;
+ }
+ return null;
+}
+
+// TTB class/type numeric codes (when classType is a code rather than text).
+// See: https://www.ttb.gov/images/pdfs/p51908.pdf
+const TTB_CLASS_CODES: Record = {
+ "2020": "red", // Table Wine
+ "2021": "red", // Table Wine - Red
+ "2022": "white", // Table Wine - White
+ "2023": "rosé", // Table Wine - Rosé
+ "2030": "dessert", // Dessert Wine
+ "2040": "sparkling", // Sparkling Wine / Champagne
+ "2050": "fortified", // Fortified Wine
+ "2060": "dessert", // Special Natural Wine
+ "2070": "red", // Aperitif Wine
+};
+
+/**
+ * Infer wine type from TTB classType string or numeric code.
+ */
+function inferWineType(classType: string | undefined | null): string {
+ if (!classType) return "red"; // default
+
+ const trimmed = classType.trim();
+
+ // Fallback: if classType is a numeric code, use the code mapping
+ if (/^\d+$/.test(trimmed)) {
+ return TTB_CLASS_CODES[trimmed] ?? "red";
+ }
+
+ const upper = trimmed.toUpperCase();
+
+ if (upper.includes("SPARKLING") || upper.includes("CHAMPAGNE")) {
+ return "sparkling";
+ }
+ if (
+ upper.includes("DESSERT") ||
+ upper.includes("SHERRY") ||
+ upper.includes("PORT") ||
+ upper.includes("MADEIRA") ||
+ upper.includes("MARSALA") ||
+ upper.includes("MUSCAT") ||
+ upper.includes("TOKAY")
+ ) {
+ return "dessert";
+ }
+ if (upper.includes("FORTIFIED")) {
+ return "fortified";
+ }
+
+ // For TABLE WINE and generic WINE, we cannot distinguish red/white from
+ // classType alone. Default to "red" as it is the most common.
+ // Grapes or other data could refine this later.
+ if (
+ upper.includes("WHITE") ||
+ upper.includes("SAUVIGNON BLANC") ||
+ upper.includes("CHARDONNAY") ||
+ upper.includes("RIESLING") ||
+ upper.includes("PINOT GRIS") ||
+ upper.includes("PINOT GRIGIO")
+ ) {
+ return "white";
+ }
+ if (upper.includes("ROSE") || upper.includes("ROSÉ") || upper.includes("BLUSH")) {
+ return "rosé";
+ }
+
+ return "red";
+}
+
+/**
+ * Determine country from the origin field.
+ * Domestic TTB labels are USA. Imports specify the country of origin.
+ */
+function parseCountry(origin: string | undefined | null): string {
+ if (!origin) return "USA";
+ const upper = origin.toUpperCase().trim();
+ if (!upper || upper === "DOMESTIC" || upper === "US" || upper === "USA") {
+ return "USA";
+ }
+ // The origin field for imports typically contains the country name
+ return normalizeProducerName(origin);
+}
+
+/**
+ * Parse grape varieties from the API grapePct array or CSV GRAPE_VARIETY column.
+ */
+function parseGrapes(
+ grapePct: Array<{ grape: string; percentage: number }> | undefined | null,
+ grapeVarietyStr?: string | null
+): string[] {
+ const grapes: string[] = [];
+
+ if (grapePct && Array.isArray(grapePct)) {
+ for (const entry of grapePct) {
+ if (entry.grape) {
+ const normalized = normalizeGrapeName(entry.grape);
+ if (normalized && !grapes.includes(normalized)) {
+ grapes.push(normalized);
+ }
+ }
+ }
+ }
+
+ if (grapes.length === 0 && grapeVarietyStr) {
+ // CSV format: may be comma-separated or semicolon-separated
+ const parts = grapeVarietyStr.split(/[;,]/).map((s) => s.trim());
+ for (const part of parts) {
+ if (part) {
+ const normalized = normalizeGrapeName(part);
+ if (normalized && !grapes.includes(normalized)) {
+ grapes.push(normalized);
+ }
+ }
+ }
+ }
+
+ return grapes;
+}
+
+// ---------------------------------------------------------------------------
+// API fetch with retry + pagination
+// ---------------------------------------------------------------------------
+
+async function fetchApiPage(page: number): Promise {
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ const response = await fetch(TTB_API_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": "WinebobImporter/1.0 (https://winebob.app; data-import)",
+ },
+ body: JSON.stringify({
+ productType: "WINE",
+ pageSize: API_PAGE_SIZE,
+ page,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return (await response.json()) as TtbApiResponse;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(` [API page ${page}] Attempt ${attempt}/${MAX_RETRIES} failed: ${message}`);
+
+ if (attempt < MAX_RETRIES) {
+ const delay = RETRY_DELAY_MS * attempt;
+ console.log(` [API page ${page}] Retrying in ${delay}ms...`);
+ await sleep(delay);
+ } else {
+ throw new Error(
+ `[API page ${page}] All ${MAX_RETRIES} attempts failed. Last error: ${message}`
+ );
+ }
+ }
+ }
+
+ throw new Error("Unreachable");
+}
+
+/**
+ * Fetch all wine records from the TTB COLA search API.
+ * Paginates through all results with rate limiting.
+ */
+async function fetchFromApi(): Promise {
+ console.log(" Fetching from TTB COLA API...");
+
+ const allRecords: TtbApiRecord[] = [];
+ let page = 1;
+ let totalResults = Infinity;
+
+ while (allRecords.length < totalResults) {
+ console.log(` Fetching page ${page}...`);
+ const response = await fetchApiPage(page);
+
+ if (page === 1 && response.totalResults != null) {
+ totalResults = response.totalResults;
+ console.log(` Total results available: ${totalResults}`);
+ }
+
+ const results = response.results ?? [];
+ if (results.length === 0) {
+ console.log(" No more results, stopping pagination.");
+ break;
+ }
+
+ allRecords.push(...results);
+ console.log(` Fetched ${allRecords.length}/${totalResults} records.`);
+
+ page++;
+ await sleep(RATE_LIMIT_MS);
+ }
+
+ console.log(` API fetch complete: ${allRecords.length} records.`);
+ return allRecords;
+}
+
+// ---------------------------------------------------------------------------
+// CSV fallback
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse a CSV line handling quoted fields.
+ */
+function parseCsvLine(line: string): string[] {
+ const fields: string[] = [];
+ let current = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+ if (char === '"') {
+ if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
+ current += '"';
+ i++;
+ } else {
+ inQuotes = !inQuotes;
+ }
+ } else if (char === "," && !inQuotes) {
+ fields.push(current.trim());
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+ fields.push(current.trim());
+
+ return fields;
+}
+
+/**
+ * Read TTB COLA data from a local CSV file (bulk download format).
+ * Expected columns: PERMIT_NO, SERIAL_NO, BRAND_NAME, FANCIFUL_NAME,
+ * CLASS_TYPE, APPELLATION, GRAPE_VARIETY, ALCOHOL_CONTENT, VINTAGE_DATE,
+ * APPROVAL_DATE, ORIGIN
+ */
+async function fetchFromCsv(filePath: string): Promise {
+ console.log(` Reading CSV from: ${filePath}`);
+
+ const records: TtbApiRecord[] = [];
+
+ const rl = createInterface({
+ input: createReadStream(filePath, { encoding: "utf-8" }),
+ crlfDelay: Infinity,
+ });
+
+ let headers: string[] = [];
+ let lineNum = 0;
+
+ for await (const line of rl) {
+ lineNum++;
+
+ if (lineNum === 1) {
+ headers = parseCsvLine(line).map((h) => h.toUpperCase().replace(/\s+/g, "_"));
+ continue;
+ }
+
+ if (!line.trim()) continue;
+
+ const fields = parseCsvLine(line);
+ const row: Record = {};
+ for (let i = 0; i < headers.length && i < fields.length; i++) {
+ row[headers[i]] = fields[i];
+ }
+
+ records.push({
+ permitId: row["PERMIT_NO"] || undefined,
+ serialNumber: row["SERIAL_NO"] || undefined,
+ brandName: row["BRAND_NAME"] || undefined,
+ fancifulName: row["FANCIFUL_NAME"] || undefined,
+ classType: row["CLASS_TYPE"] || undefined,
+ appellation: row["APPELLATION"] || undefined,
+ grapePct: row["GRAPE_VARIETY"]
+ ? row["GRAPE_VARIETY"]
+ .split(/[;,]/)
+ .filter(Boolean)
+ .map((g) => ({ grape: g.trim(), percentage: 0 }))
+ : undefined,
+ alcoholContent: row["ALCOHOL_CONTENT"] || undefined,
+ vintageDate: row["VINTAGE_DATE"] || undefined,
+ approvalDate: row["APPROVAL_DATE"] || undefined,
+ origin: row["ORIGIN"] || undefined,
+ });
+ }
+
+ console.log(` CSV parsing complete: ${records.length} records from ${lineNum - 1} data lines.`);
+ return records;
+}
+
+// ---------------------------------------------------------------------------
+// Record mapping
+// ---------------------------------------------------------------------------
+
+/**
+ * Map raw TTB COLA records to our internal ParsedTtbWine format.
+ */
+function mapRecords(records: TtbApiRecord[]): ParsedTtbWine[] {
+ const wines: ParsedTtbWine[] = [];
+
+ for (const record of records) {
+ const serialNumber = record.serialNumber;
+ if (!serialNumber) continue;
+
+ const rawName = record.fancifulName || record.brandName;
+ if (!rawName) continue;
+
+ const name = normalizeWineName(rawName);
+ const producer = record.brandName ? normalizeProducerName(record.brandName) : "";
+ const vintage = parseVintageYear(record.vintageDate);
+ const grapes = parseGrapes(record.grapePct, undefined);
+ const region = record.appellation?.trim() || "";
+ const country = parseCountry(record.origin);
+ const appellation = record.appellation?.trim() || "";
+ const type = inferWineType(record.classType);
+ const abv = parseAbv(record.alcoholContent);
+ const fingerprint = generateWineFingerprint(name, producer, vintage);
+
+ wines.push({
+ serialNumber,
+ name,
+ producer,
+ vintage,
+ grapes,
+ region,
+ country,
+ appellation,
+ type,
+ abv,
+ fingerprint,
+ });
+ }
+
+ return wines;
+}
+
+// ---------------------------------------------------------------------------
+// Database operations
+// ---------------------------------------------------------------------------
+
+async function importWines(
+ prisma: PrismaClient,
+ wines: ParsedTtbWine[],
+ importBatchId: string
+): Promise {
+ const stats: ImportStats = {
+ recordsFetched: wines.length,
+ recordsCreated: 0,
+ recordsUpdated: 0,
+ recordsSkipped: 0,
+ recordsFailed: 0,
+ };
+
+ // Load existing wines for dedup — only wines whose name appears in the
+ // current import batch (avoids loading the entire wines table into memory).
+ console.log(" Loading existing wines for deduplication...");
+ const batchNames = [...new Set(wines.map((w) => w.name))];
+ const existingSet = new Set();
+
+ const NAME_BATCH_SIZE = 500;
+ for (let i = 0; i < batchNames.length; i += NAME_BATCH_SIZE) {
+ const nameBatch = batchNames.slice(i, i + NAME_BATCH_SIZE);
+ const existingWines = await prisma.wine.findMany({
+ where: { name: { in: nameBatch } },
+ select: { name: true, producer: true },
+ });
+ for (const w of existingWines) {
+ existingSet.add(`${w.name.toLowerCase()}|${w.producer.toLowerCase()}`);
+ }
+ }
+ console.log(` Found ${existingSet.size} existing wines matching this batch.`);
+
+ // Filter out duplicates
+ const toInsert: ParsedTtbWine[] = [];
+ for (const wine of wines) {
+ const key = `${wine.name.toLowerCase()}|${wine.producer.toLowerCase()}`;
+ if (existingSet.has(key)) {
+ stats.recordsSkipped++;
+ } else {
+ toInsert.push(wine);
+ existingSet.add(key);
+ }
+ }
+
+ console.log(
+ ` ${toInsert.length} new wines to insert, ${stats.recordsSkipped} skipped (duplicates).`
+ );
+
+ // Insert wines one-by-one (Neon HTTP adapter doesn't support transactions,
+ // so createMany fails. Individual creates are slower but reliable.)
+ for (let i = 0; i < toInsert.length; i++) {
+ const wine = toInsert[i];
+ try {
+ await prisma.wine.create({
+ data: {
+ name: wine.name,
+ producer: wine.producer,
+ vintage: wine.vintage,
+ grapes: wine.grapes,
+ region: wine.region,
+ country: wine.country,
+ appellation: wine.appellation || null,
+ type: wine.type,
+ abv: wine.abv,
+ source: "ttb",
+ confidence: 0.8,
+ externalIds: { ttb_cola: wine.serialNumber },
+ importBatchId,
+ isPublic: true,
+ },
+ });
+ stats.recordsCreated++;
+ } catch {
+ stats.recordsFailed++;
+ }
+
+ if ((i + 1) % 50 === 0 || i === toInsert.length - 1) {
+ console.log(` Progress: ${i + 1}/${toInsert.length} (${stats.recordsCreated} created, ${stats.recordsFailed} failed)`);
+ }
+ }
+
+ return stats;
+}
+
+// ---------------------------------------------------------------------------
+// Main import pipeline
+// ---------------------------------------------------------------------------
+
+export interface TtbImportOptions {
+ csvPath?: string;
+}
+
+export interface TtbImportResult {
+ importBatchId: string;
+ stats: ImportStats;
+}
+
+export async function runTtbImport(
+ options?: TtbImportOptions
+): Promise {
+ const prisma = createPrismaClient();
+
+ console.log("=== TTB COLA Wine Import Pipeline ===");
+ console.log(`Started at: ${new Date().toISOString()}`);
+ console.log(`Mode: ${options?.csvPath ? "CSV file" : "API"}\n`);
+
+ // 1. Create ImportBatch
+ console.log("Step 1: Creating import batch...");
+ const batch = await prisma.importBatch.create({
+ data: {
+ source: "ttb",
+ status: "running",
+ createdBy: "system",
+ metadata: {
+ mode: options?.csvPath ? "csv" : "api",
+ csvPath: options?.csvPath || null,
+ apiUrl: TTB_API_URL,
+ },
+ },
+ });
+ console.log(` Import batch created: ${batch.id}\n`);
+
+ let stats: ImportStats = {
+ recordsFetched: 0,
+ recordsCreated: 0,
+ recordsUpdated: 0,
+ recordsSkipped: 0,
+ recordsFailed: 0,
+ };
+
+ try {
+ // 2. Fetch data (API or CSV)
+ console.log("Step 2: Fetching TTB COLA data...");
+ let rawRecords: TtbApiRecord[];
+
+ if (options?.csvPath) {
+ rawRecords = await fetchFromCsv(options.csvPath);
+ } else {
+ try {
+ rawRecords = await fetchFromApi();
+ } catch (apiErr) {
+ const apiMessage = apiErr instanceof Error ? apiErr.message : String(apiErr);
+ console.warn(` API fetch failed: ${apiMessage}`);
+ console.warn(" No CSV fallback path provided. Cannot continue.");
+ throw apiErr;
+ }
+ }
+
+ // 3. Map to wine schema
+ console.log("\nStep 3: Mapping records to wine schema...");
+ const wines = mapRecords(rawRecords);
+ console.log(` Mapped ${wines.length} valid wine records from ${rawRecords.length} raw records.`);
+
+ // 4. Dedup + batch insert
+ console.log("\nStep 4: Importing wines (dedup + batch insert)...");
+ stats = await importWines(prisma, wines, batch.id);
+
+ // 5. Update ImportBatch with final counts
+ console.log("\nStep 5: Finalizing import batch...");
+ await prisma.importBatch.update({
+ where: { id: batch.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ recordsFetched: stats.recordsFetched,
+ recordsCreated: stats.recordsCreated,
+ recordsUpdated: stats.recordsUpdated,
+ recordsSkipped: stats.recordsSkipped,
+ recordsFailed: stats.recordsFailed,
+ metadata: {
+ mode: options?.csvPath ? "csv" : "api",
+ csvPath: options?.csvPath || null,
+ apiUrl: TTB_API_URL,
+ },
+ },
+ });
+
+ console.log("\n=== Import Complete ===");
+ console.log(` Records fetched: ${stats.recordsFetched}`);
+ console.log(` Records created: ${stats.recordsCreated}`);
+ console.log(` Records skipped: ${stats.recordsSkipped}`);
+ console.log(` Records failed: ${stats.recordsFailed}`);
+ console.log(` Batch ID: ${batch.id}`);
+ console.log(` Completed at: ${new Date().toISOString()}`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(`\n!!! Import failed: ${message}`);
+
+ await prisma.importBatch.update({
+ where: { id: batch.id },
+ data: {
+ status: "failed",
+ completedAt: new Date(),
+ recordsFetched: stats.recordsFetched,
+ recordsCreated: stats.recordsCreated,
+ recordsUpdated: stats.recordsUpdated,
+ recordsSkipped: stats.recordsSkipped,
+ recordsFailed: stats.recordsFailed,
+ errorLog: message,
+ },
+ });
+
+ throw err;
+ }
+
+ return {
+ importBatchId: batch.id,
+ stats,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// CLI entry point
+// ---------------------------------------------------------------------------
+
+const isMainModule =
+ typeof process !== "undefined" &&
+ process.argv[1] &&
+ (process.argv[1].endsWith("ttb.ts") || process.argv[1].endsWith("ttb.js"));
+
+if (isMainModule) {
+ // Parse CLI args: --csv
+ const csvIdx = process.argv.indexOf("--csv");
+ const csvPath = csvIdx !== -1 ? process.argv[csvIdx + 1] : undefined;
+
+ runTtbImport({ csvPath })
+ .then((result) => {
+ console.log("\nDone. Result:", JSON.stringify(result, null, 2));
+ process.exit(0);
+ })
+ .catch((err) => {
+ console.error("Fatal error:", err);
+ process.exit(1);
+ });
+}
diff --git a/winebob/src/lib/importers/wikidata.ts b/winebob/src/lib/importers/wikidata.ts
new file mode 100644
index 0000000..16b7f4d
--- /dev/null
+++ b/winebob/src/lib/importers/wikidata.ts
@@ -0,0 +1,636 @@
+/**
+ * Wikidata Wine Import Pipeline (Phase 3)
+ *
+ * Imports wines, grape varieties, and wine region data from Wikidata's
+ * SPARQL endpoint into the Winebob database.
+ *
+ * Usage:
+ * npx tsx src/lib/importers/wikidata.ts
+ *
+ * Can also be imported as a module:
+ * import { runWikidataImport } from "@/lib/importers/wikidata";
+ */
+
+import "dotenv/config";
+import { PrismaNeonHttp } from "@prisma/adapter-neon";
+import { PrismaClient } from "../../generated/prisma/client";
+import {
+ normalizeWineName,
+ normalizeProducerName,
+ normalizeGrapeName,
+} from "./normalize";
+
+// ---------------------------------------------------------------------------
+// Prisma client (standalone — cannot use the Next.js singleton from db.ts
+// because this runs as a CLI script with tsx, and the @/ alias resolves
+// differently at compile time vs runtime for non-Next entry points.
+// We mirror the same adapter pattern used in db.ts.)
+// ---------------------------------------------------------------------------
+
+function createPrismaClient(): PrismaClient {
+ const dbUrl = process.env.DATABASE_URL;
+ if (!dbUrl) {
+ throw new Error("DATABASE_URL is not set");
+ }
+ const adapter = new PrismaNeonHttp(dbUrl, {});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return new PrismaClient({ adapter } as any);
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const WIKIDATA_SPARQL_ENDPOINT = "https://query.wikidata.org/sparql";
+const RATE_LIMIT_MS = 1000; // 1 second between SPARQL queries
+const MAX_RETRIES = 3;
+const RETRY_DELAY_MS = 2000;
+const BATCH_SIZE = 100; // Prisma createMany batch size
+
+// ---------------------------------------------------------------------------
+// SPARQL Queries
+// ---------------------------------------------------------------------------
+
+const QUERY_WINES = `
+SELECT ?wine ?wineLabel ?producerLabel ?countryLabel ?regionLabel ?grapeLabel ?inception WHERE {
+ ?wine wdt:P31/wdt:P279* wd:Q282.
+ OPTIONAL { ?wine wdt:P176 ?producer. }
+ OPTIONAL { ?wine wdt:P17 ?country. }
+ OPTIONAL { ?wine wdt:P276 ?region. }
+ OPTIONAL { ?wine wdt:P186 ?grape. }
+ OPTIONAL { ?wine wdt:P571 ?inception. }
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 10000
+`.trim();
+
+const QUERY_WINE_REGIONS = `
+SELECT ?region ?regionLabel ?countryLabel ?coord WHERE {
+ ?region wdt:P31/wdt:P279* wd:Q1187580.
+ OPTIONAL { ?region wdt:P17 ?country. }
+ OPTIONAL { ?region wdt:P625 ?coord. }
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 5000
+`.trim();
+
+const QUERY_GRAPE_VARIETIES = `
+SELECT ?grape ?grapeLabel ?colorLabel ?countryLabel WHERE {
+ ?grape wdt:P31/wdt:P279* wd:Q10978.
+ OPTIONAL { ?grape wdt:P462 ?color. }
+ OPTIONAL { ?grape wdt:P495 ?country. }
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 5000
+`.trim();
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface SparqlBinding {
+ [key: string]: { type: string; value: string } | undefined;
+}
+
+interface SparqlResponse {
+ results: {
+ bindings: SparqlBinding[];
+ };
+}
+
+interface ParsedWine {
+ wikidataId: string;
+ name: string;
+ producer: string;
+ country: string;
+ region: string;
+ grapes: string[];
+ type: string;
+ vintage: number | null;
+}
+
+interface ImportStats {
+ recordsFetched: number;
+ recordsCreated: number;
+ recordsUpdated: number;
+ recordsSkipped: number;
+ recordsFailed: number;
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Extract Wikidata Q-ID from an entity URI.
+ * e.g. "http://www.wikidata.org/entity/Q123" -> "Q123"
+ */
+function extractQId(uri: string): string {
+ const match = uri.match(/Q\d+$/);
+ return match ? match[0] : "";
+}
+
+/**
+ * Check if a label is just a Q-ID (meaning Wikidata had no English label).
+ */
+function isQId(label: string): boolean {
+ return /^Q\d+$/.test(label.trim());
+}
+
+/**
+ * Get the string value from a SPARQL binding, or empty string.
+ */
+function val(binding: SparqlBinding, key: string): string {
+ return binding[key]?.value ?? "";
+}
+
+/**
+ * Try to infer wine type from grape color label.
+ */
+function inferTypeFromColor(colorLabel: string): string {
+ const lower = colorLabel.toLowerCase();
+ if (lower.includes("red") || lower.includes("noir") || lower.includes("black")) return "red";
+ if (lower.includes("white") || lower.includes("blanc") || lower.includes("green") || lower.includes("yellow")) return "white";
+ if (lower.includes("pink") || lower.includes("rosé") || lower.includes("rose") || lower.includes("grey") || lower.includes("gris")) return "rosé";
+ return "";
+}
+
+// ---------------------------------------------------------------------------
+// SPARQL Fetch with retry
+// ---------------------------------------------------------------------------
+
+async function querySparql(query: string, label: string): Promise {
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ console.log(` [${label}] Attempt ${attempt}/${MAX_RETRIES}...`);
+
+ const url = new URL(WIKIDATA_SPARQL_ENDPOINT);
+ url.searchParams.set("query", query);
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ Accept: "application/json",
+ "User-Agent": "WinebobImporter/1.0 (https://winebob.app; data-import)",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = (await response.json()) as SparqlResponse;
+ console.log(` [${label}] Got ${data.results.bindings.length} results.`);
+ return data;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(` [${label}] Attempt ${attempt} failed: ${message}`);
+
+ if (attempt < MAX_RETRIES) {
+ const delay = RETRY_DELAY_MS * attempt;
+ console.log(` [${label}] Retrying in ${delay}ms...`);
+ await sleep(delay);
+ } else {
+ throw new Error(`[${label}] All ${MAX_RETRIES} attempts failed. Last error: ${message}`);
+ }
+ }
+ }
+
+ // Unreachable, but TypeScript needs it
+ throw new Error("Unreachable");
+}
+
+// ---------------------------------------------------------------------------
+// Processing
+// ---------------------------------------------------------------------------
+
+/**
+ * Group raw SPARQL wine bindings by Wikidata Q-ID,
+ * collecting multiple grape varieties per wine.
+ */
+function processWineBindings(bindings: SparqlBinding[]): ParsedWine[] {
+ const wineMap = new Map();
+
+ for (const binding of bindings) {
+ const uri = val(binding, "wine");
+ const qId = extractQId(uri);
+ if (!qId) continue;
+
+ const label = val(binding, "wineLabel");
+ // Skip entries with no label or where label is just a Q-ID
+ if (!label || isQId(label)) continue;
+
+ const grape = val(binding, "grapeLabel");
+ const inception = val(binding, "inception");
+
+ let vintage: number | null = null;
+ if (inception) {
+ const yearMatch = inception.match(/^(\d{4})/);
+ if (yearMatch) {
+ vintage = parseInt(yearMatch[1], 10);
+ }
+ }
+
+ const existing = wineMap.get(qId);
+ if (existing) {
+ // Add grape if not already present and not a Q-ID
+ if (grape && !isQId(grape) && !existing.grapes.includes(normalizeGrapeName(grape))) {
+ existing.grapes.push(normalizeGrapeName(grape));
+ }
+ } else {
+ const grapes: string[] = [];
+ if (grape && !isQId(grape)) {
+ grapes.push(normalizeGrapeName(grape));
+ }
+
+ const producerRaw = val(binding, "producerLabel");
+ const countryRaw = val(binding, "countryLabel");
+ const regionRaw = val(binding, "regionLabel");
+
+ wineMap.set(qId, {
+ wikidataId: qId,
+ name: normalizeWineName(label),
+ producer: producerRaw && !isQId(producerRaw) ? normalizeProducerName(producerRaw) : "",
+ country: countryRaw && !isQId(countryRaw) ? countryRaw : "",
+ region: regionRaw && !isQId(regionRaw) ? regionRaw : "",
+ grapes,
+ type: "red", // default, will be refined if grape color info available
+ vintage,
+ });
+ }
+ }
+
+ // Filter out junk entries: non-wine items and entries with no useful data
+ const JUNK_KEYWORDS = ["vinegar", "juice", "grape must", "brandy", "grappa", "spirits", "liqueur", "beer", "cider", "sake"];
+
+ const filtered = Array.from(wineMap.values()).filter((wine) => {
+ const nameLower = wine.name.toLowerCase();
+
+ // Remove non-wine products
+ if (JUNK_KEYWORDS.some((kw) => nameLower.includes(kw))) return false;
+
+ // Require at least a country — wines with no country AND no producer are too low-quality
+ if (!wine.country && !wine.producer) return false;
+
+ return true;
+ });
+
+ return filtered;
+}
+
+/**
+ * Build a map of grape -> color from the grape varieties query,
+ * used to infer wine type.
+ */
+function buildGrapeColorMap(bindings: SparqlBinding[]): Map {
+ const map = new Map();
+
+ for (const binding of bindings) {
+ const label = val(binding, "grapeLabel");
+ const color = val(binding, "colorLabel");
+ if (label && !isQId(label) && color && !isQId(color)) {
+ const normalizedGrape = normalizeGrapeName(label);
+ const inferredType = inferTypeFromColor(color);
+ if (inferredType) {
+ map.set(normalizedGrape.toLowerCase(), inferredType);
+ }
+ }
+ }
+
+ return map;
+}
+
+/**
+ * Refine wine types using grape color data.
+ */
+function refineWineTypes(wines: ParsedWine[], grapeColorMap: Map): void {
+ for (const wine of wines) {
+ if (wine.grapes.length > 0) {
+ // Use the first grape's color as the wine type
+ const firstGrape = wine.grapes[0].toLowerCase();
+ const inferred = grapeColorMap.get(firstGrape);
+ if (inferred) {
+ wine.type = inferred;
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Database operations
+// ---------------------------------------------------------------------------
+
+async function importWines(
+ prisma: PrismaClient,
+ wines: ParsedWine[],
+ importBatchId: string
+): Promise {
+ const stats: ImportStats = {
+ recordsFetched: wines.length,
+ recordsCreated: 0,
+ recordsUpdated: 0,
+ recordsSkipped: 0,
+ recordsFailed: 0,
+ };
+
+ // Load existing wines for dedup — only wines whose name appears in the
+ // current import batch (avoids loading the entire wines table into memory).
+ console.log(" Loading existing wines for deduplication...");
+ const batchNames = [...new Set(wines.map((w) => w.name))];
+ const existingSet = new Set();
+
+ // Query in batches of 500 names to avoid overly large IN clauses
+ const NAME_BATCH_SIZE = 500;
+ for (let i = 0; i < batchNames.length; i += NAME_BATCH_SIZE) {
+ const nameBatch = batchNames.slice(i, i + NAME_BATCH_SIZE);
+ const existingWines = await prisma.wine.findMany({
+ where: { name: { in: nameBatch } },
+ select: { name: true, producer: true },
+ });
+ for (const w of existingWines) {
+ existingSet.add(`${w.name.toLowerCase()}|${w.producer.toLowerCase()}`);
+ }
+ }
+ console.log(` Found ${existingSet.size} existing wines matching this batch.`);
+
+ // Filter out duplicates
+ const toInsert: ParsedWine[] = [];
+ for (const wine of wines) {
+ const key = `${wine.name.toLowerCase()}|${wine.producer.toLowerCase()}`;
+ if (existingSet.has(key)) {
+ stats.recordsSkipped++;
+ } else {
+ toInsert.push(wine);
+ // Add to set so we don't insert duplicates from the same batch
+ existingSet.add(key);
+ }
+ }
+
+ console.log(` ${toInsert.length} new wines to insert, ${stats.recordsSkipped} skipped (duplicates).`);
+
+ // Insert wines one-by-one (Neon HTTP adapter doesn't support transactions,
+ // so createMany fails. Individual creates are slower but reliable.)
+ for (let i = 0; i < toInsert.length; i++) {
+ const wine = toInsert[i];
+ try {
+ await prisma.wine.create({
+ data: {
+ name: wine.name,
+ producer: wine.producer,
+ vintage: wine.vintage,
+ grapes: wine.grapes,
+ region: wine.region,
+ country: wine.country,
+ type: wine.type,
+ source: "wikidata",
+ confidence: 0.7,
+ externalIds: { wikidata: wine.wikidataId },
+ importBatchId,
+ isPublic: true,
+ },
+ });
+ stats.recordsCreated++;
+ } catch {
+ stats.recordsFailed++;
+ }
+
+ // Progress log every 50 wines
+ if ((i + 1) % 50 === 0 || i === toInsert.length - 1) {
+ console.log(` Progress: ${i + 1}/${toInsert.length} (${stats.recordsCreated} created, ${stats.recordsFailed} failed)`);
+ }
+ }
+
+ return stats;
+}
+
+async function importGrapeVarieties(
+ prisma: PrismaClient,
+ bindings: SparqlBinding[]
+): Promise<{ created: number; skipped: number }> {
+ const result = { created: 0, skipped: 0 };
+
+ // Deduplicate grapes by normalized name
+ const grapeMap = new Map<
+ string,
+ { name: string; color: string; country: string; wikidataId: string }
+ >();
+
+ for (const binding of bindings) {
+ const label = val(binding, "grapeLabel");
+ if (!label || isQId(label)) continue;
+
+ const name = normalizeGrapeName(label);
+ const key = name.toLowerCase();
+
+ if (grapeMap.has(key)) continue;
+
+ const colorLabel = val(binding, "colorLabel");
+ const country = val(binding, "countryLabel");
+ const uri = val(binding, "grape");
+ const qId = extractQId(uri);
+
+ let color = "red"; // default
+ if (colorLabel && !isQId(colorLabel)) {
+ const inferred = inferTypeFromColor(colorLabel);
+ if (inferred) color = inferred;
+ }
+
+ grapeMap.set(key, {
+ name,
+ color,
+ country: country && !isQId(country) ? country : "",
+ wikidataId: qId,
+ });
+ }
+
+ console.log(` Processing ${grapeMap.size} unique grape varieties...`);
+
+ // Upsert each grape variety
+ for (const grape of Array.from(grapeMap.values())) {
+ try {
+ await prisma.grapeVariety.upsert({
+ where: { name: grape.name },
+ create: {
+ name: grape.name,
+ color: grape.color,
+ originCountry: grape.country || null,
+ aliases: [],
+ },
+ update: {
+ // Update color/country if we have better data
+ color: grape.color,
+ originCountry: grape.country || undefined,
+ },
+ });
+ result.created++;
+ } catch {
+ result.skipped++;
+ }
+ }
+
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Main import pipeline
+// ---------------------------------------------------------------------------
+
+export interface WikidataImportResult {
+ importBatchId: string;
+ stats: ImportStats;
+ grapesImported: number;
+}
+
+export async function runWikidataImport(): Promise {
+ const prisma = createPrismaClient();
+
+ console.log("=== Wikidata Wine Import Pipeline ===");
+ console.log(`Started at: ${new Date().toISOString()}\n`);
+
+ // 1. Create ImportBatch
+ console.log("Step 1: Creating import batch...");
+ const batch = await prisma.importBatch.create({
+ data: {
+ source: "wikidata",
+ status: "running",
+ createdBy: "system",
+ metadata: {
+ sparqlEndpoint: WIKIDATA_SPARQL_ENDPOINT,
+ queries: ["wines", "wine_regions", "grape_varieties"],
+ },
+ },
+ });
+ console.log(` Import batch created: ${batch.id}\n`);
+
+ let stats: ImportStats = {
+ recordsFetched: 0,
+ recordsCreated: 0,
+ recordsUpdated: 0,
+ recordsSkipped: 0,
+ recordsFailed: 0,
+ };
+ let grapesImported = 0;
+
+ try {
+ // 2. Query Wikidata SPARQL endpoint
+ console.log("Step 2: Querying Wikidata SPARQL endpoint...");
+
+ console.log("\n Query 1: Wines...");
+ const wineResults = await querySparql(QUERY_WINES, "Wines");
+
+ await sleep(RATE_LIMIT_MS);
+
+ console.log("\n Query 2: Wine regions...");
+ const _regionResults = await querySparql(QUERY_WINE_REGIONS, "Wine Regions");
+
+ await sleep(RATE_LIMIT_MS);
+
+ console.log("\n Query 3: Grape varieties...");
+ const grapeResults = await querySparql(QUERY_GRAPE_VARIETIES, "Grape Varieties");
+
+ // 3. Process results
+ console.log("\nStep 3: Processing results...");
+
+ const grapeColorMap = buildGrapeColorMap(grapeResults.results.bindings);
+ console.log(` Built grape color map with ${grapeColorMap.size} entries.`);
+
+ const wines = processWineBindings(wineResults.results.bindings);
+ console.log(` Processed ${wines.length} unique wines from ${wineResults.results.bindings.length} SPARQL rows.`);
+
+ refineWineTypes(wines, grapeColorMap);
+ console.log(" Refined wine types using grape color data.");
+
+ // 4 & 5. Dedup + insert wines
+ console.log("\nStep 4: Importing wines...");
+ stats = await importWines(prisma, wines, batch.id);
+
+ // 7. Import grape varieties
+ console.log("\nStep 5: Importing grape varieties...");
+ const grapeStats = await importGrapeVarieties(
+ prisma,
+ grapeResults.results.bindings
+ );
+ grapesImported = grapeStats.created;
+ console.log(` Grapes imported: ${grapeStats.created}, skipped: ${grapeStats.skipped}`);
+
+ // 6. Update ImportBatch with final counts
+ console.log("\nStep 6: Finalizing import batch...");
+ await prisma.importBatch.update({
+ where: { id: batch.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ recordsFetched: stats.recordsFetched,
+ recordsCreated: stats.recordsCreated,
+ recordsUpdated: stats.recordsUpdated,
+ recordsSkipped: stats.recordsSkipped,
+ recordsFailed: stats.recordsFailed,
+ metadata: {
+ sparqlEndpoint: WIKIDATA_SPARQL_ENDPOINT,
+ queries: ["wines", "wine_regions", "grape_varieties"],
+ grapesImported,
+ wineBindingsTotal: wineResults.results.bindings.length,
+ },
+ },
+ });
+
+ console.log("\n=== Import Complete ===");
+ console.log(` Wines fetched: ${stats.recordsFetched}`);
+ console.log(` Wines created: ${stats.recordsCreated}`);
+ console.log(` Wines skipped: ${stats.recordsSkipped}`);
+ console.log(` Wines failed: ${stats.recordsFailed}`);
+ console.log(` Grapes imported: ${grapesImported}`);
+ console.log(` Batch ID: ${batch.id}`);
+ console.log(` Completed at: ${new Date().toISOString()}`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(`\n!!! Import failed: ${message}`);
+
+ await prisma.importBatch.update({
+ where: { id: batch.id },
+ data: {
+ status: "failed",
+ completedAt: new Date(),
+ recordsFetched: stats.recordsFetched,
+ recordsCreated: stats.recordsCreated,
+ recordsUpdated: stats.recordsUpdated,
+ recordsSkipped: stats.recordsSkipped,
+ recordsFailed: stats.recordsFailed,
+ errorLog: message,
+ },
+ });
+
+ throw err;
+ }
+
+ return {
+ importBatchId: batch.id,
+ stats,
+ grapesImported,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// CLI entry point
+// ---------------------------------------------------------------------------
+
+const isMainModule =
+ typeof process !== "undefined" &&
+ process.argv[1] &&
+ (process.argv[1].endsWith("wikidata.ts") ||
+ process.argv[1].endsWith("wikidata.js"));
+
+if (isMainModule) {
+ runWikidataImport()
+ .then((result) => {
+ console.log("\nDone. Result:", JSON.stringify(result, null, 2));
+ process.exit(0);
+ })
+ .catch((err) => {
+ console.error("Fatal error:", err);
+ process.exit(1);
+ });
+}
diff --git a/winebob/src/lib/liveActions.ts b/winebob/src/lib/liveActions.ts
new file mode 100644
index 0000000..9504db9
--- /dev/null
+++ b/winebob/src/lib/liveActions.ts
@@ -0,0 +1,489 @@
+"use server";
+
+import { prisma } from "@/lib/db";
+import { requireAuth } from "@/lib/auth";
+import { revalidatePath } from "next/cache";
+
+// ============ SOMMELIER PROFILE ============
+
+export async function getSommelierProfile(userId?: string) {
+ if (!userId) {
+ const session = await requireAuth();
+ userId = session.user.id;
+ }
+ return prisma.sommelierProfile.findUnique({ where: { userId } });
+}
+
+export async function getMyProfile() {
+ const session = await requireAuth();
+ return prisma.sommelierProfile.findUnique({ where: { userId: session.user.id } });
+}
+
+export async function createSommelierProfile(data: {
+ displayName: string;
+ bio?: string;
+ expertise: string[];
+ certifications: string[];
+}) {
+ const session = await requireAuth();
+
+ const existing = await prisma.sommelierProfile.findUnique({
+ where: { userId: session.user.id },
+ });
+ if (existing) throw new Error("Profile already exists");
+
+ return prisma.sommelierProfile.create({
+ data: {
+ userId: session.user.id,
+ displayName: data.displayName,
+ bio: data.bio,
+ expertise: data.expertise,
+ certifications: data.certifications,
+ },
+ });
+}
+
+export async function updateSommelierProfile(data: {
+ displayName?: string;
+ bio?: string;
+ avatar?: string;
+ expertise?: string[];
+ certifications?: string[];
+}) {
+ const session = await requireAuth();
+ return prisma.sommelierProfile.update({
+ where: { userId: session.user.id },
+ data,
+ });
+}
+
+export async function getAllSommeliers() {
+ return prisma.sommelierProfile.findMany({
+ orderBy: [{ verified: "desc" }, { rating: "desc" }, { totalEvents: "desc" }],
+ });
+}
+
+// ============ LIVE EVENT MANAGEMENT ============
+
+export async function createLiveEvent(data: {
+ title: string;
+ description?: string;
+ coverImage?: string;
+ isPublic: boolean;
+ scheduledAt: string; // ISO date string
+ maxParticipants?: number;
+ guessFields: string[];
+ scoringConfig: Record;
+ difficulty: string;
+ showCrowdStats?: boolean;
+ wines: { wineId: string; hints: { content: string; hintType: string }[] }[];
+}) {
+ const session = await requireAuth();
+
+ const profile = await prisma.sommelierProfile.findUnique({
+ where: { userId: session.user.id },
+ });
+ if (!profile) throw new Error("You need a sommelier profile to host live events");
+
+ // Validate wine IDs
+ const wineIds = data.wines.map((w) => w.wineId);
+ if (wineIds.length === 0) throw new Error("At least one wine is required");
+
+ const existingWines = await prisma.wine.findMany({
+ where: { id: { in: wineIds } },
+ select: { id: true },
+ });
+ const existingSet = new Set(existingWines.map((w) => w.id));
+ const invalid = wineIds.filter((id) => !existingSet.has(id));
+ if (invalid.length > 0) throw new Error(`Invalid wine IDs: ${invalid.join(", ")}`);
+
+ // Generate join code for private events
+ let joinCode: string | null = null;
+ if (!data.isPublic) {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ joinCode = Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
+ }
+
+ // Create event
+ const event = await prisma.liveEvent.create({
+ data: {
+ sommelierId: profile.id,
+ title: data.title,
+ description: data.description,
+ coverImage: data.coverImage,
+ isPublic: data.isPublic,
+ joinCode,
+ scheduledAt: new Date(data.scheduledAt),
+ maxParticipants: data.maxParticipants,
+ guessFields: data.guessFields,
+ scoringConfig: data.scoringConfig,
+ difficulty: data.difficulty,
+ showCrowdStats: data.showCrowdStats ?? true,
+ },
+ });
+
+ // Create wines + hints (separately for Neon HTTP adapter)
+ for (let i = 0; i < data.wines.length; i++) {
+ const w = data.wines[i];
+ const liveWine = await prisma.liveWine.create({
+ data: {
+ eventId: event.id,
+ wineId: w.wineId,
+ position: i + 1,
+ },
+ });
+
+ // Create hints for this wine
+ for (let h = 0; h < w.hints.length; h++) {
+ await prisma.liveHint.create({
+ data: {
+ wineId: liveWine.id,
+ position: h + 1,
+ content: w.hints[h].content,
+ hintType: w.hints[h].hintType,
+ },
+ });
+ }
+ }
+
+ // Increment sommelier event count
+ await prisma.sommelierProfile.update({
+ where: { id: profile.id },
+ data: { totalEvents: { increment: 1 } },
+ });
+
+ revalidatePath("/live");
+ return event;
+}
+
+// ============ LIVE EVENT CONTROL (sommelier) ============
+
+export async function startLiveEvent(eventId: string) {
+ const session = await requireAuth();
+ const event = await getLiveEventWithAuth(eventId, session.user.id);
+ if (event.status !== "scheduled") throw new Error("Event is not in scheduled state");
+
+ await prisma.liveEvent.update({
+ where: { id: eventId },
+ data: { status: "live", startedAt: new Date() },
+ });
+ revalidatePath(`/live/${eventId}`);
+}
+
+export async function releaseHint(hintId: string) {
+ const session = await requireAuth();
+
+ const hint = await prisma.liveHint.findUnique({
+ where: { id: hintId },
+ include: { wine: { include: { event: { include: { sommelier: true } } } } },
+ });
+ if (!hint) throw new Error("Hint not found");
+ if (hint.wine.event.sommelier.userId !== session.user.id) throw new Error("Not authorized");
+ if (hint.revealed) return; // idempotent
+
+ await prisma.liveHint.update({
+ where: { id: hintId },
+ data: { revealed: true, revealedAt: new Date() },
+ });
+ revalidatePath(`/live/${hint.wine.eventId}`);
+}
+
+export async function revealLiveWine(eventId: string, position: number) {
+ const session = await requireAuth();
+ const event = await getLiveEventWithAuth(eventId, session.user.id);
+ if (event.status !== "live") throw new Error("Event is not live");
+
+ const liveWine = await prisma.liveWine.findFirst({
+ where: { eventId, position },
+ });
+ if (!liveWine) throw new Error("Wine not found");
+ if (liveWine.revealed) return; // idempotent
+
+ await prisma.liveWine.update({
+ where: { id: liveWine.id },
+ data: { revealed: true, revealedAt: new Date() },
+ });
+
+ // Score all guesses for this wine
+ await scoreLiveWine(eventId, position);
+
+ revalidatePath(`/live/${eventId}`);
+}
+
+export async function completeLiveEvent(eventId: string) {
+ const session = await requireAuth();
+ const event = await getLiveEventWithAuth(eventId, session.user.id);
+ if (event.status !== "live") throw new Error("Event is not live");
+
+ // Final scoring pass
+ const unrevealed = await prisma.liveWine.findMany({
+ where: { eventId, revealed: false },
+ });
+ for (const w of unrevealed) {
+ await prisma.liveWine.update({
+ where: { id: w.id },
+ data: { revealed: true, revealedAt: new Date() },
+ });
+ await scoreLiveWine(eventId, w.position);
+ }
+
+ await prisma.liveEvent.update({
+ where: { id: eventId },
+ data: { status: "completed", completedAt: new Date() },
+ });
+
+ // Update sommelier viewer count
+ const participantCount = await prisma.liveParticipant.count({ where: { eventId } });
+ await prisma.sommelierProfile.update({
+ where: { id: event.sommelierId },
+ data: { totalViewers: { increment: participantCount } },
+ });
+
+ revalidatePath(`/live/${eventId}`);
+}
+
+// ============ PARTICIPANT ACTIONS ============
+
+export async function joinLiveEvent(data: {
+ eventId: string;
+ displayName: string;
+ joinCode?: string; // required for private events
+}) {
+ const event = await prisma.liveEvent.findUnique({ where: { id: data.eventId } });
+ if (!event) throw new Error("Event not found");
+ if (event.status === "completed" || event.status === "cancelled") {
+ throw new Error("Event has ended");
+ }
+
+ // Private event: verify join code
+ if (!event.isPublic) {
+ if (!data.joinCode || data.joinCode.toUpperCase() !== event.joinCode) {
+ throw new Error("Invalid join code");
+ }
+ }
+
+ // Check max participants
+ if (event.maxParticipants) {
+ const count = await prisma.liveParticipant.count({ where: { eventId: data.eventId } });
+ if (count >= event.maxParticipants) throw new Error("Event is full");
+ }
+
+ const participant = await prisma.liveParticipant.create({
+ data: {
+ eventId: data.eventId,
+ displayName: data.displayName,
+ },
+ });
+
+ return { participantId: participant.id, sessionToken: participant.sessionToken };
+}
+
+export async function submitLiveGuess(data: {
+ eventId: string;
+ participantId: string;
+ sessionToken: string;
+ winePosition: number;
+ guessedGrape?: string;
+ guessedRegion?: string;
+ guessedCountry?: string;
+ guessedVintage?: number;
+ guessedProducer?: string;
+ guessedType?: string;
+ guessedPrice?: number;
+ notes?: string;
+}) {
+ // Verify participant identity
+ const participant = await prisma.liveParticipant.findFirst({
+ where: { id: data.participantId, sessionToken: data.sessionToken, eventId: data.eventId },
+ });
+ if (!participant) throw new Error("Invalid participant");
+
+ // Verify event is live
+ const event = await prisma.liveEvent.findUnique({ where: { id: data.eventId } });
+ if (!event || event.status !== "live") throw new Error("Event is not accepting guesses");
+
+ // Verify wine is not revealed (submission lock)
+ const liveWine = await prisma.liveWine.findFirst({
+ where: { eventId: data.eventId, position: data.winePosition },
+ });
+ if (!liveWine) throw new Error("Wine not found");
+ if (liveWine.revealed) throw new Error("Wine already revealed — guesses locked");
+
+ // Upsert guess (can update until revealed)
+ await prisma.liveGuess.upsert({
+ where: {
+ eventId_participantId_winePosition: {
+ eventId: data.eventId,
+ participantId: data.participantId,
+ winePosition: data.winePosition,
+ },
+ },
+ create: {
+ eventId: data.eventId,
+ participantId: data.participantId,
+ winePosition: data.winePosition,
+ guessedGrape: data.guessedGrape,
+ guessedRegion: data.guessedRegion,
+ guessedCountry: data.guessedCountry,
+ guessedVintage: data.guessedVintage,
+ guessedProducer: data.guessedProducer,
+ guessedType: data.guessedType,
+ guessedPrice: data.guessedPrice,
+ notes: data.notes,
+ },
+ update: {
+ guessedGrape: data.guessedGrape,
+ guessedRegion: data.guessedRegion,
+ guessedCountry: data.guessedCountry,
+ guessedVintage: data.guessedVintage,
+ guessedProducer: data.guessedProducer,
+ guessedType: data.guessedType,
+ guessedPrice: data.guessedPrice,
+ notes: data.notes,
+ },
+ });
+
+ revalidatePath(`/live/${data.eventId}`);
+}
+
+// ============ DATA FETCHING ============
+
+export async function getUpcomingLiveEvents() {
+ return prisma.liveEvent.findMany({
+ where: {
+ isPublic: true,
+ status: { in: ["scheduled", "live"] },
+ },
+ orderBy: [{ status: "desc" }, { scheduledAt: "asc" }], // live first, then by date
+ include: {
+ sommelier: { select: { displayName: true, avatar: true, verified: true, expertise: true } },
+ _count: { select: { participants: true, wines: true } },
+ },
+ });
+}
+
+export async function getLiveEventById(id: string) {
+ const event = await prisma.liveEvent.findUnique({
+ where: { id },
+ include: {
+ sommelier: { select: { id: true, displayName: true, avatar: true, verified: true, expertise: true, userId: true } },
+ participants: { select: { id: true, displayName: true } },
+ wines: { orderBy: { position: "asc" } },
+ guesses: true,
+ },
+ });
+ if (!event) return null;
+
+ // Fetch actual wine data + hints separately (Neon adapter)
+ const liveWineIds = event.wines.map((w) => w.id);
+ const wineIds = event.wines.map((w) => w.wineId);
+
+ const [wines, hints] = await Promise.all([
+ wineIds.length > 0 ? prisma.wine.findMany({ where: { id: { in: wineIds } } }) : [],
+ liveWineIds.length > 0 ? prisma.liveHint.findMany({ where: { wineId: { in: liveWineIds } }, orderBy: { position: "asc" } }) : [],
+ ]);
+
+ const wineMap = new Map(wines.map((w) => [w.id, w]));
+ const hintsByWine = new Map();
+ for (const h of hints) {
+ const arr = hintsByWine.get(h.wineId) ?? [];
+ arr.push(h);
+ hintsByWine.set(h.wineId, arr);
+ }
+
+ return {
+ ...event,
+ wines: event.wines.map((lw) => ({
+ ...lw,
+ wine: wineMap.get(lw.wineId) ?? null,
+ hints: hintsByWine.get(lw.id) ?? [],
+ })),
+ };
+}
+
+export async function getCrowdStats(eventId: string, winePosition: number) {
+ const guesses = await prisma.liveGuess.findMany({
+ where: { eventId, winePosition },
+ });
+
+ const stats: Record> = {};
+ const fields = ["guessedGrape", "guessedRegion", "guessedCountry", "guessedType"] as const;
+
+ for (const field of fields) {
+ const counts: Record = {};
+ for (const g of guesses) {
+ const val = g[field];
+ if (val) counts[val] = (counts[val] ?? 0) + 1;
+ }
+ // Sort by count, take top 5
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 5);
+ if (sorted.length > 0) {
+ stats[field.replace("guessed", "").toLowerCase()] = Object.fromEntries(
+ sorted.map(([k, v]) => [k, Math.round((v / guesses.length) * 100)])
+ );
+ }
+ }
+
+ return { stats, totalGuesses: guesses.length };
+}
+
+// ============ HELPERS ============
+
+async function getLiveEventWithAuth(eventId: string, userId: string) {
+ const event = await prisma.liveEvent.findUnique({
+ where: { id: eventId },
+ include: { sommelier: true },
+ });
+ if (!event) throw new Error("Event not found");
+ if (event.sommelier.userId !== userId) throw new Error("Not authorized");
+ return event;
+}
+
+async function scoreLiveWine(eventId: string, position: number) {
+ const event = await prisma.liveEvent.findUnique({ where: { id: eventId } });
+ if (!event) return;
+
+ const liveWine = await prisma.liveWine.findFirst({ where: { eventId, position } });
+ if (!liveWine) return;
+
+ const actual = await prisma.wine.findUnique({ where: { id: liveWine.wineId } });
+ if (!actual) return;
+
+ const guesses = await prisma.liveGuess.findMany({
+ where: { eventId, winePosition: position },
+ });
+
+ const weights = (event.scoringConfig as Record) ?? {
+ grape: 25, region: 20, country: 15, vintage: 15, producer: 15, type: 10,
+ };
+
+ const norm = (s: string) => s.trim().toLowerCase();
+
+ await Promise.all(
+ guesses.map((guess) => {
+ let score = 0;
+
+ if (weights.grape && guess.guessedGrape) {
+ if (actual.grapes.some((g: string) => norm(g) === norm(guess.guessedGrape!))) score += weights.grape;
+ }
+ if (weights.region && guess.guessedRegion) {
+ if (norm(actual.region) === norm(guess.guessedRegion)) score += weights.region;
+ }
+ if (weights.country && guess.guessedCountry) {
+ if (norm(actual.country) === norm(guess.guessedCountry)) score += weights.country;
+ }
+ if (weights.vintage && guess.guessedVintage && actual.vintage) {
+ if (actual.vintage === guess.guessedVintage) score += weights.vintage;
+ else if (Math.abs(actual.vintage - guess.guessedVintage) === 1) score += Math.round(weights.vintage / 2);
+ }
+ if (weights.producer && guess.guessedProducer) {
+ if (norm(actual.producer) === norm(guess.guessedProducer)) score += weights.producer;
+ }
+ if (weights.type && guess.guessedType) {
+ if (norm(actual.type) === norm(guess.guessedType)) score += weights.type;
+ }
+
+ return prisma.liveGuess.update({ where: { id: guess.id }, data: { score } });
+ })
+ );
+}
diff --git a/winebob/src/proxy.ts b/winebob/src/proxy.ts
new file mode 100644
index 0000000..390a558
--- /dev/null
+++ b/winebob/src/proxy.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { getToken } from "next-auth/jwt";
+
+export async function proxy(request: NextRequest) {
+ const token = await getToken({ req: request });
+
+ // If not authenticated, redirect to login
+ if (!token) {
+ const loginUrl = new URL("/login", request.url);
+ loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: [
+ "/arena/:path*",
+ "/profile/:path*",
+ ],
+};
diff --git a/winebob/src/types/next-auth.d.ts b/winebob/src/types/next-auth.d.ts
new file mode 100644
index 0000000..bdadd50
--- /dev/null
+++ b/winebob/src/types/next-auth.d.ts
@@ -0,0 +1,12 @@
+import "next-auth";
+
+declare module "next-auth" {
+ interface Session {
+ user: {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ image?: string | null;
+ };
+ }
+}
diff --git a/winebob/tsconfig.json b/winebob/tsconfig.json
new file mode 100644
index 0000000..cf9c65d
--- /dev/null
+++ b/winebob/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/winebob/vercel.json b/winebob/vercel.json
new file mode 100644
index 0000000..a28fb8d
--- /dev/null
+++ b/winebob/vercel.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": "nextjs",
+ "regions": ["arn1"],
+ "headers": [
+ {
+ "source": "/manifest.json",
+ "headers": [
+ { "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
+ ]
+ },
+ {
+ "source": "/(.*)",
+ "headers": [
+ { "key": "X-Content-Type-Options", "value": "nosniff" },
+ { "key": "X-Frame-Options", "value": "DENY" },
+ { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
+ ]
+ }
+ ]
+}