1 Commits

Author SHA1 Message Date
43a466f7e8 feat: map tab with Leaflet + seed-data.js refactor + Yelp proxy
- Add Leaflet map tab in public/index.html with CARTO dark tiles, category
  toggles, vote-count markers, and external venue search
- Extract seed data to seed-data.js with CATEGORY_META, buildSeedData(),
  mergeSeedData() helpers
- Refactor server.js: approvedOptionsWithVoteSummary(), buildRealtimeSnapshot(),
  createUserOption() helpers; Yelp API proxy at /api/yelp; /api/budgets endpoint
- Extract inline seed data from server.js to seed-data.js module
- Add budgetScenarios and priceUpdatedAt to realtime snapshot
2026-04-29 22:16:47 -07:00
26 changed files with 255 additions and 8252 deletions

View File

@@ -5,7 +5,7 @@ Real-time group voting for the bachelor party — hotels, golf, nightlife, excur
## Quick Start
```bash
cd cabo-voting-app
cd voting_app
npm install
node server.js
# → http://localhost:3001
@@ -14,13 +14,7 @@ node server.js
## Features
- **Real-time WebSocket voting** — all clients update instantly
- **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries
- **Budget planner tab** — compares 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
- **Guest authentication** — bachelor-party voters sign in with their name and the last 4 digits of their phone number
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries
- **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live
- **Responsive** — works on desktop and mobile
@@ -28,24 +22,10 @@ node server.js
## Data
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel.
System seed data auto-refreshes researched options while preserving existing votes and user-added options.
Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices.
When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios.
Guest access is rostered in `seed-data.js` and `data/votes.json`; Jon is marked as groom and Toph as best man.
The live automation itself runs from `~/.codex/automations/cabo-price-watch/automation.toml`, and its human-readable and machine-readable outputs are written back into this repo under `price-watch/latest-report.md` and `price-watch/history.jsonl`.
For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout.
When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios.
## Deployment
The app can run directly under `systemd` with:
```bash
PORT=3021 DATA_DIR=/srv/state/cabo-voting node server.js
```
Traefik can then reverse proxy to the chosen host port.
Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via `cabo-voting.yml`.
See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cabo Bachelor Party</title>
<script type="module" crossorigin src="/assets/index-CY3ZP8YS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5xoFPr6.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cabo Bachelor Party</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,750 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, NavLink, Route, Routes, useNavigate, useParams } from 'react-router-dom'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const AUTH_TOKEN_KEY = 'cabo_guest_auth_token'
const BUNDLE_TAB_ID = 'bundles'
const COMPONENT_TABS = new Set(['hotel', 'flight', 'golf', 'nightlife', 'excursion'])
const CAT_COLORS = {
hotel: '#3b82f6',
flight: '#38bdf8',
golf: '#22c55e',
nightlife: '#a855f7',
excursion: '#06b6d4',
itinerary: '#fbbf24',
budget: '#f97316',
}
const CAT_EMOJI = {
hotel: '🏨',
flight: '✈️',
golf: '⛳',
nightlife: '🎧',
excursion: '🚤',
itinerary: '🗺️',
budget: '💸',
}
function normalizeSourceKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown-source'
}
function getVotes(option) {
if (Array.isArray(option?.votes)) return option.votes
if (Array.isArray(option?.voters)) return option.voters.map((name) => ({ name }))
return []
}
function isPackageSource(source) {
return String(source?.bookingType || '').toLowerCase() === 'package'
}
function isPackageLink(link) {
const text = `${link?.label || ''} ${link?.url || ''}`.toLowerCase()
return /\b(costco|apple vacations|cheapcaribbean|package|bundle|flight[- ]?hotel|hotel[- ]?flight)\b/.test(text)
}
function formatMoney(value, currency = 'USD') {
if (typeof value !== 'number' || Number.isNaN(value)) return ''
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
function formatDate(value) {
if (!value) return ''
const date = new Date(value.includes('T') ? value : `${value}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: value.includes('-') && !value.includes('T') ? 'numeric' : undefined })
}
function formatBookingType(type, basis) {
const typeLabel = { package: 'Package', standalone: 'Standalone', calculated: 'Calculated' }[type] || ''
const basisLabel = {
perTraveler: 'per traveler',
perNight: 'per night',
perDay: 'per day',
perPerson: 'per person',
perGroup: 'per group',
totalPackage: 'total package',
perRound: 'per round',
perTable: 'per table',
}[basis] || basis || ''
return [typeLabel, basisLabel].filter(Boolean).join(' · ')
}
function cx(...classes) {
return classes.filter(Boolean).join(' ')
}
function getOptionImageUrl(option, bookingUrl) {
const sourceUrl = bookingUrl || option.bookingUrl || option.url || option.links?.[0]?.url || ''
if (sourceUrl) return `/api/preview-image?url=${encodeURIComponent(sourceUrl)}`
return option.imageUrl || ''
}
function useCaboData() {
const [state, setState] = useState({
categories: [],
options: [],
budgetScenarios: [],
guestRoster: [],
pollsOpen: true,
totalVoters: 0,
priceUpdatedAt: '',
priceHistoryRunCount: 0,
tripCheckIn: '',
tripCheckOut: '',
connected: false,
})
const wsRef = useRef(null)
useEffect(() => {
let closed = false
let retry = 1000
let timer = null
const connect = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = location.port === '5173' ? `${location.hostname}:3001` : location.host
const ws = new WebSocket(`${protocol}//${host}`)
wsRef.current = ws
ws.onopen = () => {
retry = 1000
setState((current) => ({ ...current, connected: true }))
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'init') {
setState((current) => ({
...current,
categories: message.categories || [],
options: message.options || [],
budgetScenarios: message.budgetScenarios || [],
guestRoster: message.guestRoster || [],
pollsOpen: message.pollsOpen,
totalVoters: message.totalVoters || 0,
priceUpdatedAt: message.priceUpdatedAt || '',
priceHistoryRunCount: message.priceHistoryRunCount || 0,
tripCheckIn: message.tripCheckIn || '',
tripCheckOut: message.tripCheckOut || '',
}))
} else if (message.type === 'vote_update') {
setState((current) => ({
...current,
options: current.options.map((option) => {
const update = message.results?.find((result) => result.id === option.id)
return update ? { ...option, votes: (update.voters || []).map((name) => ({ name })) } : option
}),
}))
} else if (['option_added', 'option_approved', 'option_updated'].includes(message.type)) {
setState((current) => ({
...current,
options: current.options.some((option) => option.id === message.option.id)
? current.options.map((option) => option.id === message.option.id ? { ...option, ...message.option } : option)
: [...current.options, message.option],
}))
} else if (message.type === 'option_deleted') {
setState((current) => ({ ...current, options: current.options.filter((option) => option.id !== message.id) }))
} else if (message.type === 'polls_status') {
setState((current) => ({ ...current, pollsOpen: message.open }))
}
}
ws.onclose = () => {
setState((current) => ({ ...current, connected: false }))
if (!closed) {
timer = setTimeout(connect, retry)
retry = Math.min(retry * 2, 30000)
}
}
ws.onerror = () => ws.close()
}
connect()
return () => {
closed = true
clearTimeout(timer)
wsRef.current?.close()
}
}, [])
const send = (message) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message))
}
}
return { state, setState, send }
}
function useGuestSession() {
const [guest, setGuest] = useState(null)
const [token, setToken] = useState(() => localStorage.getItem(AUTH_TOKEN_KEY) || '')
const [ready, setReady] = useState(false)
useEffect(() => {
if (!token) {
setReady(true)
return
}
fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
.then((response) => response.ok ? response.json() : Promise.reject(new Error('invalid')))
.then((payload) => setGuest(payload.guest))
.catch(() => {
localStorage.removeItem(AUTH_TOKEN_KEY)
setToken('')
setGuest(null)
})
.finally(() => setReady(true))
}, [token])
const login = async (name, pin) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, pin }),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload.error || 'Invalid guest name or code')
localStorage.setItem(AUTH_TOKEN_KEY, payload.token)
setToken(payload.token)
setGuest(payload.guest)
}
const logout = () => {
localStorage.removeItem(AUTH_TOKEN_KEY)
setToken('')
setGuest(null)
}
return { guest, token, ready, login, logout }
}
function buildTabs(categories) {
const tabs = categories.filter((category) => category.id !== 'results').map((category) => ({ ...category }))
const hotelIndex = tabs.findIndex((category) => category.id === 'hotel')
const bundleTab = { id: BUNDLE_TAB_ID, name: 'Bundles', emoji: '🧳' }
if (hotelIndex >= 0) tabs.splice(hotelIndex + 1, 0, bundleTab)
else tabs.unshift(bundleTab)
tabs.push({ id: 'results', name: 'Results', emoji: '🏆' })
return tabs
}
function getAvailableSources(option, tabId) {
const sources = Array.isArray(option.availableSources) && option.availableSources.length
? option.availableSources.map((source) => ({ ...source, sourceKey: normalizeSourceKey(source.sourceKey || source.sourceLabel || source.source) }))
: [{
sourceKey: normalizeSourceKey(option.automationInsights?.source || 'unknown-source'),
sourceLabel: option.automationInsights?.source || 'Unknown source',
sourceUrl: option.automationInsights?.sourceUrl || option.bookingUrl || option.url || null,
bookingType: option.automationInsights?.bookingType || null,
priceBasis: option.automationInsights?.priceBasis || null,
latestPrice: option.latestPricePoint?.price ?? null,
latestDisplayPrice: option.latestPricePoint?.displayPrice || null,
currency: option.latestPricePoint?.currency || 'USD',
}]
if (tabId === BUNDLE_TAB_ID) return sources.filter(isPackageSource)
if (COMPONENT_TABS.has(tabId)) return sources.filter((source) => !isPackageSource(source))
return sources
}
function getLatestPoint(option, tabId) {
const sources = getAvailableSources(option, tabId)
const preferred = sources[0]
if (!preferred) return option.latestPricePoint || null
const series = option.priceHistoryBySource?.[preferred.sourceKey] || option.priceHistory || []
return series.at?.(-1) || option.latestPricePoint || null
}
function getBookingUrl(option, tabId) {
const source = getAvailableSources(option, tabId)[0]
if (source?.sourceUrl) return source.sourceUrl
if (option.bookingUrl) return option.bookingUrl
if (option.url) return option.url
return option.links?.[0]?.url || ''
}
function getVisibleLinks(option, tabId) {
const links = Array.isArray(option.links) ? option.links : []
if (tabId === BUNDLE_TAB_ID || !COMPONENT_TABS.has(tabId)) return links
return links.filter((link) => !isPackageLink(link))
}
function getVisibleOptions(options, tabId) {
if (tabId === BUNDLE_TAB_ID) {
return options.filter((option) => option.approved && getAvailableSources(option, tabId).length > 0)
}
return options.filter((option) => option.approved && option.categoryId === tabId)
}
function AppShell({ data, guest, token, send, logout }) {
const tabs = useMemo(() => buildTabs(data.categories), [data.categories])
const [mobileView, setMobileView] = useState('list')
const navigate = useNavigate()
useEffect(() => {
if (location.pathname === '/') navigate('/hotel', { replace: true })
}, [navigate])
return (
<div className="min-h-screen bg-ink text-slate-100">
<header className="sticky top-0 z-40 border-b-2 border-aqua bg-gradient-to-r from-panel to-panel2 px-4 py-3">
<div className="mx-auto flex max-w-[1440px] items-center justify-between gap-4">
<div>
<h1 className="text-base font-black text-aqua md:text-xl">Cabo Bachelor Party</h1>
<p className="text-xs text-slate-400">Vote, compare prices, and keep the map in view.</p>
</div>
<div className="flex items-center gap-3 text-xs text-slate-300">
<span className={cx('h-2 w-2 rounded-full', data.connected ? 'bg-emerald-400' : 'bg-red-400')} />
<span>{data.connected ? 'Live' : 'Reconnecting'}</span>
{guest && (
<button className="rounded-full border border-aqua/40 bg-aqua/10 px-3 py-1 font-bold text-aqua" onClick={logout}>
{guest.name}
</button>
)}
</div>
</div>
</header>
<nav className="sticky top-[62px] z-30 overflow-x-auto border-b border-line bg-panel/95 backdrop-blur">
<div className="mx-auto flex max-w-[1440px]">
{tabs.map((tab) => (
<NavLink
key={tab.id}
to={`/${tab.id}`}
className={({ isActive }) => cx(
'min-w-24 flex-1 border-b-2 px-3 py-3 text-center text-xs font-bold text-slate-400 transition hover:bg-white/5 hover:text-white',
isActive && 'border-aqua bg-aqua/10 text-aqua',
)}
>
<span className="block text-lg">{tab.emoji}</span>
{tab.name}
</NavLink>
))}
</div>
</nav>
<main className="mx-auto w-full max-w-[1440px] px-4 py-4">
<Routes>
<Route path="/" element={<Navigate to="/hotel" replace />} />
<Route
path="/:tabId"
element={
<MainRoute
data={data}
tabs={tabs}
guest={guest}
token={token}
send={send}
mobileView={mobileView}
setMobileView={setMobileView}
/>
}
/>
</Routes>
</main>
</div>
)
}
function MainRoute({ data, tabs, guest, token, send, mobileView, setMobileView }) {
const { tabId = 'hotel' } = useParams()
const tab = tabs.find((candidate) => candidate.id === tabId)
if (!tab) return <Navigate to="/hotel" replace />
if (tabId === 'results') return <ResultsView data={data} />
const visibleOptions = getVisibleOptions(data.options, tabId)
const showMap = tabId !== 'budget'
return (
<>
{showMap && (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-line bg-panel p-1 md:hidden">
<button className={cx('rounded-lg px-3 py-2 text-xs font-black', mobileView === 'list' ? 'bg-aqua/15 text-aqua' : 'text-slate-400')} onClick={() => setMobileView('list')}>List</button>
<button className={cx('rounded-lg px-3 py-2 text-xs font-black', mobileView === 'map' ? 'bg-aqua/15 text-aqua' : 'text-slate-400')} onClick={() => setMobileView('map')}>Map</button>
</div>
)}
<div className={cx('grid gap-4', showMap && 'lg:grid-cols-[minmax(420px,520px)_minmax(0,1fr)] xl:grid-cols-[minmax(520px,640px)_minmax(0,1fr)]')}>
<section className={cx(showMap && mobileView === 'map' && 'hidden md:block')}>
<OptionList tabId={tabId} tab={tab} options={visibleOptions} data={data} guest={guest} token={token} send={send} />
<AddOptionForm categories={data.categories} token={token} guest={guest} />
</section>
{showMap && (
<aside className={cx('md:block', mobileView === 'list' && 'hidden')}>
<CaboMap options={data.options.filter((option) => option.approved)} onVote={(optionId, remove) => send({ type: 'vote', optionId, remove, authToken: token })} guest={guest} />
</aside>
)}
</div>
</>
)
}
function OptionList({ tabId, tab, options, data, guest, token, send }) {
const [sortMode, setSortMode] = useState('vote-desc')
const sorted = useMemo(() => {
return [...options].sort((a, b) => {
if (sortMode.startsWith('vote')) {
const delta = getVotes(a).length - getVotes(b).length
return sortMode === 'vote-asc' ? delta : -delta
}
const aPrice = getLatestPoint(a, tabId)?.price
const bPrice = getLatestPoint(b, tabId)?.price
const aHas = typeof aPrice === 'number'
const bHas = typeof bPrice === 'number'
if (aHas && bHas) return sortMode === 'price-asc' ? aPrice - bPrice : bPrice - aPrice
if (aHas !== bHas) return aHas ? -1 : 1
return 0
})
}, [options, sortMode, tabId])
return (
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
<div className="flex items-center gap-3">
<span className={cx('h-2 w-2 rounded-full', data.connected ? 'bg-emerald-400' : 'bg-red-400')} />
<span>{data.pollsOpen ? 'Polls open' : 'Polls closed'}</span>
<span>{data.totalVoters} voter{data.totalVoters === 1 ? '' : 's'}</span>
</div>
<label className="flex items-center gap-2">
<span className="font-bold uppercase tracking-wide">Sort</span>
<select className="rounded-lg border border-aqua/20 bg-panel2 px-3 py-2 text-slate-100" value={sortMode} onChange={(event) => setSortMode(event.target.value)}>
<option value="vote-desc">Votes: High to Low</option>
<option value="vote-asc">Votes: Low to High</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
</label>
</div>
{tabId === 'budget' && <BudgetBoard scenarios={data.budgetScenarios} updatedAt={data.priceUpdatedAt} />}
<div className="grid gap-3">
{sorted.length ? sorted.map((option) => (
<OptionCard key={option.id} option={option} tabId={tabId} guest={guest} token={token} send={send} />
)) : (
<div className="rounded-xl border border-dashed border-line bg-panel p-10 text-center text-sm text-slate-400">
No {tab.name.toLowerCase()} options yet.
</div>
)}
</div>
</div>
)
}
function OptionCard({ option, tabId, guest, token, send }) {
const votes = getVotes(option)
const hasVoted = guest && votes.some((vote) => vote.name === guest.name)
const latestPoint = getLatestPoint(option, tabId)
const source = getAvailableSources(option, tabId)[0]
const bookingUrl = getBookingUrl(option, tabId)
const imageUrl = getOptionImageUrl(option, bookingUrl)
const links = getVisibleLinks(option, tabId)
const price = latestPoint?.displayPrice || (typeof latestPoint?.price === 'number' ? formatMoney(latestPoint.price, latestPoint.currency) : source?.latestDisplayPrice || '')
const dates = [formatDate(latestPoint?.tripCheckIn), formatDate(latestPoint?.tripCheckOut)].filter(Boolean).join(' to ')
const chips = [
...(option.details || []),
...(latestPoint?.highlights || []),
...(latestPoint?.features || []),
].slice(0, 7)
const vote = () => {
if (!guest) return
send({ type: 'vote', optionId: option.id, remove: hasVoted, authToken: token })
}
return (
<article className={cx('min-w-0 overflow-hidden rounded-xl border bg-panel shadow-lg transition hover:-translate-y-0.5 hover:border-aqua/70', hasVoted ? 'border-aqua/70' : 'border-line')}>
<div className="grid gap-0">
<div className="grid h-44 place-items-center overflow-hidden bg-gradient-to-br from-aqua/10 via-panel2 to-gold/10 sm:h-52">
{imageUrl ? (
<img
src={imageUrl}
alt={`${option.name} booking site preview`}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="px-6 text-center text-sm font-black uppercase tracking-wider text-slate-500">No booking image</div>
)}
</div>
<div className="min-w-0 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-base font-black text-white">{option.name}</h2>
<p className="mt-1 break-words text-sm leading-5 text-slate-400">{option.desc}</p>
</div>
<div className="shrink-0 text-sm font-black text-aqua">{votes.length} vote{votes.length === 1 ? '' : 's'}</div>
</div>
<div className="mt-3 grid gap-2 min-[560px]:grid-cols-2">
<Fact label="Current price" value={price || 'Not tracked yet'} sub={dates} />
<Fact label="Source" value={source?.sourceLabel || latestPoint?.source || 'Planning data'} />
<Fact label="Booking" value={formatBookingType(source?.bookingType || latestPoint?.bookingType, source?.priceBasis || latestPoint?.priceBasis) || 'Option link'} />
<Fact label="Status" value={latestPoint?.availability || latestPoint?.decisionNote || 'Available to compare'} />
</div>
{!!chips.length && (
<div className="mt-3 flex flex-wrap gap-1.5">
{chips.map((chip) => <span key={chip} className="max-w-full rounded-full bg-panel2 px-2.5 py-1 text-[11px] text-slate-300">{chip}</span>)}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
{bookingUrl && (
<a href={bookingUrl} target="_blank" rel="noreferrer" className="rounded-full border border-gold/40 bg-gold/10 px-3 py-2 text-xs font-black text-amber-100 hover:bg-gold/20">
Open {source?.sourceLabel || 'booking'} quote
</a>
)}
{links.slice(0, 4).map((link) => (
<a key={`${option.id}-${link.label}`} href={link.url} target="_blank" rel="noreferrer" className="max-w-full rounded-full border border-aqua/20 bg-aqua/10 px-3 py-2 text-xs font-bold text-aqua hover:bg-aqua/20">
{link.label}
</a>
))}
<button disabled={!guest} onClick={vote} className={cx('rounded-full border px-3 py-2 text-xs font-black disabled:cursor-not-allowed disabled:opacity-40', hasVoted ? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100' : 'border-aqua/40 bg-aqua/10 text-aqua')}>
{hasVoted ? 'Remove vote' : 'Vote'}
</button>
</div>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-panel2">
<div className="h-full rounded-full" style={{ width: `${Math.min(100, votes.length * 18)}%`, background: CAT_COLORS[option.categoryId] || '#00d4ff' }} />
</div>
<p className="mt-2 text-xs text-slate-500">{votes.length ? votes.map((vote) => vote.name).join(', ') : 'No votes yet.'}</p>
</div>
</div>
</article>
)
}
function Fact({ label, value, sub }) {
return (
<div className="min-w-0 rounded-lg border border-white/5 bg-white/[0.03] p-3">
<div className="text-[10px] font-black uppercase tracking-wider text-slate-500">{label}</div>
<div className="mt-1 break-words text-sm font-bold leading-5 text-white">{value}</div>
{sub && <div className="mt-1 break-words text-xs leading-4 text-slate-500">{sub}</div>}
</div>
)
}
function BudgetBoard({ scenarios, updatedAt }) {
if (!scenarios?.length) return null
return (
<section className="mb-4 rounded-2xl border border-orange-400/25 bg-gradient-to-br from-orange-400/10 via-panel to-cyan-400/10 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-black text-orange-100">Budget Cheat Sheet</h2>
<p className="text-sm text-orange-100/70">Fresh automation scenarios from the latest price run.</p>
</div>
{updatedAt && <span className="rounded-full bg-orange-400/10 px-3 py-1 text-xs font-bold text-orange-100">Updated {formatDate(updatedAt)}</span>}
</div>
<div className="grid gap-3 md:grid-cols-3">
{scenarios.slice(0, 3).map((scenario) => (
<div key={scenario.id} className="rounded-xl border border-white/10 bg-ink/70 p-4">
<div className="text-xs font-black uppercase tracking-wider text-orange-200">{scenario.tier} · {scenario.groupSize}</div>
<div className="mt-2 text-2xl font-black">{formatMoney(scenario.perPerson)} pp</div>
<div className="text-xs text-orange-100/70">{formatMoney(scenario.groupTotal)} group total</div>
<p className="mt-3 text-sm leading-5 text-slate-300">{scenario.summary}</p>
</div>
))}
</div>
</section>
)
}
function ResultsView({ data }) {
const categories = data.categories.filter((category) => !['results'].includes(category.id))
return (
<div className="mx-auto max-w-4xl">
<div className="mb-5 text-center">
<h2 className="text-2xl font-black text-aqua">Results</h2>
<p className="text-sm text-slate-400">{data.totalVoters} voters · {data.pollsOpen ? 'Polls open' : 'Polls closed'}</p>
</div>
<div className="grid gap-4">
{categories.map((category) => {
const options = data.options.filter((option) => option.approved && option.categoryId === category.id).sort((a, b) => getVotes(b).length - getVotes(a).length)
if (!options.length) return null
const max = Math.max(...options.map((option) => getVotes(option).length), 1)
return (
<section key={category.id} className="rounded-xl border border-line bg-panel p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wider text-slate-400">{category.emoji} {category.name}</h3>
<div className="grid gap-2">
{options.map((option, index) => {
const votes = getVotes(option).length
return (
<div key={option.id} className="grid grid-cols-[32px_1fr_44px] items-center gap-3 text-sm">
<span className="font-black text-slate-400">{index + 1}</span>
<div>
<div className="font-bold text-white">{option.name}</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-panel2">
<div className="h-full rounded-full" style={{ width: `${(votes / max) * 100}%`, background: CAT_COLORS[category.id] || '#00d4ff' }} />
</div>
</div>
<span className="text-right font-black text-aqua">{votes}</span>
</div>
)
})}
</div>
</section>
)
})}
</div>
</div>
)
}
function CaboMap({ options, guest, onVote }) {
const mapRef = useRef(null)
const mapElRef = useRef(null)
const markerLayerRef = useRef(null)
useEffect(() => {
if (!mapElRef.current || mapRef.current) return
mapRef.current = L.map(mapElRef.current, { zoomControl: true }).setView([23.065, -109.698], 12)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap, &copy; CARTO',
maxZoom: 18,
}).addTo(mapRef.current)
markerLayerRef.current = L.layerGroup().addTo(mapRef.current)
}, [])
useEffect(() => {
const map = mapRef.current
const layer = markerLayerRef.current
if (!map || !layer) return
layer.clearLayers()
const markers = options.filter((option) => option.lat && option.lng).map((option) => {
const color = option.categoryColor || CAT_COLORS[option.categoryId] || '#00d4ff'
const emoji = CAT_EMOJI[option.categoryId] || '📍'
const icon = L.divIcon({
html: `<div style="background:${color};width:34px;height:34px;border-radius:50%;border:2px solid white;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 4px 14px rgba(0,0,0,.45)">${emoji}</div>`,
className: '',
iconSize: [34, 34],
iconAnchor: [17, 17],
})
const votes = getVotes(option)
const hasVoted = guest && votes.some((vote) => vote.name === guest.name)
const marker = L.marker([option.lat, option.lng], { icon })
marker.bindPopup(`
<div class="cabo-popup">
<strong>${option.name}</strong>
<p>${option.desc || ''}</p>
<a href="${getBookingUrl(option, option.categoryId)}" target="_blank" rel="noreferrer">Book / quote</a>
<button data-option-id="${option.id}" data-remove="${hasVoted ? 'true' : 'false'}">${hasVoted ? 'Remove vote' : 'Vote'}</button>
</div>
`)
marker.on('popupopen', (event) => {
event.popup.getElement()?.querySelector('button')?.addEventListener('click', () => {
onVote(option.id, hasVoted)
})
})
marker.addTo(layer)
return marker
})
if (markers.length) {
map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [34, 34], maxZoom: 14 })
}
setTimeout(() => map.invalidateSize(), 50)
}, [options, guest, onVote])
useEffect(() => {
const resize = () => mapRef.current?.invalidateSize()
window.addEventListener('resize', resize)
setTimeout(resize, 100)
return () => window.removeEventListener('resize', resize)
}, [])
return (
<div className="sticky top-32 h-[calc(100vh-9rem)] min-h-[520px] overflow-hidden rounded-2xl border border-line bg-panel shadow-glow">
<div ref={mapElRef} className="h-full w-full" />
</div>
)
}
function AddOptionForm({ categories, guest, token }) {
const [form, setForm] = useState({ categoryId: 'hotel', name: '', desc: '', url: '', details: '' })
const [status, setStatus] = useState('')
const submit = async (event) => {
event.preventDefault()
if (!guest) {
setStatus('Sign in before suggesting a place.')
return
}
const response = await fetch('/api/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(form),
})
setStatus(response.ok ? 'Suggestion sent.' : 'Could not submit suggestion.')
if (response.ok) setForm({ categoryId: 'hotel', name: '', desc: '', url: '', details: '' })
}
return (
<form onSubmit={submit} className="mt-5 rounded-xl border border-dashed border-line bg-panel p-4">
<h3 className="mb-3 text-sm font-black text-slate-400">Suggest a Place</h3>
<div className="grid gap-2">
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="Name" />
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.desc} onChange={(event) => setForm({ ...form, desc: event.target.value })} placeholder="Short description" />
<input className="rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.url} onChange={(event) => setForm({ ...form, url: event.target.value })} placeholder="Booking or website URL" />
<textarea className="min-h-20 rounded-lg border border-line bg-panel2 px-3 py-2 text-sm outline-none focus:border-aqua" value={form.details} onChange={(event) => setForm({ ...form, details: event.target.value })} placeholder="Details, one per line" />
<div className="flex gap-2">
<select className="min-w-0 flex-1 rounded-lg border border-line bg-panel2 px-3 py-2 text-sm" value={form.categoryId} onChange={(event) => setForm({ ...form, categoryId: event.target.value })}>
{categories.filter((category) => !['results'].includes(category.id)).map((category) => <option key={category.id} value={category.id}>{category.emoji} {category.name}</option>)}
</select>
<button className="rounded-lg bg-aqua px-4 py-2 text-sm font-black text-ink" type="submit">Submit</button>
</div>
</div>
{status && <p className="mt-2 text-xs text-slate-400">{status}</p>}
</form>
)
}
function AuthModal({ roster, login }) {
const [name, setName] = useState('')
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const submit = async (event) => {
event.preventDefault()
try {
await login(name, pin)
} catch (err) {
setError(err.message)
}
}
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/80 p-4 backdrop-blur">
<form onSubmit={submit} className="w-full max-w-sm rounded-2xl border border-line bg-panel p-6 text-center shadow-2xl">
<h2 className="text-xl font-black text-aqua">Guest Access</h2>
<p className="mt-2 text-sm text-slate-400">Select your name and enter the last 4 digits of your phone number.</p>
<select className="mt-5 w-full rounded-lg border border-line bg-panel2 px-3 py-3 text-left outline-none focus:border-aqua" value={name} onChange={(event) => setName(event.target.value)}>
<option value="">Select your name</option>
{roster.map((guest) => <option key={guest.name} value={guest.name}>{guest.name}</option>)}
</select>
<input className="mt-3 w-full rounded-lg border border-line bg-panel2 px-3 py-3 text-center text-lg tracking-[0.3em] outline-none focus:border-aqua" inputMode="numeric" maxLength={4} type="password" value={pin} onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="0000" />
{error && <p className="mt-3 text-sm text-red-300">{error}</p>}
<button className="mt-5 w-full rounded-lg bg-aqua px-4 py-3 font-black text-ink" type="submit">Join the Vote</button>
</form>
</div>
)
}
export default function App() {
const { state, send } = useCaboData()
const { guest, token, ready, login, logout } = useGuestSession()
if (!ready) return <div className="grid min-h-screen place-items-center bg-ink text-aqua">Loading...</div>
return (
<>
<AppShell data={state} guest={guest} token={token} send={send} logout={logout} />
{!guest && <AuthModal roster={state.guestRoster} login={login} />}
</>
)
}

View File

@@ -1,218 +0,0 @@
.budget-tab {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
.budget-header {
text-align: center;
margin-bottom: 1.5rem;
}
.budget-header h2 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.budget-header p {
margin: 0;
opacity: 0.7;
font-size: 0.875rem;
}
.budget-leaders {
margin-bottom: 1.5rem;
}
.budget-leaders h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
opacity: 0.8;
}
.leaders-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.leader-card {
background: var(--card-bg, #1e1e2e);
border: 1px solid var(--border, #2a2a3a);
border-radius: 10px;
padding: 0.875rem;
text-align: center;
}
.leader-cat {
font-size: 0.75rem;
opacity: 0.6;
margin-bottom: 0.375rem;
font-weight: 600;
}
.leader-name {
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 0.25rem;
line-height: 1.3;
}
.leader-name.no-votes {
opacity: 0.4;
font-weight: 400;
font-style: italic;
}
.leader-tier {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.leader-price {
font-size: 0.72rem;
color: #00d4ff;
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.leader-votes {
font-size: 0.7rem;
opacity: 0.5;
}
/* Tier pills */
.budget-tiers {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tier-card {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.6rem;
border-radius: 8px;
border: 2px solid var(--border, #2a2a3a);
opacity: 0.5;
transition: all 0.2s;
position: relative;
}
.tier-card.active {
opacity: 1;
border-color: var(--tier-color);
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
}
.tier-label {
font-size: 0.8rem;
font-weight: 700;
}
.tier-badge {
position: absolute;
top: -8px;
right: -8px;
background: var(--tier-color);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
text-transform: uppercase;
}
/* Breakdown table */
.budget-breakdown h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.breakdown-table {
background: var(--card-bg, #1e1e2e);
border: 1px solid var(--border, #2a2a3a);
border-radius: 10px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.breakdown-row {
display: grid;
grid-template-columns: 1fr 1.4fr repeat(3, 70px);
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
border-bottom: 1px solid var(--border, #2a2a3a);
}
.source-cell {
font-size: 0.65rem;
opacity: 0.6;
text-align: left !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breakdown-row:last-child {
border-bottom: none;
}
.breakdown-row.header-row {
font-weight: 700;
opacity: 0.6;
font-size: 0.75rem;
background: var(--bg-secondary, #16161e);
}
.breakdown-row span {
text-align: right;
}
.breakdown-row span:first-child {
text-align: left;
}
.breakdown-row.total-row {
background: color-mix(in srgb, var(--accent) 8%, transparent);
font-weight: 700;
}
.breakdown-row.group-row {
background: var(--bg-secondary, #16161e);
font-weight: 700;
font-size: 0.875rem;
}
.budget-note {
font-size: 0.7rem;
opacity: 0.5;
margin: 0;
text-align: center;
}
/* Placeholder */
.budget-placeholder {
text-align: center;
padding: 3rem 1rem;
opacity: 0.7;
}
.placeholder-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.budget-placeholder p {
max-width: 300px;
margin: 0 auto;
font-size: 0.875rem;
line-height: 1.5;
}

View File

@@ -1,371 +0,0 @@
import { useMemo } from 'react'
import './BudgetTab.css'
// ─── Price parsers ────────────────────────────────────────────────────────────
function extractPrices(details) {
if (!details || !details.length) return {}
const text = details.join(' ')
const prices = {}
// Per-person package: "Apple exact-date quote: $2,016 pp" / "Costco package: $1,678.99 pp"
const ppMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person|per-person)/i)
if (ppMatch) prices.perPerson = parseFloat(ppMatch[1].replace(/,/g, ''))
// Per-night: "KAYAK from $212/night" / "KAYAK exact-date room rate: $335/night"
const pnMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:\/night|per night|per-night)/i)
if (pnMatch) prices.perNight = parseFloat(pnMatch[1].replace(/,/g, ''))
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
const groupMatch = text.match(/(?:for|at)\s+(\d+)[^\d$]*\$([\d,]+(?:\.\d{2})?)/i)
|| text.match(/\$([\d,]+(?:\.\d{2})?)\s+(?:total|for\s+\d+)/i)
if (groupMatch) {
prices.groupTotal = parseFloat((groupMatch[2] || groupMatch[1]).replace(/,/g, ''))
const sizeMatch = text.match(/for\s+(\d+)\s*[^\d$]*/i)
if (sizeMatch) prices.groupSize = parseInt(sizeMatch[1])
}
// Simple "From $1,504 total"
if (!prices.groupTotal) {
const totalMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\s+total/i)
if (totalMatch) prices.groupTotal = parseFloat(totalMatch[1].replace(/,/g, ''))
}
// Per-person from group total: "About $188 pp at 8"
const ppAtMatch = text.match(/\$\d+[\d,.]*\s*(?:pp|per person).*?at\s+(\d+)/i)
|| text.match(/about?\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person).*?at\s+(\d+)/i)
if (ppAtMatch) {
prices.perPerson = parseFloat(ppAtMatch[1].replace(/,/g, ''))
prices.atGroupSize = parseInt(ppAtMatch[2])
}
// Planning number: "Use about $180 as current planning number"
const planMatch = text.match(/use\s+(?:about\s+)?\$([\d,]+(?:\.\d{2})?)\s+(?:as\s+)?(?:the\s+)?(?:current\s+)?planning/i)
if (planMatch && !prices.perPerson) prices.planningRate = parseFloat(planMatch[1].replace(/,/g, ''))
// Flat per-person: "From $76" — only if no other signals
if (!prices.perPerson && !prices.perNight && !prices.groupTotal && !prices.planningRate) {
const flatMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\b(?!.*\b(?:pp|per|total|for\b)/i)
if (flatMatch) {
const val = parseFloat(flatMatch[1].replace(/,/g, ''))
if (val > 20) prices.perPerson = val
}
}
return prices
}
// ─── Check whether an option has real scraped pricing ─────────────────────────
function hasRealPricing(option) {
const p = extractPrices(option.details || [])
// Has at least one real price signal (not just a planning estimate or generic text)
return !!(p.perPerson || p.perNight || p.groupTotal || p.planningRate)
}
// ─── Resolve per-person cost for an option ───────────────────────────────────
function resolvePerPerson(option, fallback) {
const p = extractPrices(option.details || [])
if (p.perPerson) return p.perPerson
if (p.perNight) return p.perNight * 3 // assume 3-night stay
if (p.groupTotal && p.groupSize) return Math.round(p.groupTotal / p.groupSize)
if (p.groupTotal && p.atGroupSize) {
// scale to 8-person group as baseline
return Math.round(p.groupTotal / p.atGroupSize)
}
if (p.groupTotal) return Math.round(p.groupTotal / 8)
if (p.planningRate) return p.planningRate
return fallback
}
// ─── Fixed costs ─────────────────────────────────────────────────────────────
const FLIGHT_PP = 350
const FOOD_BUFFER = 275
// ─── Per-person costs from real scraped data (used when details are empty) ──
const FALLBACK_HOTEL = { budget: 450, balanced: 850, splurge: 1250 }
const FALLBACK_GOLF = { budget: 130, balanced: 180, splurge: 250 }
const FALLBACK_NIGHTLIFE = { budget: 40, balanced: 100, splurge: 200 }
const FALLBACK_EXCURSION = { budget: 76, balanced: 110, splurge: 188 }
const FALLBACK_TRANSFER = { budget: 33, balanced: 83, splurge: 183 }
// ─── Tier classification by hotel price signal ──────────────────────────────
function classifyTier(optionId, perPerson) {
// Use the actual scraped per-person package prices as ground truth
const KNOWN_PP = {
// Hotels — package prices
'hotel-corazon': null, // no live rate
'hotel-breathless': 1678.99, // Costco package
'hotel-grand-fiesta': null, // no Costco live, Apple $2,111
'hotel-secrets': 2005.80, // Costco package
'hotel-pacifica': null, // no live rate
'hotel-dreams': null,
// Hotels — nightly rates → scaled to 3-night
// KAYAK per-night rates × 3 nights
}
if (KNOWN_PP[optionId] !== undefined && KNOWN_PP[optionId] !== null) {
const pp = KNOWN_PP[optionId]
if (pp <= 1000) return 'budget'
if (pp <= 1900) return 'balanced'
return 'splurge'
}
// Nightly rate proxies (×3 nights)
if (perPerson <= 300) return 'budget'
if (perPerson <= 900) return 'balanced'
return 'splurge'
}
// ─── Category label / emoji ──────────────────────────────────────────────────
const CAT_LABELS = {
hotel: '🏨 Hotel',
golf: '⛳ Golf',
nightlife: '🎧 Nightlife',
excursion: '🚤 Excursion',
}
const GROUP_SIZES = [8, 10, 12]
// ─── Main component ─────────────────────────────────────────────────────────
function getWinningOption(options, categoryId) {
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
if (!catOpts.length) return null
return catOpts.reduce((best, o) =>
(o.votes?.length || 0) > (best.votes?.length || 0) ? o : best
, catOpts[0])
}
function dominantTier(tiers) {
const counts = { budget: 0, balanced: 0, splurge: 0 }
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
if (counts.splurge >= 2) return 'splurge'
if (counts.budget >= 2) return 'budget'
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'balanced'
}
function formatPrice(v) {
if (!v) return '—'
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
function getPriceLabel(option) {
const p = extractPrices(option.details || [])
const text = (option.details || []).join(' ')
if (p.perPerson) {
// Prefer package price label
const pkgMatch = text.match(/(Costco|Apple).*?\$[\d,]+(?:\.\d{2})?\s*(?:pp|per person)/i)
if (pkgMatch) {
const m = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person)/i)
if (m) return `pkg ${formatPrice(parseFloat(m[1].replace(/,/g, '')))}/pp`
}
return formatPrice(p.perPerson) + '/pp'
}
if (p.perNight) {
const nights = 3
return formatPrice(p.perNight) + '/night → ' + formatPrice(p.perNight * nights) + ' for 3 nights'
}
if (p.groupTotal && p.groupSize) {
const pp = Math.round(p.groupTotal / p.groupSize)
return formatPrice(p.groupTotal) + ' total ÷ ' + p.groupSize + ' = ' + formatPrice(pp) + '/pp'
}
if (p.groupTotal) {
const pp = Math.round(p.groupTotal / 8)
return formatPrice(p.groupTotal) + ' group total (~' + formatPrice(pp) + '/pp)'
}
if (p.planningRate) return '~$' + Math.round(p.planningRate) + '/pp planning'
return null
}
export default function BudgetTab({ options, categories }) {
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
const leaders = useMemo(() => {
const result = {}
votingCategories.forEach(cat => {
result[cat] = getWinningOption(options, cat)
})
return result
}, [options])
// Only include leaders that have actual scraped pricing
const pricedLeaders = useMemo(() => {
const result = {}
votingCategories.forEach(cat => {
const leader = leaders[cat]
if (leader && hasRealPricing(leader)) result[cat] = leader
})
return result
}, [leaders])
// Determine tier per category based on actual prices
const tiers = useMemo(() => {
const t = {}
votingCategories.forEach(cat => {
const leader = pricedLeaders[cat]
if (!leader) { t[cat] = 'balanced'; return }
const pp = resolvePerPerson(leader, null)
t[cat] = classifyTier(leader.id, pp)
})
return t
}, [pricedLeaders])
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
// Build the cost breakdown from real prices only
const costRows = useMemo(() => {
const rows = {}
votingCategories.forEach(cat => {
const leader = pricedLeaders[cat]
if (!leader) return // skip categories without priced leaders
const pp = resolvePerPerson(leader, null)
rows[cat] = { label: CAT_LABELS[cat], pp, source: leader.name }
})
// Transfer depends on tier (only show if we have at least a hotel)
if (Object.keys(pricedLeaders).length > 0) {
rows.transfer = { label: '🚗 Transfers', pp: FALLBACK_TRANSFER[overallTier], source: 'shared shuttle estimate' }
}
rows.flight = { label: '✈️ Flights', pp: FLIGHT_PP, source: 'group average estimate' }
rows.food = { label: '🍽️ Food + drinks', pp: FOOD_BUFFER, source: 'buffer for 3 days' }
return rows
}, [pricedLeaders, overallTier])
const totals = useMemo(() => {
return GROUP_SIZES.map(size => {
const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0)
return { size, perPerson: Math.round(sum), total: Math.round(sum * size) }
})
}, [costRows])
const hasPricedLeaders = Object.keys(pricedLeaders).length > 0
const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
return (
<div className="budget-tab">
<div className="budget-header">
<h2>Trip Budget Calculator</h2>
<p>Prices sourced from live travel data updates as votes come in</p>
</div>
{/* Category leaders with real prices */}
<div className="budget-leaders">
<h3>Current Leaders Live Prices</h3>
{Object.keys(pricedLeaders).length > 0 ? (
<div className="leaders-grid">
{votingCategories.map(cat => {
const leader = pricedLeaders[cat]
if (!leader) return null
const catTier = tiers[cat]
const priceLabel = getPriceLabel(leader)
return (
<div key={cat} className="leader-card" style={{ '--cat-color': tierColor[catTier] }}>
<div className="leader-cat">{CAT_LABELS[cat]}</div>
<div className="leader-name">{leader.name}</div>
{priceLabel && <div className="leader-price">{priceLabel}</div>}
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
{tierLabel[catTier]}
</div>
<div className="leader-votes">
{leader.votes?.length > 0
? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}`
: 'no votes yet'}
</div>
</div>
)
})}
</div>
) : (
<div className="budget-placeholder">
<div className="placeholder-icon"></div>
<p>Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.</p>
</div>
)}
</div>
{/* Tier summary pills */}
<div className="budget-tiers">
{['budget', 'balanced', 'splurge'].map(tier => (
<div
key={tier}
className={`tier-card ${overallTier === tier ? 'active' : ''}`}
style={{ '--tier-color': tierColor[tier] }}
>
<div className="tier-label">{tierLabel[tier]}</div>
{overallTier === tier && <div className="tier-badge">Current</div>}
</div>
))}
</div>
{/* Dynamic cost breakdown */}
{hasPricedLeaders ? (
<div className="budget-breakdown">
<h3>Estimated Cost {tierLabel[overallTier]} Tier</h3>
<div className="breakdown-table">
<div className="breakdown-row header-row">
<span>Line Item</span>
<span>Source</span>
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
</div>
{[
costRows.flight,
costRows.hotel,
costRows.golf,
costRows.nightlife,
costRows.excursion,
costRows.transfer,
costRows.food,
].map(row => row && (
<div className="breakdown-row" key={row.label}>
<span>{row.label}</span>
<span className="source-cell">{row.source || '—'}</span>
{GROUP_SIZES.map(s => (
<span key={s}>{formatPrice(row.pp)}</span>
))}
</div>
))}
<div className="breakdown-row total-row">
<span>Est. Per Person</span>
<span className="source-cell">sum</span>
{totals.map(t => (
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
{formatPrice(t.perPerson)}
</span>
))}
</div>
<div className="breakdown-row group-row">
<span>Est. Group Total</span>
<span className="source-cell"></span>
{totals.map(t => (
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
{formatPrice(t.total)}
</span>
))}
</div>
</div>
<p className="budget-note">
* Prices from live scraped data. Hotel rates are package or nightly × 3 nights.
Per-person costs shown are the same for all group sizes. Add bar tabs and tips separately.
</p>
</div>
) : (
<div className="budget-placeholder">
<div className="placeholder-icon">🗳</div>
<p>No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.</p>
</div>
)}
</div>
)
}

View File

@@ -1,100 +0,0 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px;
width: 360px;
max-width: 90vw;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; line-height: 1.5; }
.modal form { display: flex; flex-direction: column; gap: 12px; }
.modal input {
width: 100%;
padding: 10px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.modal input:focus { border-color: var(--accent); }
.modal button {
width: 100%;
padding: 10px;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-weight: 700;
transition: opacity 0.2s;
}
.modal button:hover:not(:disabled) { opacity: 0.85; }
.modal button:disabled { opacity: 0.4; cursor: not-allowed; }
.name-error {
color: #f87171;
font-size: 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 6px;
padding: 6px 10px;
text-align: center;
}
.pin-input {
letter-spacing: 0.3em !important;
font-size: 1.5rem !important;
text-align: center !important;
font-family: monospace !important;
}
.back-btn {
background: transparent !important;
color: var(--text-muted) !important;
border: 1px solid var(--border) !important;
font-weight: 400 !important;
font-size: 0.8rem !important;
}
@media (max-width: 640px) {
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
.modal {
width: 100%;
border-radius: 20px 20px 0 0;
padding: 12px 24px 32px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.drag-handle {
width: 40px; height: 4px;
background: var(--border);
border-radius: 2px;
margin: 0 auto 14px;
}
.modal h2 { font-size: 1.15rem; }
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
}

View File

@@ -1,107 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { GROOMSMEN, validateGroomsman } from '../groommen'
import './NameModal.css'
const STORAGE_KEY_PIN = 'cabo_voter_pin'
export default function NameModal({ onSubmit }) {
const [name, setName] = useState('')
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const [step, setStep] = useState(1) // 1 = name, 2 = pin
const inputRef = useRef(null)
useEffect(() => {
if (step === 1) inputRef.current?.focus()
}, [step])
const handleNameNext = (e) => {
e.preventDefault()
if (!name.trim()) return
const key = name.trim().toLowerCase().replace(/\s+/g, '')
if (!GROOMSMEN[key]) {
setError(`"${name}" is not on the guest list. Check with the groom.`)
return
}
setError('')
setStep(2)
// Pre-fill pin from localStorage if saved
const saved = localStorage.getItem(STORAGE_KEY_PIN)
if (saved) setPin(saved)
}
const handlePinSubmit = (e) => {
e.preventDefault()
if (pin.length !== 4) {
setError('Enter the last 4 digits of your phone number.')
return
}
const key = name.trim().toLowerCase().replace(/\s+/g, '')
if (!validateGroomsman(name, pin)) {
setError('Wrong PIN. Make sure you\'re using the correct name + last 4 digits.')
return
}
localStorage.setItem(STORAGE_KEY_PIN, pin)
onSubmit(name.trim())
}
const handleBack = () => {
setStep(1)
setPin('')
setError('')
}
return (
<div className="modal-overlay">
<div className="modal">
<div className="drag-handle"></div>
{step === 1 ? (
<>
<h2>🏄 Who's Voting?</h2>
<p>Enter your <strong>full name</strong> as it appears on the guest list.</p>
<form onSubmit={handleNameNext}>
<input
ref={inputRef}
type="text"
value={name}
onChange={e => { setName(e.target.value); setError('') }}
placeholder="e.g. Jon, Toph, Hans…"
maxLength={30}
autoComplete="off"
autoCapitalize="words"
/>
{error && <div className="name-error">{error}</div>}
<button type="submit" disabled={!name.trim()}>Next →</button>
</form>
</>
) : (
<>
<h2>🔐 Verify with PIN</h2>
<p>Enter the <strong>last 4 digits</strong> of your phone number, {name.split(' ')[0]}.</p>
<form onSubmit={handlePinSubmit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={e => { setPin(e.target.value.replace(/\D/g, '')); setError('') }}
placeholder="••••"
autoComplete="off"
className="pin-input"
/>
{error && <div className="name-error">{error}</div>}
<button type="submit" disabled={pin.length !== 4}>Join the Vote </button>
<button type="button" className="back-btn" onClick={handleBack}> Back</button>
</form>
</>
)}
</div>
</div>
)
}

View File

@@ -1,43 +0,0 @@
// Groomsmen: name (lowercase) → last 4 digits of phone number
export const GROOMSMEN = {
jon: '7506',
toph: '8116',
hans: '6681',
janno: '2809',
jt: '3286',
Cordero: '0379', // no last name given
lester: '8014',
nick: '6044',
david: '5993',
poalo: '9922', // likely "Paolo"
justin: '2329',
benstewart: '1957',
joseph: '4976',
francis: '4934',
chris: '1584', // Chris Mayor — admin
}
// Reverse map: last4 → display name
export const GROOMSMEN_BY_PIN = Object.fromEntries(
Object.entries(GROOMSMEN).map(([name, pin]) => [pin, name])
)
/**
* Validate a name + 4-digit PIN combination.
* Name must match a key in GROOMSMEN (case-insensitive).
* PIN must match that entry's value.
*/
export function validateGroomsman(name, pin) {
const key = name.trim().toLowerCase().replace(/\s+/g, '')
const entry = GROOMSMEN[key]
if (!entry) return false
return entry === pin.trim()
}
/**
* Resolve the canonical display name for a validated groomsman.
*/
export function getCanonicalName(name) {
const key = name.trim().toLowerCase().replace(/\s+/g, '')
return GROOMSMEN[key] ? key : name.trim()
}

View File

@@ -1,59 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
color-scheme: dark;
}
body {
margin: 0;
min-width: 320px;
background: #0b0d14;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.leaflet-container {
background: #0a0a14;
font-family: inherit;
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: #13161f;
color: #e0e6f0;
border: 1px solid #252a38;
}
.cabo-popup {
min-width: 190px;
}
.cabo-popup strong {
display: block;
color: #00d4ff;
margin-bottom: 4px;
}
.cabo-popup p {
margin: 0 0 8px;
color: #9aa6bd;
font-size: 12px;
line-height: 1.35;
}
.cabo-popup a,
.cabo-popup button {
display: inline-flex;
margin-right: 6px;
margin-top: 6px;
border: 1px solid rgba(0, 212, 255, 0.35);
border-radius: 999px;
background: rgba(0, 212, 255, 0.12);
color: #bdf4ff;
font-size: 12px;
font-weight: 800;
padding: 6px 9px;
text-decoration: none;
cursor: pointer;
}

View File

@@ -1,13 +0,0 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -1,21 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
root: 'client',
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:3001',
'/ws': {
target: 'ws://127.0.0.1:3001',
ws: true,
},
},
},
})

2933
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
{
"name": "cabo-voting-app",
"version": "1.0.0",
"private": true,
"description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.",
"main": "server.js",
"scripts": {
"dev": "vite --config client/vite.config.js --host 0.0.0.0",
"build": "vite build --config client/vite.config.js",
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"leaflet": "^1.9.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.17.0",
"uuid": "^11.1.0",
"ws": "^8.18.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.17",
"vite": "^8.0.16"
}
}

View File

@@ -1 +0,0 @@
latest-report.md

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +0,0 @@
# Cabo Price Watch - 2026-06-12
Trip window per current watch contract: 2027-02-02 through 2027-02-06 (4 nights)
## Biggest Changes
- CheapCaribbean now exposes a live Los Cabos package ladder: Dreams Los Cabos at $893 pp, Riu Palace Cabo San Lucas at $919 pp, Breathless at $1,091 pp, Secrets at $1,073 pp, Paradisus at $1,127 pp, Grand Fiesta Americana at $1,242 pp, and Hard Rock at $1,373 pp.
- Cabo Bash now shows current nightlife and day-club pricing with real tiers for Bagatelle, Taboo, Mandala, La Vaquita, and El Squid Roe.
- Golf pricing is still live and usable: Quivira is $360 for the current twilight window that covers February, and Palmillas public marketplace rate is $205 for twilight 18 holes.
- Cabo Adventures and Cabo Villas both surfaced current water/activity pricing again, including whale watching from $76, private whale watching from $1,504, private sailing from $855.94, and the 46-foot yacht from $1,753.38.
## Stable Anchors
- Exact-date KAYAK flight anchors and the Hotwire Hotel Colli stay anchor remained the live standalone baselines from the previous run.
- Current KAYAK hotel floors worth tracking are Breathless at $419/night, Secrets at $305/night, Grand Fiesta Americana at $212/night, Solmar Resort at $98/night, Grand Solmar Lands End at $138/night, Hyatt Ziva at $314/night, and JW Marriott at $331/night.
- No sold-out items surfaced in this pass.
## Missing Or Gated
- Costco Travel package pricing remains login/continue-gated, but the Los Cabos package lineup is still visible and continues to surface Hacienda del Mar, Grand Fiesta Americana, Secrets, Grand Velas, Hilton, Le Blanc, and related options.
- Apple Vacations hotel/package pages continue to expose inclusions and promos, but I could not verify a clean date-matched package price for the target window from the page text available in this pass.
- The Cabo Adventures ATV page still hides the clean base price in the captured text, but it does clearly expose the mandatory $25 entrance fee and $35 damage waiver.
## New Options Worth Adding
- Add CheapCaribbean package cards for Dreams, Breathless, Secrets, Paradisus, Grand Fiesta Americana, Riu Palace Cabo San Lucas, and Hard Rock.
- Add hotel-only cards for Hyatt Ziva, JW Marriott, Solmar Resort, and Grand Solmar Lands End.
- Add nightlife cards for Cabo Bash Taboo, Mandala, and Bagatelle, plus The Cabo Agencys Cabo Wabo table and booth options.
- Add excursion cards for private whale watching and the Cabo Villas private sail/yacht options.
## Budget Impact
- Budget: Dreams package + Palmilla + whale watch lands near $1,174 pp for 8 guests.
- Balanced: Breathless package + Quivira + private sail + Mandala lands near $1,853.31 pp for 10 guests.
- Splurge: Hard Rock package + Quivira + premium yacht + Taboo presidential deck lands near $2,352.45 pp for 12 guests.
- Package-vs-standalone caveat: package prices already include flight and hotel, so flight or room-only costs must not be added again unless the package explicitly excludes them.

View File

@@ -1,181 +0,0 @@
{
"reporting": {
"latestReport": "price-watch/latest-report.md",
"historyLog": "price-watch/history.jsonl"
},
"comparison": {
"materialPriceChangeUsd": 100,
"highlightNewOptions": true,
"markLoginRequiredSources": true
},
"tripDates": {
"checkIn": "2027-02-02",
"checkOut": "2027-02-06",
"nights": 4,
"note": "All per-night or per-day rates should be converted to the full check-in/check-out total for comparison, while preserving the unit rate in the display label."
},
"trackedSources": [
{
"id": "hotel-packages",
"label": "Flight + Hotel Packages",
"categories": ["hotel"],
"bookingType": "package",
"requiredChecks": [
"Costco Travel package results",
"Apple Vacations package search",
"CheapCaribbean package search",
"other date-matched package providers found during research"
]
},
{
"id": "standalone-hotels",
"label": "Standalone Hotels",
"categories": ["hotel"],
"bookingType": "standalone",
"requiredChecks": [
"KAYAK hotel search",
"official hotel booking engine when public rates are visible",
"other OTA hotel-only rates found during research"
]
},
{
"id": "flights",
"label": "Standalone Flights",
"categories": ["flight"],
"bookingType": "standalone",
"requiredChecks": [
"LAX to SJD date-matched round trip on airline and travel sites",
"ONT to SJD date-matched round trip on airline and travel sites",
"Google Flights exact-date search",
"KAYAK exact-date flight search",
"airline direct-booking results when publicly visible",
"capture airline, stops, schedule window, baggage caveats, and total price per traveler"
]
},
{
"id": "golf",
"label": "Golf",
"categories": ["golf"],
"bookingType": "standalone",
"requiredChecks": [
"official course tee-time pages when available",
"public tee-time marketplaces",
"resort/package golf inclusions when attached to hotel packages"
]
},
{
"id": "nightlife",
"label": "Nightlife and Day Clubs",
"categories": ["nightlife"],
"bookingType": "standalone",
"requiredChecks": [
"VIP table packages",
"bottle service minimums",
"day-club and beach-club package pricing",
"cover charges or ticketed events when visible"
]
},
{
"id": "excursions",
"label": "Excursions and Water Activities",
"categories": ["excursion"],
"bookingType": "standalone",
"requiredChecks": [
"yacht and private charter quotes",
"whale-watch and sunset sail pricing",
"ATV/off-road packages",
"bachelor-party-relevant group excursions"
]
},
{
"id": "derived-itineraries",
"label": "Derived Itineraries",
"categories": ["itinerary"],
"bookingType": "calculated",
"requiredChecks": [
"recalculate budget, balanced, and splurge itinerary totals from the current hotel/package, flight, golf, nightlife, and excursion results",
"include component breakdowns and assumptions for each itinerary"
]
},
{
"id": "derived-budgets",
"label": "Derived Budget Tracks",
"categories": ["budget"],
"bookingType": "calculated",
"requiredChecks": [
"recalculate 8, 10, and 12 person totals from current results",
"prefer exact package prices when the itinerary uses a flight+hotel package",
"avoid double-counting flights or hotels already included in package prices",
"include per-person and group-total math plus assumptions"
]
}
],
"bookingTypeRules": {
"package": "Use for bundled products such as flight+hotel packages or hotel+transfer packages. Include included components and do not mix directly with standalone room-only rates.",
"standalone": "Use for individual bookings such as hotel-only rates, flights, golf tee times, nightlife tables, yacht charters, and excursions.",
"calculated": "Use for automation-derived itinerary and budget totals built from current package or standalone components."
},
"outputSchema": {
"optionPrices": [
{
"seedKey": "stable app option key when available",
"price": "numeric price only, or null when unavailable",
"displayLabel": "human-readable price label",
"category": "hotel | flight | golf | nightlife | excursion | itinerary | budget",
"source": "travel site or vendor",
"sourceUrl": "exact result or source URL",
"bookingType": "package | standalone | calculated",
"priceBasis": "perTraveler | perNight | perDay | perPerson | perGroup | totalPackage | perRound | perTable",
"unitPrice": "original unit price when source quotes per night or per day",
"tripTotalPrice": "calculated full-stay/check-in-to-check-out price when source quotes per night or per day",
"includedComponents": ["flight", "hotel", "transfer", "golf", "nightlife", "excursion"],
"excludedComponents": ["components that must be budgeted separately"],
"origin": "airport code for flight/package quotes when applicable",
"destination": "airport or destination code when applicable",
"availability": "available | unavailable | sold-out | login-required | request-quote",
"features": ["structured decision features"],
"amenities": ["hotel or venue amenities"],
"inclusions": ["included items"],
"limitations": ["tradeoffs, caveats, restrictions"],
"decisionNote": "short decision note"
}
],
"derivedItineraries": [
{
"seedKey": "itinerary-budget | itinerary-balanced | itinerary-splurge | itinerary-concierge or new stable key",
"tier": "Budget | Balanced | Splurge | Concierge",
"perPerson": "numeric calculated per-person total",
"groupTotal": "numeric calculated group total when group size is known",
"groupSize": "8 | 10 | 12 or selected party size",
"components": ["component price keys used"],
"assumptions": ["calculation assumptions"],
"summary": "short recommendation summary"
}
],
"budgetScenarios": [
{
"id": "stable scenario id",
"tier": "Budget | Balanced | Splurge",
"groupSize": "numeric group size",
"perPerson": "numeric calculated per-person total",
"groupTotal": "numeric calculated group total",
"summary": "short scenario summary",
"notes": ["component breakdown and assumptions"]
}
]
},
"notes": [
"Use seed-data.js as the current baseline for names, links, and budget assumptions.",
"Check hotels, flights, golf, nightlife, and excursions on every run before updating itinerary or budget recommendations.",
"For flights, search both airline direct-booking pages and travel aggregators such as Google Flights, KAYAK, Expedia, and similar public flight search tools when available.",
"Differentiate bundled package prices from standalone booking prices using bookingType and priceBasis on every price point.",
"For package quotes, list the included and excluded components so budgets do not double-count flights, hotels, transfers, or resort credits.",
"For standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",
"For per-night or per-day hotel/activity rates, calculate the total for the expected check-in/check-out dates and preserve the unit rate in unitPrice or displayLabel.",
"Itinerary and budget options are calculated outputs. Recompute them from the freshest current package and standalone component prices instead of treating seed-data.js totals as current.",
"Write a human-readable report to price-watch/latest-report.md on every run.",
"Append one machine-readable summary line per run to price-watch/history.jsonl, including per-option price points, derivedItineraries, and budgetScenarios keyed by stable option ids or seed keys.",
"Capture structured option details when available: current price, availability, source, sourceUrl, highlights, features, amenities, inclusions, limitations, and a short decision note.",
"If a source is gated behind login or membership, note that clearly in both outputs."
]
}

View File

@@ -120,87 +120,8 @@
.btn-reject { background: var(--red); color: #fff; }
.btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
.btn-delete:hover { border-color: var(--red); color: var(--red); }
.btn-edit { background: transparent; border: 1px solid var(--border); color: var(--amber); }
.btn-edit:hover { border-color: var(--amber); color: #fcd34d; }
.btn-row { display: flex; gap: 6px; flex-shrink: 0; }
/* Editor */
.editor-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
margin-bottom: 24px;
}
.editor-card.hidden { display: none; }
.editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.editor-grid .full { grid-column: 1 / -1; }
.editor-card label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.editor-card input,
.editor-card select,
.editor-card textarea {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 10px 12px;
font-size: 0.88rem;
outline: none;
}
.editor-card textarea {
min-height: 110px;
resize: vertical;
}
.editor-card input:focus,
.editor-card select:focus,
.editor-card textarea:focus {
border-color: var(--accent);
}
.editor-meta {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 4px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
color: var(--text);
margin-top: 6px;
}
.checkbox-row input { width: auto; }
.editor-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
}
.btn-secondary:hover {
border-color: var(--text-muted);
color: var(--text);
}
/* Toast */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
@@ -219,8 +140,6 @@
@media (max-width: 600px) {
.option-row { flex-wrap: wrap; }
.btn-row { width: 100%; justify-content: flex-end; }
.editor-grid { grid-template-columns: 1fr; }
.editor-actions { justify-content: stretch; }
}
</style>
</head>
@@ -266,44 +185,6 @@
<button class="btn-toggle open" id="pollsBtn" onclick="togglePolls()">Close Polls</button>
</div>
<div class="editor-card hidden" id="editorCard">
<div class="section-header">
<span class="section-title">Edit Option</span>
<span class="badge" id="editorStatus">Draft</span>
</div>
<div class="editor-meta" id="editorMeta">Select an option to edit.</div>
<div class="editor-grid">
<label>
Name
<input id="editorName" type="text" maxlength="80" />
</label>
<label>
Category
<select id="editorCategory"></select>
</label>
<label class="full">
Description
<input id="editorDesc" type="text" maxlength="200" />
</label>
<label class="full">
Details
<textarea id="editorDetails" placeholder="One detail per line"></textarea>
</label>
<label class="full">
Website URL
<input id="editorUrl" type="url" />
</label>
</div>
<label class="checkbox-row">
<input id="editorApproved" type="checkbox" />
Approve this option
</label>
<div class="editor-actions">
<button class="btn btn-secondary" onclick="closeEditor()">Cancel</button>
<button class="btn btn-approve" onclick="saveEditor()">Save changes</button>
</div>
</div>
<!-- Pending Options -->
<div class="section">
<div class="section-header">
@@ -330,7 +211,6 @@ const API = '';
const PWD_KEY = 'cabo_admin_pwd';
const CORRECT_PWD = 'cabo2026';
let allData = null;
let editingOptionId = null;
function toast(msg, type='') {
const t = document.getElementById('toast');
@@ -360,7 +240,6 @@ async function loadData() {
fetch(API + '/api/options?includeUnapproved=true').then(r => r.json()),
]);
allData = { categories: cats, options: opts };
renderEditorCategoryOptions();
renderStats();
renderPending();
renderAll();
@@ -370,14 +249,6 @@ async function loadData() {
}
}
function renderEditorCategoryOptions() {
const select = document.getElementById('editorCategory');
select.innerHTML = (allData?.categories || [])
.filter(category => category.id !== 'results' && category.id !== 'map')
.map(category => `<option value="${category.id}">${category.name}</option>`)
.join('');
}
function renderStats() {
const voters = new Set();
let totalVotes = 0;
@@ -407,7 +278,6 @@ function renderPending() {
<div class="name">${o.name}</div>
<div class="meta">by ${o.addedBy || 'unknown'}</div>
<div class="btn-row">
<button class="btn btn-edit" onclick="editOption('${o.id}')">✎ Edit</button>
<button class="btn btn-approve" onclick="approve('${o.id}')">✓ Approve</button>
<button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button>
</div>
@@ -425,56 +295,12 @@ function renderAll() {
<div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div>
<div class="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div>
<div class="btn-row">
<button class="btn btn-edit" onclick="editOption('${o.id}')">✎ Edit</button>
<button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button>
</div>
</div>
`).join('');
}
function editOption(id) {
const current = allData.options.find(o => o.id === id);
if (!current) return;
editingOptionId = id;
document.getElementById('editorCard').classList.remove('hidden');
document.getElementById('editorName').value = current.name || '';
document.getElementById('editorCategory').value = current.categoryId || '';
document.getElementById('editorDesc').value = current.desc || '';
document.getElementById('editorDetails').value = Array.isArray(current.details) ? current.details.join('\n') : '';
document.getElementById('editorUrl').value = current.url || '';
document.getElementById('editorApproved').checked = Boolean(current.approved);
document.getElementById('editorStatus').textContent = current.approved ? 'Approved' : 'Pending';
document.getElementById('editorMeta').textContent = `${current.name}${current.categoryId} • added by ${current.addedBy || 'unknown'}`;
document.getElementById('editorName').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function closeEditor() {
editingOptionId = null;
document.getElementById('editorCard').classList.add('hidden');
}
async function saveEditor() {
if (!editingOptionId) return;
try {
await fetch(API + '/api/options/' + editingOptionId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('editorName').value.trim(),
desc: document.getElementById('editorDesc').value.trim(),
details: document.getElementById('editorDetails').value,
url: document.getElementById('editorUrl').value.trim(),
categoryId: document.getElementById('editorCategory').value,
approved: document.getElementById('editorApproved').checked,
})
});
toast('Option updated', 'success');
closeEditor();
await loadData();
} catch(e) { toast('Failed to update option', 'error'); }
}
async function togglePolls() {
try {
// Get current state first

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
const SEED_VERSION = 7;
const PRICE_UPDATED_AT = '2026-05-01';
const SEED_VERSION = 2;
const PRICE_UPDATED_AT = '2026-04-29';
const CATEGORY_META = {
hotel: { emoji: '🏨', color: '#3b82f6' },
flight: { emoji: '✈️', color: '#38bdf8' },
golf: { emoji: '⛳', color: '#22c55e' },
nightlife: { emoji: '🎧', color: '#a855f7' },
excursion: { emoji: '🚤', color: '#06b6d4' },
@@ -12,23 +11,6 @@ const CATEGORY_META = {
results: { emoji: '🏆', color: '#facc15' },
};
const GUEST_ROSTER = [
{ name: 'Jon', last4: '7506', role: 'groom' },
{ name: 'Toph', last4: '8116', role: 'best-man' },
{ name: 'Hans', last4: '6681', role: 'guest' },
{ name: 'Janno', last4: '2809', role: 'guest' },
{ name: 'JT', last4: '3286', role: 'guest' },
{ name: 'Cordero', last4: '0379', role: 'guest' },
{ name: 'Lester', last4: '8014', role: 'guest' },
{ name: 'Nick', last4: '6044', role: 'guest' },
{ name: 'David', last4: '5993', role: 'guest' },
{ name: 'Poalo', last4: '9922', role: 'guest' },
{ name: 'Justin', last4: '2329', role: 'guest' },
{ name: 'Ben Stewart', last4: '1957', role: 'guest' },
{ name: 'Joseph', last4: '4976', role: 'guest' },
{ name: 'Francis', last4: '4934', role: 'guest' },
];
const BUDGET_SCENARIOS = [
{
id: 'budget-8',
@@ -176,12 +158,6 @@ const BUDGET_SCENARIOS = [
},
];
function buildOptionImageUrl(option) {
if (option.imageUrl) return option.imageUrl;
const primaryUrl = option.links?.[0]?.url || option.url || option.bookingUrl || '';
return primaryUrl ? `/api/preview-image?url=${encodeURIComponent(primaryUrl)}` : null;
}
function createOption(option) {
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
const primaryUrl = option.links?.[0]?.url || option.url || null;
@@ -193,13 +169,9 @@ function createOption(option) {
links: [],
categoryColor,
url: primaryUrl,
bookingUrl: option.bookingUrl || primaryUrl,
imageUrl: buildOptionImageUrl(option),
...option,
categoryColor,
url: primaryUrl,
bookingUrl: option.bookingUrl || primaryUrl,
imageUrl: buildOptionImageUrl(option),
};
}
@@ -209,7 +181,6 @@ function buildSeedData() {
priceUpdatedAt: PRICE_UPDATED_AT,
categories: [
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
{ id: 'flight', name: 'Flights', emoji: '✈️' },
{ id: 'golf', name: 'Golf', emoji: '⛳' },
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
@@ -217,7 +188,6 @@ function buildSeedData() {
{ id: 'budget', name: 'Budget', emoji: '💸' },
{ id: 'results', name: 'Results', emoji: '🏆' },
],
guestRoster: GUEST_ROSTER,
budgetScenarios: BUDGET_SCENARIOS,
options: [
createOption({
@@ -228,7 +198,7 @@ function buildSeedData() {
desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.',
lat: 23.0639,
lng: -109.6991,
details: ['Costco package availability only', 'KAYAK no fresh rates', 'Walk to marina nightlife'],
details: ['KAYAK recent rooms $173-$551/night', 'Costco package', 'Walk to marina nightlife'],
links: [
{ label: 'Official', url: 'https://www.corazoncabo.com/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
@@ -243,12 +213,12 @@ function buildSeedData() {
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
lat: 23.0628,
lng: -109.6981,
details: ['Apple exact-date quote: $2,016 pp', 'Costco package: $1,678.99 pp', 'Adults-only'],
details: ['Apple Vacations from $942 pp / 3 nights', 'KAYAK from $393/night', 'Adults-only'],
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-02' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=3&mode=0&onsaleid=1398047&traveldate=2026-05-10' },
],
}),
createOption({
@@ -259,11 +229,11 @@ function buildSeedData() {
desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.',
lat: 23.0949,
lng: -109.7067,
details: ['Apple exact-date quote: $2,111 pp', 'KAYAK from $212/night', 'Golf-friendly'],
details: ['Apple Vacations from $859 pp / 3 nights', 'KAYAK from $209/night', 'Golf-friendly'],
links: [
{ label: 'Official', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Official', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=4&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=2027-02-02&vendorcode=APV' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=3&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=&vendorcode=APV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
],
}),
@@ -275,11 +245,11 @@ function buildSeedData() {
desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
lat: 23.0227,
lng: -109.7062,
details: ['KAYAK exact-date room rate: $335/night', 'Costco package: $2,005.80 pp', 'Adults-only'],
details: ['CheapCaribbean from $885 pp / 3 nights', '4-night examples from $1,108 pp', 'Adults-only'],
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-02&vendorcode=CCV' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=3&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=&vendorcode=CCV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
],
}),
@@ -297,193 +267,6 @@ function buildSeedData() {
{ label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
],
}),
createOption({
id: 'hotel-dreams-los-cabos',
seedKey: 'hotel-dreams-los-cabos',
categoryId: 'hotel',
name: 'Dreams Los Cabos Suites Golf Resort & Spa',
desc: 'Balanced all-inclusive option with the cleanest Apple and Costco pricing signal from today.',
details: ['Apple exact-date quote: $1,757 pp', 'Costco package: $1,447.80 pp', 'All-inclusive'],
links: [
{ label: 'Hyatt Inclusive', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/dreams/mexico/los-cabos-suites-golf-resort-spa/' },
],
}),
createOption({
id: 'hotel-zoetry-casa-del-mar',
seedKey: 'hotel-zoetry-casa-del-mar',
categoryId: 'hotel',
name: 'Zoetry Casa del Mar',
desc: 'Higher-end adults-only pick that sits in the luxe tier without going fully maxed out.',
details: ['Apple exact-date quote: $1,944 pp', 'Costco package: $1,717.42 pp', 'Adults-only'],
links: [
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/zoetry-casa-del-mar/zocdm' },
],
}),
createOption({
id: 'hotel-hyatt-ziva-los-cabos',
seedKey: 'hotel-hyatt-ziva-los-cabos',
categoryId: 'hotel',
name: 'Hyatt Ziva Los Cabos',
desc: 'Family-friendly luxury option that still works for a big group if the trip tilts more polished than rowdy.',
details: ['Apple exact-date quote: $2,178 pp', 'Beachfront', 'All-inclusive'],
links: [
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/hyatt-ziva-los-cabos/sjdif' },
],
}),
createOption({
id: 'hotel-riu-palace-cabo-san-lucas',
seedKey: 'hotel-riu-palace-cabo-san-lucas',
categoryId: 'hotel',
name: 'Riu Palace Cabo San Lucas',
desc: 'Value-forward all-inclusive with a more party-friendly profile than the luxury adults-only resorts.',
details: ['Apple exact-date quote: $1,529 pp', 'Party-friendly all-inclusive', 'Value pick'],
links: [
{ label: 'RIU', url: 'https://www.riu.com/en/hotel/mexico/los-cabos/hotel-riu-palace-cabo-san-lucas/' },
],
}),
createOption({
id: 'hotel-riu-palace-baja-california',
seedKey: 'hotel-riu-palace-baja-california',
categoryId: 'hotel',
name: 'Riu Palace Baja California',
desc: 'Adults-only RIU choice with a cleaner energy than Cabo San Lucas while staying in the value band.',
details: ['Apple exact-date quote: $1,597 pp', 'Adults-only', 'RIU all-inclusive'],
links: [
{ label: 'RIU', url: 'https://www.riu.com/en/hotel/mexico/los-cabos/hotel-riu-palace-baja-california/' },
],
}),
createOption({
id: 'hotel-me-cabo-by-melia',
seedKey: 'hotel-me-cabo-by-melia',
categoryId: 'hotel',
name: 'ME Cabo by Meliá',
desc: 'Beach-club leaning stay for the group that wants energy and location over quiet luxury.',
details: ['Apple exact-date quote: $1,533 pp', 'Beach club energy', 'Medano Beach'],
links: [
{ label: 'ME Cabo', url: 'https://www.hotelmecabo.com/' },
],
}),
createOption({
id: 'hotel-paradisus-los-cabos',
seedKey: 'hotel-paradisus-los-cabos',
categoryId: 'hotel',
name: 'Paradisus Los Cabos',
desc: 'Upscale all-inclusive with strong amenities and a better balance than the ultra-luxe splurge properties.',
details: ['Apple exact-date quote: $1,722 pp', 'Spa-forward', 'Adults-friendly luxury'],
links: [
{ label: 'Paradisus', url: 'https://www.paradisusloscabosresort.com/' },
],
}),
createOption({
id: 'hotel-hard-rock-los-cabos',
seedKey: 'hotel-hard-rock-los-cabos',
categoryId: 'hotel',
name: 'Hard Rock Hotel Los Cabos',
desc: 'The loudest splurge option from todays Apple search, with the highest quoted price on the list.',
details: ['Apple exact-date quote: $3,343 pp', 'Premium splurge', 'High-energy all-inclusive'],
links: [
{ label: 'Hard Rock', url: 'https://hotel.hardrock.com/los-cabos' },
],
}),
createOption({
id: 'hotel-solmar-resort',
seedKey: 'hotel-solmar-resort',
categoryId: 'hotel',
name: 'Solmar Resort',
desc: 'Low-cost KAYAK option if the group wants to keep the room line item very lean.',
details: ['KAYAK exact-date quote: $185/night', 'Budget-friendly', 'Downtown-adjacent'],
links: [
{ label: 'Solmar', url: 'https://www.solmar.com/en/hotels/cabo-san-lucas/solmar-resort/' },
],
}),
createOption({
id: 'hotel-tesoro-los-cabos',
seedKey: 'hotel-tesoro-los-cabos',
categoryId: 'hotel',
name: 'Tesoro Los Cabos',
desc: 'Marina-side value stay that landed in the middle of the KAYAK result set today.',
details: ['KAYAK exact-date quote: $250/night', 'Marina access', 'Low-mid budget'],
links: [
{ label: 'Tesoro', url: 'https://tesoroloscabos.com/' },
],
}),
createOption({
id: 'hotel-grand-solmar-lands-end',
seedKey: 'hotel-grand-solmar-lands-end',
categoryId: 'hotel',
name: "Grand Solmar Land's End Resort & Spa",
desc: 'Luxury Pacific-side resort with a stronger price tag than the value stays but below the ultra-splurge properties.',
details: ['KAYAK exact-date quote: $712/night', 'Luxury', 'Pacific-side'],
links: [
{ label: 'Grand Solmar', url: 'https://grandsolmarresort.solmar.com/' },
],
}),
createOption({
id: 'hotel-comfort-inn-suites-los-cabos',
seedKey: 'hotel-comfort-inn-suites-los-cabos',
categoryId: 'hotel',
name: 'Comfort Inn & Suites Los Cabos',
desc: 'Bare-bones KAYAK option if the group wants a practical bed-and-shower stay.',
details: ['KAYAK exact-date quote: $129/night', 'Budget stay', 'Practical'],
links: [
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Comfort-Inn-Suites-Los-Cabos.1071781145.ksp' },
],
}),
createOption({
id: 'hotel-capital-o-hotel-dos-mares',
seedKey: 'hotel-capital-o-hotel-dos-mares',
categoryId: 'hotel',
name: 'Capital O Hotel Dos Mares, Cabo San Lucas',
desc: 'Lowest visible KAYAK price of the day, useful only if the group is aggressively minimizing room cost.',
details: ['KAYAK exact-date quote: $48/night', 'Lowest visible price', 'Budget'],
links: [
{ label: 'OYO', url: 'https://www.oyorooms.com/mx/92226/' },
],
}),
createOption({
id: 'hotel-villa-del-palmar-beach-resort-cabo-san-lucas',
seedKey: 'hotel-villa-del-palmar-beach-resort-cabo-san-lucas',
categoryId: 'hotel',
name: 'Villa del Palmar Beach Resort Cabo San Lucas',
desc: 'Broad-appeal beach resort with a middle-of-the-road KAYAK room price today.',
details: ['KAYAK exact-date quote: $460/night', 'Beach resort', 'Family-friendly'],
links: [
{ label: 'Villa del Palmar', url: 'https://cabo.villadelpalmar.com/' },
],
}),
createOption({
id: 'hotel-esperanza-auberge-collection',
seedKey: 'hotel-esperanza-auberge-collection',
categoryId: 'hotel',
name: 'Esperanza, Auberge Collection',
desc: 'Top-end KAYAK splurge result from today, priced well above the other options in the set.',
details: ['KAYAK exact-date quote: $3,243/night', 'Luxury splurge', 'Auberge Collection'],
links: [
{ label: 'Auberge', url: 'https://aubergeresorts.com/esperanza/' },
],
}),
createOption({
id: 'flight-ont-sjd-kayak-cheapest',
seedKey: 'flight-ont-sjd-kayak-cheapest',
categoryId: 'flight',
name: 'ONT to SJD on KAYAK - Cheapest',
desc: 'Lowest exact-date round-trip flight quote from Ontario to San Jose del Cabo for the target trip window.',
details: ['$402 RT pp', 'Volaris, 1 stop', 'Best budget-flight anchor'],
links: [
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
],
}),
createOption({
id: 'flight-ont-sjd-kayak-best',
seedKey: 'flight-ont-sjd-kayak-best',
categoryId: 'flight',
name: 'ONT to SJD on KAYAK - Best',
desc: 'Higher-comfort exact-date round-trip flight quote for the target trip window.',
details: ['$605 RT pp', 'American, 1 stop', 'Best-value comfort anchor'],
links: [
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
],
}),
createOption({
id: 'golf-palmilla',
seedKey: 'golf-palmilla',
@@ -674,7 +457,7 @@ function buildSeedData() {
lng: -109.7067,
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
links: [
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
{ label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
],
@@ -729,7 +512,7 @@ function buildSeedData() {
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.',
details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
links: [
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
],
}),
@@ -788,7 +571,6 @@ function mergeSeedData(existing = {}) {
priceUpdatedAt: seed.priceUpdatedAt,
categories: [...seed.categories, ...preservedCustomCategories],
budgetScenarios: seed.budgetScenarios,
guestRoster: seed.guestRoster,
options: [...mergedSeedOptions, ...preservedCustomOptions],
voters: Array.isArray(existing.voters) ? existing.voters : [],
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,

916
server.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./client/index.html', './client/src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
ink: '#0b0d14',
panel: '#13161f',
panel2: '#1a1e2a',
line: '#252a38',
aqua: '#00d4ff',
gold: '#fbbf24',
},
boxShadow: {
glow: '0 18px 60px rgba(0, 212, 255, 0.14)',
},
},
},
plugins: [],
}