Compare commits

..

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
17 changed files with 184 additions and 5193 deletions

View File

@@ -5,7 +5,7 @@ Real-time group voting for the bachelor party — hotels, golf, nightlife, excur
## Quick Start ## Quick Start
```bash ```bash
cd cabo-voting-app cd voting_app
npm install npm install
node server.js node server.js
# → http://localhost:3001 # → http://localhost:3001
@@ -14,13 +14,7 @@ node server.js
## Features ## Features
- **Real-time WebSocket voting** — all clients update instantly - **Real-time WebSocket voting** — all clients update instantly
- **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries - **5 categories** — Hotels, Golf, Nightlife, Excursions, 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
- **Add suggestions** — anyone can propose new venues - **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live - **Admin approval** — pending options require approval before going live
- **Responsive** — works on desktop and mobile - **Responsive** — works on desktop and mobile
@@ -28,24 +22,10 @@ node server.js
## Data ## Data
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel. 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 ## Deployment
The app can run directly under `systemd` with: Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via `cabo-voting.yml`.
```bash
PORT=3021 DATA_DIR=/srv/state/cabo-voting node server.js
```
Traefik can then reverse proxy to the chosen host port.
See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap. See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap.

View File

@@ -1,256 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import Header from './components/Header'
import NameModal from './components/NameModal'
import TabBar from './components/TabBar'
import OptionList from './components/OptionList'
import ResultsTab from './components/ResultsTab'
import AddOption from './components/AddOption'
import Toast from './components/Toast'
import WsOverlay from './components/WsOverlay'
import OptionModal from './components/OptionModal'
import YourVotesModal from './components/YourVotesModal'
import SocialToast from './components/SocialToast'
import MapTab from './components/MapTab'
import BudgetTab from './components/BudgetTab'
import { useWebSocket } from './hooks/useWebSocket'
import { useVoterSession } from './hooks/useVoterSession'
import { useSound } from './hooks/useSound'
import './App.css'
const SOUND_KEY = 'cabo-voting-sound'
const THEME_KEY = 'cabo-voting-theme'
export default function App() {
const [categories, setCategories] = useState([])
const [options, setOptions] = useState([])
const [pollsOpen, setPollsOpen] = useState(true)
const [totalVoters, setTotalVoters] = useState(0)
const [activeTab, setActiveTab] = useState('hotel')
const [toast, setToast] = useState(null)
const [selectedOption, setSelectedOption] = useState(null) // detail modal
const [yourVotesOpen, setYourVotesOpen] = useState(false) // your votes modal
const [socialToast, setSocialToast] = useState(null) // floating vote toast
const [soundEnabled, setSoundEnabled] = useState(() => localStorage.getItem(SOUND_KEY) !== 'off')
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || 'dark')
const [onlineCount, setOnlineCount] = useState(0)
const [pollDeadline, setPollDeadline] = useState(null)
const socialToastTimer = useRef(null)
const { voterName, setVoterName, clearVoter } = useVoterSession()
const { playVoteSound, playRemoveSound } = useSound()
const pollsExpired = pollDeadline && Date.now() > new Date(pollDeadline).getTime()
const { wsRef, wsConnected, reconnect } = useWebSocket({
setCategories, setOptions, setPollsOpen, setTotalVoters,
setOnlineCount, setPollDeadline,
})
// Apply theme to body
useEffect(() => {
document.body.dataset.theme = theme
localStorage.setItem(THEME_KEY, theme)
}, [theme])
const showToast = useCallback((msg, type = '') => {
setToast({ msg, type })
setTimeout(() => setToast(null), 3000)
}, [])
const handleVote = useCallback((option, removed = false) => {
if (!voterName) return
if (!pollsOpen || pollsExpired) return
const opt = options.find(o => o.id === option.id)
if (!opt) return
const alreadyVoted = opt.votes?.some(v => v.name === voterName)
// Optimistic update
setOptions(prev => prev.map(o => {
if (o.id !== option.id) return o
if (removed || alreadyVoted) {
return { ...o, votes: o.votes.filter(v => v.name !== voterName) }
} else {
return { ...o, votes: [...(o.votes || []), { name: voterName, timestamp: Date.now() }] }
}
}))
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'vote', optionId: option.id, voterName, remove: removed || alreadyVoted }))
}
if (removed || alreadyVoted) {
playRemoveSound()
showToast(`Removed vote for ${opt.name}`)
} else {
playVoteSound()
showToast(`Voted for ${opt.name}!`)
}
// Social toast for others (shows voter name + option name)
setSocialToast({ voterName, optionName: opt.name, categoryId: opt.categoryId })
clearTimeout(socialToastTimer.current)
socialToastTimer.current = setTimeout(() => setSocialToast(null), 3000)
}, [voterName, options, pollsOpen, pollsExpired, wsRef, playVoteSound, playRemoveSound, showToast])
const handleRemoveVote = useCallback((optionId) => {
const opt = options.find(o => o.id === optionId)
if (opt) handleVote(opt, true)
}, [options, handleVote])
// Check URL params on load
useEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('view') === 'results') setActiveTab('results')
const optionId = params.get('option')
if (optionId) {
// Wait for options to load, then open modal
const check = () => {
const opt = options.find(o => o.id === optionId)
if (opt) { setSelectedOption(opt); } else if (options.length > 0) { setSelectedOption(null) }
}
check()
}
}, []) // eslint-disable-line
const handleAddSubmit = useCallback((data) => {
if (!voterName) { showToast('Enter your name first', 'error'); return }
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'add_option', ...data, voterName }))
}
document.getElementById('add-name').value = ''
document.getElementById('add-desc').value = ''
document.getElementById('add-url').value = ''
showToast(`Submitted "${data.name}" for approval!`, 'success')
}, [voterName, wsRef, showToast])
const toggleSound = useCallback(() => {
setSoundEnabled(prev => {
const next = !prev
localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
return next
})
}, [])
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
}, [])
const votingCats = categories.filter(c => c.id !== 'results')
const optionCounts = votingCats.reduce((acc, cat) => {
acc[cat.id] = options.filter(o => o.categoryId === cat.id).length
return acc
}, {})
// "Your Votes" — all options this voter voted for
const yourVotes = options.filter(o => o.votes?.some(v => v.name === voterName))
return (
<>
<a className="skip-link" href="#main">Skip to content</a>
<Header
voterName={voterName}
pollsOpen={pollsOpen}
totalVoters={totalVoters}
wsConnected={wsConnected}
onChangeName={clearVoter}
soundEnabled={soundEnabled}
onToggleSound={toggleSound}
theme={theme}
onToggleTheme={toggleTheme}
onlineCount={onlineCount}
pollDeadline={pollDeadline}
pollsExpired={pollsExpired}
/>
{!voterName && <NameModal onSubmit={setVoterName} />}
<TabBar
categories={categories}
activeTab={activeTab}
onTab={setActiveTab}
optionCounts={optionCounts}
onYourVotes={() => setYourVotesOpen(true)}
voterName={voterName}
yourVotesCount={yourVotes.length}
/>
<main id="main">
{activeTab === 'results' ? (
<ResultsTab
categories={categories}
options={options}
pollsOpen={pollsOpen}
totalVoters={totalVoters}
/>
) : activeTab === 'map' ? (
<MapTab
options={options}
categories={categories}
onSelectOption={setSelectedOption}
/>
) : activeTab === 'budget' ? (
<BudgetTab
options={options}
categories={categories}
/>
) : (
<OptionList
options={options.filter(o => o.categoryId === activeTab && o.approved)}
voterName={voterName}
pollsOpen={pollsOpen}
pollsExpired={pollsExpired}
onVote={handleVote}
onCardClick={setSelectedOption}
categoryId={activeTab}
/>
)}
{activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && (
<AddOption
categories={categories}
voterName={voterName}
onSubmit={handleAddSubmit}
onNeedsName={() => showToast('Enter your name first', 'error')}
/>
)}
</main>
{/* Option detail modal */}
{selectedOption && (
<OptionModal
option={selectedOption}
voterName={voterName}
onClose={() => setSelectedOption(null)}
onVote={handleVote}
categories={categories}
/>
)}
{/* Your votes modal */}
{yourVotesOpen && (
<YourVotesModal
votes={yourVotes}
voterName={voterName}
onClose={() => setYourVotesOpen(false)}
onRemoveVote={handleRemoveVote}
onViewOption={setSelectedOption}
categories={categories}
/>
)}
<WsOverlay connected={wsConnected} onReconnect={reconnect} />
{toast && <Toast msg={toast.msg} type={toast.type} />}
{socialToast && (
<SocialToast
voterName={socialToast.voterName}
optionName={socialToast.optionName}
categoryId={socialToast.categoryId}
/>
)}
</>
)
}

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()
}

908
package-lock.json generated
View File

@@ -1,908 +0,0 @@
{
"name": "cabo-voting-app",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cabo-voting-app",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"uuid": "^11.1.0",
"ws": "^8.18.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -1,19 +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": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"uuid": "^11.1.0",
"ws": "^8.18.2"
}
}

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,46 +0,0 @@
# Cabo Price Watch Report
- Date checked: 2026-05-04T19:26:07Z
- Status: live pricing refresh completed
## Biggest Changes
- Flights moved down versus the prior ONT seed anchor. KAYAKs cheapest visible LAX -> SJD fare is $274 RT pp, with the best nonstop at $377 RT pp.
- Golf is mixed to higher. Palmilla is visible at $140, Cabo Real is visible at $160 on a marketplace result and $190 on the official rate page, and Quivira is visible at $395+.
- Excursions now have a cleaner live floor. Viator whale watching starts at $60, with higher-finish options at $79.65, $99, $80, and $266.23.
- Nightlife/day-club pricing is now visible. Casa Doradas day pass is $110, OMNIA shows MX$1,000+, and Cabo Party Fun shows a $1,080 group pool-party option.
- Apple Vacations package cards are visible again, but they are 3-night package quotes, not exact Feb 3-7, 2027 date matches.
## Live Pricing Snapshot
| Category | Current live anchors |
| --- | --- |
| Flights | Viva Zero $274 RT pp, Delta Main Basic $377 RT pp, Alaska Saver $408 RT pp, American Main Cabin $483 RT pp |
| Standalone hotels | Hyatt Place Los Cabos $2,154 total, Hilton Vacation Club Cabo Azul $6,969 total, Le Blanc $25,451 total, Casa Maat $28,667 total |
| Package cards | Riu Santa Fe $404 pp, Dreams $568 pp, Sandos Finisterra $649 pp, Grand Fiesta Americana $805 pp, Hyatt Ziva $788 pp, Paradisus $854 pp, Grand Velas $2,284 pp |
| Golf | Palmilla from $140, Cabo Real from $160 or $190, Cabo del Sol from $290, Quivira from $395 |
| Nightlife/day clubs | Casa Dorada day pass $110, OMNIA MX$1,000+, Cabo Party Fun $1,080 for 1-8 people |
| Excursions | Whale watching from $60, glass-bottom whale tour $79.65, humpback whale tour $99, open-ocean safari $266.23 |
## New Options Worth Adding
- Hyatt Place Los Cabos as the current low-cost exact-date hotel anchor.
- Hilton Vacation Club Cabo Azul as a midrange standalone resort anchor.
- Le Blanc Spa Resort Los Cabos and Casa Maat at JW Marriott as splurge hotel anchors.
- Casa Dorada day pass and Cabo Party Fun pool parties as new nightlife/day-club options.
- Apple Vacations package cards for Dreams, Riu Santa Fe, Grand Fiesta Americana, Sandos Finisterra, Hyatt Ziva, Paradisus, and Grand Velas.
## Caveats
- Apple Vacations package cards are bundle-friendly, but the visible prices are 3-night package quotes and are not exact Feb 3-7, 2027 matches.
- Booking.com hotel prices are exact-date stay totals for Feb 3-7, 2027 and are the best standalone hotel anchors in this run.
- I did not hit any sold-out state in the live results.
- I did not need any login-required source in this pass.
## Impact On Recommendations
- Budget: about $737.86 pp, or $5,902.86 for 8 people.
- Balanced: about $1,298.79 pp, or $12,987.86 for 10 people.
- Splurge: about $3,142.16 pp, or $37,705.90 for 12 people.
- The current budget and balanced tracks are still driven more by flight and hotel than by activities.
- The splurge track is dominated by the Le Blanc hotel rate and Quivira golf.

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-03",
"checkOut": "2027-02-07",
"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-reject { background: var(--red); color: #fff; }
.btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); } .btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
.btn-delete:hover { border-color: var(--red); color: var(--red); } .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; } .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 */
.toast { .toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px); position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
@@ -219,8 +140,6 @@
@media (max-width: 600px) { @media (max-width: 600px) {
.option-row { flex-wrap: wrap; } .option-row { flex-wrap: wrap; }
.btn-row { width: 100%; justify-content: flex-end; } .btn-row { width: 100%; justify-content: flex-end; }
.editor-grid { grid-template-columns: 1fr; }
.editor-actions { justify-content: stretch; }
} }
</style> </style>
</head> </head>
@@ -266,44 +185,6 @@
<button class="btn-toggle open" id="pollsBtn" onclick="togglePolls()">Close Polls</button> <button class="btn-toggle open" id="pollsBtn" onclick="togglePolls()">Close Polls</button>
</div> </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 --> <!-- Pending Options -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
@@ -330,7 +211,6 @@ const API = '';
const PWD_KEY = 'cabo_admin_pwd'; const PWD_KEY = 'cabo_admin_pwd';
const CORRECT_PWD = 'cabo2026'; const CORRECT_PWD = 'cabo2026';
let allData = null; let allData = null;
let editingOptionId = null;
function toast(msg, type='') { function toast(msg, type='') {
const t = document.getElementById('toast'); const t = document.getElementById('toast');
@@ -360,7 +240,6 @@ async function loadData() {
fetch(API + '/api/options?includeUnapproved=true').then(r => r.json()), fetch(API + '/api/options?includeUnapproved=true').then(r => r.json()),
]); ]);
allData = { categories: cats, options: opts }; allData = { categories: cats, options: opts };
renderEditorCategoryOptions();
renderStats(); renderStats();
renderPending(); renderPending();
renderAll(); 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() { function renderStats() {
const voters = new Set(); const voters = new Set();
let totalVotes = 0; let totalVotes = 0;
@@ -407,7 +278,6 @@ function renderPending() {
<div class="name">${o.name}</div> <div class="name">${o.name}</div>
<div class="meta">by ${o.addedBy || 'unknown'}</div> <div class="meta">by ${o.addedBy || 'unknown'}</div>
<div class="btn-row"> <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-approve" onclick="approve('${o.id}')">✓ Approve</button>
<button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button> <button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button>
</div> </div>
@@ -425,56 +295,12 @@ function renderAll() {
<div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div> <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="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div>
<div class="btn-row"> <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> <button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button>
</div> </div>
</div> </div>
`).join(''); `).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() { async function togglePolls() {
try { try {
// Get current state first // 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 SEED_VERSION = 2;
const PRICE_UPDATED_AT = '2026-05-01'; const PRICE_UPDATED_AT = '2026-04-29';
const CATEGORY_META = { const CATEGORY_META = {
hotel: { emoji: '🏨', color: '#3b82f6' }, hotel: { emoji: '🏨', color: '#3b82f6' },
flight: { emoji: '✈️', color: '#38bdf8' },
golf: { emoji: '⛳', color: '#22c55e' }, golf: { emoji: '⛳', color: '#22c55e' },
nightlife: { emoji: '🎧', color: '#a855f7' }, nightlife: { emoji: '🎧', color: '#a855f7' },
excursion: { emoji: '🚤', color: '#06b6d4' }, excursion: { emoji: '🚤', color: '#06b6d4' },
@@ -12,23 +11,6 @@ const CATEGORY_META = {
results: { emoji: '🏆', color: '#facc15' }, 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 = [ const BUDGET_SCENARIOS = [
{ {
id: 'budget-8', id: 'budget-8',
@@ -199,7 +181,6 @@ function buildSeedData() {
priceUpdatedAt: PRICE_UPDATED_AT, priceUpdatedAt: PRICE_UPDATED_AT,
categories: [ categories: [
{ id: 'hotel', name: 'Hotels', emoji: '🏨' }, { id: 'hotel', name: 'Hotels', emoji: '🏨' },
{ id: 'flight', name: 'Flights', emoji: '✈️' },
{ id: 'golf', name: 'Golf', emoji: '⛳' }, { id: 'golf', name: 'Golf', emoji: '⛳' },
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, { id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
{ id: 'excursion', name: 'Excursions', emoji: '🚤' }, { id: 'excursion', name: 'Excursions', emoji: '🚤' },
@@ -207,7 +188,6 @@ function buildSeedData() {
{ id: 'budget', name: 'Budget', emoji: '💸' }, { id: 'budget', name: 'Budget', emoji: '💸' },
{ id: 'results', name: 'Results', emoji: '🏆' }, { id: 'results', name: 'Results', emoji: '🏆' },
], ],
guestRoster: GUEST_ROSTER,
budgetScenarios: BUDGET_SCENARIOS, budgetScenarios: BUDGET_SCENARIOS,
options: [ options: [
createOption({ createOption({
@@ -218,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.', 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, lat: 23.0639,
lng: -109.6991, 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: [ links: [
{ label: 'Official', url: 'https://www.corazoncabo.com/' }, { label: 'Official', url: 'https://www.corazoncabo.com/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' }, { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
@@ -233,12 +213,12 @@ function buildSeedData() {
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.', desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
lat: 23.0628, lat: 23.0628,
lng: -109.6981, 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: [ links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' }, { 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: '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: '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-03' }, { 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({ createOption({
@@ -249,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.', 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, lat: 23.0949,
lng: -109.7067, 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: [ 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: '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-03&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' }, { label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
], ],
}), }),
@@ -265,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.', desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
lat: 23.0227, lat: 23.0227,
lng: -109.7062, 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: [ links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' }, { 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: '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-03&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' }, { label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
], ],
}), }),
@@ -287,193 +267,6 @@ function buildSeedData() {
{ label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' }, { 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-03/2027-02-07?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-03/2027-02-07?sort=bestflight_a' },
],
}),
createOption({ createOption({
id: 'golf-palmilla', id: 'golf-palmilla',
seedKey: 'golf-palmilla', seedKey: 'golf-palmilla',
@@ -664,7 +457,7 @@ function buildSeedData() {
lng: -109.7067, lng: -109.7067,
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'], details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
links: [ 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: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' }, { label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
], ],
@@ -719,7 +512,7 @@ function buildSeedData() {
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.', 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'], details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
links: [ 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' }, { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
], ],
}), }),
@@ -778,7 +571,6 @@ function mergeSeedData(existing = {}) {
priceUpdatedAt: seed.priceUpdatedAt, priceUpdatedAt: seed.priceUpdatedAt,
categories: [...seed.categories, ...preservedCustomCategories], categories: [...seed.categories, ...preservedCustomCategories],
budgetScenarios: seed.budgetScenarios, budgetScenarios: seed.budgetScenarios,
guestRoster: seed.guestRoster,
options: [...mergedSeedOptions, ...preservedCustomOptions], options: [...mergedSeedOptions, ...preservedCustomOptions],
voters: Array.isArray(existing.voters) ? existing.voters : [], voters: Array.isArray(existing.voters) ? existing.voters : [],
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true, pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,

817
server.js
View File

@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const cors = require('cors'); const cors = require('cors');
const crypto = require('crypto');
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -12,19 +11,8 @@ const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
const DEFAULT_DATA_DIR = path.join(__dirname, 'data'); const DATA_DIR = path.join(__dirname, 'data');
const DATA_DIR = process.env.DATA_DIR const DATA_FILE = path.join(DATA_DIR, 'votes.json');
? path.resolve(process.env.DATA_DIR)
: DEFAULT_DATA_DIR;
const DATA_FILE = process.env.DATA_FILE
? path.resolve(process.env.DATA_FILE)
: path.join(DATA_DIR, 'votes.json');
const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.jsonl');
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
? path.resolve(process.env.PRICE_HISTORY_FILE)
: DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
@@ -58,613 +46,6 @@ function saveData(nextData) {
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2)); fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
} }
function normalizeKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function normalizeGuestPin(value) {
return String(value || '').replace(/\D/g, '').slice(-4);
}
function normalizeGuestName(value) {
return normalizeKey(value).replace(/-/g, ' ');
}
function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source';
}
function formatCurrencyValue(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value);
}
function normalizeBookingType(value) {
const normalized = normalizeKey(value || '');
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
if (normalized.includes('package') || normalized.includes('bundle')) return 'package';
if (normalized.includes('calculated') || normalized.includes('derived')) return 'calculated';
return 'standalone';
}
function inferBookingType(point, defaults = {}) {
if (point.bookingType || point.booking_type || point.productType || defaults.bookingType) {
return point.bookingType || point.booking_type || point.productType || defaults.bookingType;
}
const haystack = [
point.source,
point.sourceLabel,
point.vendor,
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
].filter(Boolean).join(' ').toLowerCase();
if (haystack.includes('costco') || haystack.includes('apple vacation') || haystack.includes('cheapcaribbean')) {
return 'package';
}
if (haystack.includes('package') || haystack.includes('flight+hotel') || haystack.includes('flight + hotel')) {
return 'package';
}
if (haystack.includes('automation calculation')) return 'calculated';
return 'standalone';
}
const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth';
function getGuestRoster() {
return Array.isArray(data.guestRoster) ? data.guestRoster : [];
}
function findGuestByNameAndPin(name, pin) {
const normalizedName = normalizeGuestName(name);
const normalizedPin = normalizeGuestPin(pin);
return getGuestRoster().find((guest) => (
normalizeGuestName(guest.name) === normalizedName
&& normalizeGuestPin(guest.last4) === normalizedPin
)) || null;
}
function signGuestToken(guest) {
const payload = {
name: guest.name,
last4: normalizeGuestPin(guest.last4),
role: guest.role || 'guest',
issuedAt: Date.now(),
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = crypto
.createHmac('sha256', GUEST_AUTH_SECRET)
.update(encodedPayload)
.digest('base64url');
return `${encodedPayload}.${signature}`;
}
function verifyGuestToken(token) {
if (typeof token !== 'string' || !token.includes('.')) return null;
const [encodedPayload, signature] = token.split('.');
if (!encodedPayload || !signature) return null;
const expectedSignature = crypto
.createHmac('sha256', GUEST_AUTH_SECRET)
.update(encodedPayload)
.digest('base64url');
const sigA = Buffer.from(signature);
const sigB = Buffer.from(expectedSignature);
if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null;
try {
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
const guest = findGuestByNameAndPin(payload.name, payload.last4);
if (!guest) return null;
return {
name: guest.name,
last4: normalizeGuestPin(guest.last4),
role: guest.role || 'guest',
};
} catch {
return null;
}
}
function readGuestAuthToken(req, body = {}) {
const bodyToken = body.authToken || body.guestToken || body.token;
if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim();
const headerToken = req.headers['x-guest-auth'];
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
const authorization = req.headers.authorization || '';
if (authorization.toLowerCase().startsWith('bearer ')) {
return authorization.slice(7).trim();
}
return null;
}
function requireGuestAuth(req, res, body = {}) {
const guest = verifyGuestToken(readGuestAuthToken(req, body));
if (!guest) {
res.status(401).json({ error: 'Guest authentication required' });
return null;
}
return guest;
}
const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta',
'costco-secrets': 'hotel-secrets',
'costco-corazon': 'hotel-corazon',
'costco-pacifica': 'hotel-pacifica',
'costco-dreams': 'hotel-dreams-los-cabos',
'costco-zoetry': 'hotel-zoetry-casa-del-mar',
'costco-hard-rock': 'hotel-hard-rock-los-cabos',
};
function toTextList(value) {
const items = Array.isArray(value) ? value : value == null ? [] : [value];
return [...new Set(items.flatMap((item) => {
if (Array.isArray(item)) {
return item;
}
if (item && typeof item === 'object') {
return [
item.label,
item.name,
item.text,
item.title,
item.value,
item.summary,
item.description,
].filter(Boolean);
}
return [item];
})
.map((item) => String(item).trim())
.filter(Boolean))];
}
function readJsonLines(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
function latestNonEmptyArray(runs, fieldNames) {
for (let index = runs.length - 1; index >= 0; index -= 1) {
for (const fieldName of fieldNames) {
if (Array.isArray(runs[index][fieldName]) && runs[index][fieldName].length) {
return runs[index][fieldName];
}
}
}
return null;
}
function getOptionHistoryKeys(option) {
const nameKey = normalizeKey(option.name);
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
const rawKeys = [
option.seedKey,
option.id,
option.priceKey,
option.optionKey,
option.slug,
categoryNameKey,
nameKey,
].filter(Boolean).map(normalizeKey);
return [...new Set(rawKeys.flatMap((key) => [key, HISTORY_KEY_ALIASES[key] || null].filter(Boolean)))];
}
function extractNumericPrice(point) {
const candidates = [
point.price,
point.value,
point.amount,
point.perPerson,
point.groupTotal,
];
for (const candidate of candidates) {
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
if (typeof candidate === 'string') {
const parsed = parseNumericPriceFromText(candidate);
if (Number.isFinite(parsed)) return parsed;
}
}
const textCandidates = [
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
point.note,
point.description,
].filter(Boolean);
for (const candidate of textCandidates) {
const parsed = parseNumericPriceFromText(candidate, point.priceBasis || point.price_basis || point.unit);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function parseNumericPriceFromText(value, priceBasis = '') {
if (typeof value !== 'string') return null;
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized || /\b(no|not)\s+(fresh\s+)?(price|rates?|available|counted|visible|captured)\b/i.test(normalized)) {
return null;
}
const matches = [...normalized.matchAll(/\$?\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?|[0-9]+(?:\.[0-9]{1,2})?)/g)]
.map((match) => ({
value: Number(match[1].replace(/,/g, '')),
index: match.index || 0,
}))
.filter((match) => Number.isFinite(match.value));
if (!matches.length) return null;
const basis = normalizeKey(priceBasis);
if (basis === 'totalpackage' || basis === 'pergroup') {
return matches.at(-1).value;
}
const travelerMatch = matches.find((match) => (
/per\s+(traveler|person|guest|adult|round|table|night)|pp|\/night/i.test(normalized.slice(match.index, match.index + 80))
));
if (travelerMatch) return travelerMatch.value;
return matches[0].value;
}
function inferPriceBasis(point, defaults = {}) {
if (point.priceBasis || point.price_basis || point.unit || defaults.priceBasis) {
return point.priceBasis || point.price_basis || point.unit || defaults.priceBasis;
}
const haystack = [
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
point.note,
point.description,
].filter(Boolean).join(' ').toLowerCase();
if (haystack.includes('/night') || haystack.includes('per night')) return 'perNight';
if (haystack.includes('per traveler')) return 'perTraveler';
if (haystack.includes('per person') || /\bpp\b/.test(haystack)) return 'perPerson';
if (haystack.includes('per round')) return 'perRound';
if (haystack.includes('per table')) return 'perTable';
if (haystack.includes('total') || haystack.includes('package')) return 'totalPackage';
const bookingType = normalizeBookingType(inferBookingType(point, defaults));
if (bookingType === 'package') return 'perTraveler';
if (bookingType === 'calculated') return 'perPerson';
return null;
}
function getTripNightCount() {
const checkInMs = Date.parse(`${TRIP_CHECK_IN}T00:00:00Z`);
const checkOutMs = Date.parse(`${TRIP_CHECK_OUT}T00:00:00Z`);
if (Number.isNaN(checkInMs) || Number.isNaN(checkOutMs) || checkOutMs <= checkInMs) return 1;
return Math.max(1, Math.round((checkOutMs - checkInMs) / (24 * 60 * 60 * 1000)));
}
function isStayUnitPrice(priceBasis) {
const normalized = normalizeKey(priceBasis);
return ['pernight', 'perday', 'nightly', 'daily'].includes(normalized);
}
function normalizeTripPrice({ price, priceBasis, currency, displayPrice }) {
if (!isStayUnitPrice(priceBasis)) {
return {
price,
unitPrice: price,
tripTotalPrice: price,
displayPrice,
tripNights: null,
};
}
const tripNights = getTripNightCount();
const tripTotalPrice = Number((price * tripNights).toFixed(2));
const unitLabel = displayPrice || `${formatCurrencyValue(price, currency)}/night`;
return {
price: tripTotalPrice,
unitPrice: price,
tripTotalPrice,
displayPrice: `${formatCurrencyValue(tripTotalPrice, currency)} stay total (${unitLabel} x ${tripNights} nights)`,
tripNights,
};
}
function loadPriceHistoryState() {
const runs = readJsonLines(PRICE_HISTORY_FILE)
.map((entry, index) => {
const checkedAtRaw = entry.checkedAt || entry.checked_at || entry.runAt || entry.timestamp || entry.date || null;
const checkedAtMs = checkedAtRaw ? Date.parse(checkedAtRaw) : Date.now() + index;
return {
...entry,
checkedAt: Number.isNaN(checkedAtMs) ? null : new Date(checkedAtMs).toISOString(),
checkedAtMs: Number.isNaN(checkedAtMs) ? Date.now() + index : checkedAtMs,
};
})
.sort((a, b) => a.checkedAtMs - b.checkedAtMs);
runs.forEach((run, index) => {
run.runIndex = index;
});
const seriesByKey = new Map();
const addPointToSeries = (run, point, defaults = {}) => {
const key = normalizeKey(
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
);
const price = extractNumericPrice({
...defaults,
...point,
});
if (!key || price === null) return;
const currency = point.currency || defaults.currency || 'USD';
const priceBasis = inferPriceBasis(point, defaults);
const rawDisplayPrice = point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null;
const tripPrice = normalizeTripPrice({
price,
priceBasis,
currency,
displayPrice: rawDisplayPrice,
});
const nextPoint = {
checkedAt: run.checkedAt,
checkedAtMs: run.checkedAtMs,
runIndex: run.runIndex,
price: tripPrice.price,
unitPrice: tripPrice.unitPrice,
tripTotalPrice: tripPrice.tripTotalPrice,
tripNights: tripPrice.tripNights,
tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null,
tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null,
currency,
displayPrice: tripPrice.displayPrice,
unitDisplayPrice: rawDisplayPrice,
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || defaults.source || null),
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'),
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
priceBasis,
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
origin: point.origin || point.originAirport || defaults.origin || null,
destination: point.destination || point.destinationAirport || defaults.destination || null,
note: point.note || point.description || null,
availability: point.availability || point.status || null,
decisionNote: point.decisionNote || point.note || point.description || defaults.decisionNote || null,
highlights: toTextList(point.highlights || point.summaryBullets || point.bullets || defaults.highlights),
features: toTextList(point.features || point.featureHighlights || point.featureLabels || defaults.features),
amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels || defaults.amenities),
inclusions: toTextList(point.inclusions || point.includes || point.perks || defaults.inclusions),
limitations: toTextList(point.limitations || point.tradeoffs || point.caveats || defaults.limitations),
};
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
seriesByKey.get(key).push(nextPoint);
};
runs.forEach((run) => {
const pricePoints = Array.isArray(run.optionPrices)
? run.optionPrices
: Array.isArray(run.prices)
? run.prices
: Array.isArray(run.trackedPrices)
? run.trackedPrices
: [];
pricePoints.forEach((point) => {
addPointToSeries(run, point);
});
const derivedItineraries = Array.isArray(run.derivedItineraries)
? run.derivedItineraries
: Array.isArray(run.itineraryScenarios)
? run.itineraryScenarios
: [];
derivedItineraries.forEach((itinerary) => {
addPointToSeries(run, itinerary, {
bookingType: 'calculated',
priceBasis: 'perPerson',
source: 'Automation calculation',
sourceKey: 'automation-calculation',
displayPrice: typeof itinerary.perPerson === 'number' ? `$${itinerary.perPerson.toLocaleString()} pp` : null,
price: itinerary.perPerson,
decisionNote: itinerary.summary,
highlights: itinerary.assumptions,
inclusions: itinerary.components,
});
});
});
seriesByKey.forEach((series) => {
series.sort((a, b) => a.runIndex - b.runIndex);
});
return {
runs,
seriesByKey,
budgetScenarios: latestNonEmptyArray(runs, ['budgetScenarios', 'derivedBudgetScenarios']),
derivedItineraries: latestNonEmptyArray(runs, ['derivedItineraries', 'itineraryScenarios']),
latestCheckedAt: runs.at(-1)?.checkedAt || null,
totalRuns: runs.length,
};
}
function buildPriceHistoryBySource(priceHistory) {
const grouped = new Map();
priceHistory.forEach((point) => {
const sourceKey = normalizeKey(point.sourceKey || point.source || 'unknown-source');
const sourceLabel = normalizeSourceLabel(point.source || point.sourceLabel || sourceKey);
if (!grouped.has(sourceKey)) {
grouped.set(sourceKey, {
sourceKey,
sourceLabel,
sourceUrl: point.sourceUrl || null,
points: [],
});
}
const bucket = grouped.get(sourceKey);
bucket.points.push({
...point,
sourceKey,
source: sourceLabel,
});
if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl;
if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel;
});
const seriesBySource = {};
const sourceSummaries = [...grouped.values()].map((bucket) => {
bucket.points.sort((a, b) => a.runIndex - b.runIndex);
seriesBySource[bucket.sourceKey] = bucket.points;
const latestPoint = bucket.points.at(-1) || null;
return {
sourceKey: bucket.sourceKey,
sourceLabel: bucket.sourceLabel,
sourceUrl: bucket.sourceUrl,
bookingType: latestPoint?.bookingType || null,
priceBasis: latestPoint?.priceBasis || null,
pointCount: bucket.points.length,
latestCheckedAt: latestPoint?.checkedAt || null,
latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
latestPrice: latestPoint?.price ?? null,
latestDisplayPrice: latestPoint?.displayPrice || null,
currency: latestPoint?.currency || 'USD',
};
}).sort((a, b) => {
const aMs = a.latestCheckedAtMs || 0;
const bMs = b.latestCheckedAtMs || 0;
if (aMs !== bMs) return bMs - aMs;
return a.sourceLabel.localeCompare(b.sourceLabel);
});
return {
seriesBySource,
sourceSummaries,
};
}
function getPriceHistoryForOption(option, priceHistoryState) {
const optionKeys = getOptionHistoryKeys(option);
for (const key of optionKeys) {
const series = priceHistoryState.seriesByKey.get(key);
if (series && series.length) {
return series;
}
}
return [];
}
function decorateOptionWithPriceHistory(option, priceHistoryState) {
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
const { seriesBySource, sourceSummaries } = buildPriceHistoryBySource(priceHistory);
const defaultSourceSummary = sourceSummaries[0] || null;
const defaultSourceKey = defaultSourceSummary?.sourceKey || null;
const defaultPriceHistory = defaultSourceKey ? seriesBySource[defaultSourceKey] || [] : priceHistory;
const latestPricePoint = defaultPriceHistory.at(-1) || null;
const optionDetails = toTextList(option.details);
const automationHighlights = toTextList(latestPricePoint?.highlights);
const automationFeatures = toTextList(latestPricePoint?.features);
const automationAmenities = toTextList(latestPricePoint?.amenities);
const automationInclusions = toTextList(latestPricePoint?.inclusions);
const automationLimitations = toTextList(latestPricePoint?.limitations);
const decisionDetails = [
...optionDetails,
...automationHighlights,
...automationFeatures,
...automationAmenities,
...automationInclusions,
...automationLimitations,
];
return {
...option,
priceHistory: defaultPriceHistory,
priceHistoryBySource: seriesBySource,
availableSources: sourceSummaries,
defaultSourceKey,
currentSourceKey: defaultSourceKey,
latestPricePoint,
currentPrice: latestPricePoint?.price ?? null,
decisionDetails: [...new Set(decisionDetails)],
automationInsights: latestPricePoint ? {
currentPrice: latestPricePoint.price,
currency: latestPricePoint.currency || 'USD',
displayPrice: latestPricePoint.displayPrice || null,
source: latestPricePoint.source || null,
sourceUrl: latestPricePoint.sourceUrl || null,
bookingType: latestPricePoint.bookingType || null,
priceBasis: latestPricePoint.priceBasis || null,
includedComponents: latestPricePoint.includedComponents || [],
excludedComponents: latestPricePoint.excludedComponents || [],
availability: latestPricePoint.availability || null,
decisionNote: latestPricePoint.decisionNote || null,
highlights: automationHighlights,
features: automationFeatures,
amenities: automationAmenities,
inclusions: automationInclusions,
limitations: automationLimitations,
} : null,
};
}
function decorateOptionsWithPriceHistory(options, priceHistoryState) {
return options.map((option) => decorateOptionWithPriceHistory(option, priceHistoryState));
}
function approvedOptionsWithVoteSummary() { function approvedOptionsWithVoteSummary() {
return data.options return data.options
.filter((option) => option.approved) .filter((option) => option.approved)
@@ -683,43 +64,19 @@ function broadcast(payload) {
} }
function buildRealtimeSnapshot() { function buildRealtimeSnapshot() {
const priceHistoryState = loadPriceHistoryState();
const approvedOptions = data.options.filter((option) => option.approved);
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
return { return {
type: 'init', type: 'init',
pollsOpen: data.pollsOpen, pollsOpen: data.pollsOpen,
categories: data.categories, categories: data.categories,
tripCheckIn: TRIP_CHECK_IN, options: data.options.filter((option) => option.approved),
tripCheckOut: TRIP_CHECK_OUT,
guestRoster: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
results: approvedOptionsWithVoteSummary(), results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length, totalVoters: data.voters.length,
budgetScenarios, budgetScenarios: data.budgetScenarios || [],
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, priceUpdatedAt: data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns,
}; };
} }
function normalizeDetails(details) { function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) {
if (Array.isArray(details)) {
return details.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof details === 'string') {
return details
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved, details }) {
return { return {
id: uuidv4(), id: uuidv4(),
seedKey: null, seedKey: null,
@@ -733,52 +90,13 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
addedBy: voterName, addedBy: voterName,
approved, approved,
votes: [], votes: [],
details: normalizeDetails(details), details: [],
categoryColor: CATEGORY_META[categoryId]?.color || '#888', categoryColor: CATEGORY_META[categoryId]?.color || '#888',
}; };
} }
let data = loadData(); let data = loadData();
app.get('/api/auth/guests', (req, res) => {
res.json({
guests: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
});
});
app.get('/api/auth/me', (req, res) => {
const guest = verifyGuestToken(readGuestAuthToken(req));
if (!guest) {
return res.status(401).json({ authenticated: false });
}
res.json({
authenticated: true,
guest,
});
});
app.post('/api/auth/login', (req, res) => {
const { name, pin } = req.body || {};
const guest = findGuestByNameAndPin(name, pin);
if (!guest) {
return res.status(401).json({ error: 'Invalid guest name or code' });
}
res.json({
success: true,
token: signGuestToken(guest),
guest: {
name: guest.name,
role: guest.role || 'guest',
},
});
});
app.get('/api/categories', (req, res) => { app.get('/api/categories', (req, res) => {
res.json(data.categories); res.json(data.categories);
}); });
@@ -786,69 +104,43 @@ app.get('/api/categories', (req, res) => {
app.get('/api/options', (req, res) => { app.get('/api/options', (req, res) => {
const { category, includeUnapproved } = req.query; const { category, includeUnapproved } = req.query;
let options = data.options; let options = data.options;
const priceHistoryState = loadPriceHistoryState();
if (category) options = options.filter((option) => option.categoryId === category); if (category) options = options.filter((option) => option.categoryId === category);
if (!includeUnapproved) options = options.filter((option) => option.approved); if (!includeUnapproved) options = options.filter((option) => option.approved);
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState)); res.json(options);
}); });
app.get('/api/results', (req, res) => { app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
const results = data.categories.map((category) => ({ const results = data.categories.map((category) => ({
...category, ...category,
options: data.options options: data.options
.filter((option) => option.approved && option.categoryId === category.id) .filter((option) => option.approved && option.categoryId === category.id)
.map((option) => ({ .map((option) => ({ ...option, voteCount: option.votes.length })),
...decorateOptionWithPriceHistory(option, priceHistoryState),
voteCount: option.votes.length,
})),
})); }));
res.json({ res.json({
pollsOpen: data.pollsOpen, pollsOpen: data.pollsOpen,
results, results,
totalVoters: data.voters.length, totalVoters: data.voters.length,
budgetScenarios, budgetScenarios: data.budgetScenarios || [],
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, priceUpdatedAt: data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns,
}); });
}); });
app.get('/api/budgets', (req, res) => { app.get('/api/budgets', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
res.json({ res.json({
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, updatedAt: data.priceUpdatedAt || null,
scenarios: priceHistoryState.budgetScenarios || data.budgetScenarios || [], scenarios: data.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
});
});
app.get('/api/price-history', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
const seriesByOption = Object.fromEntries(
[...priceHistoryState.seriesByKey.entries()],
);
res.json({
latestCheckedAt: priceHistoryState.latestCheckedAt,
totalRuns: priceHistoryState.totalRuns,
seriesByOption,
budgetScenarios: priceHistoryState.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
}); });
}); });
app.post('/api/vote', (req, res) => { app.post('/api/vote', (req, res) => {
const { optionId } = req.body; const { optionId, voterName } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!optionId) { if (!voterName || !optionId) {
return res.status(400).json({ error: 'Missing fields' }); return res.status(400).json({ error: 'Missing fields' });
} }
if (!guest) return;
if (!data.pollsOpen) { if (!data.pollsOpen) {
return res.status(403).json({ error: 'Polls are closed' }); return res.status(403).json({ error: 'Polls are closed' });
} }
@@ -860,17 +152,17 @@ app.post('/api/vote', (req, res) => {
const previousVote = data.options.find((candidate) => ( const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === guest.name) && candidate.votes.some((vote) => vote.name === voterName)
)); ));
if (previousVote) { if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name); previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
} }
option.votes.push({ name: guest.name, timestamp: Date.now() }); option.votes.push({ name: voterName, timestamp: Date.now() });
if (!data.voters.find((voter) => voter.name === guest.name)) { if (!data.voters.find((voter) => voter.name === voterName)) {
data.voters.push({ name: guest.name, joinedAt: Date.now() }); data.voters.push({ name: voterName, joinedAt: Date.now() });
} }
saveData(data); saveData(data);
@@ -879,26 +171,23 @@ app.post('/api/vote', (req, res) => {
}); });
app.delete('/api/vote/:optionId', (req, res) => { app.delete('/api/vote/:optionId', (req, res) => {
const guest = requireGuestAuth(req, res, req.body); const { voterName } = req.body;
if (!guest) return;
const option = data.options.find((candidate) => candidate.id === req.params.optionId); const option = data.options.find((candidate) => candidate.id === req.params.optionId);
if (!option) return res.status(404).json({ error: 'Not found' }); if (!option) return res.status(404).json({ error: 'Not found' });
option.votes = option.votes.filter((vote) => vote.name !== guest.name); option.votes = option.votes.filter((vote) => vote.name !== voterName);
saveData(data); saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
res.json({ success: true }); res.json({ success: true });
}); });
app.post('/api/options', (req, res) => { app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, lat, lng, details } = req.body; const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!categoryId || !name) { if (!categoryId || !name || !voterName) {
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
} }
if (!guest) return;
const category = data.categories.find((candidate) => candidate.id === categoryId); const category = data.categories.find((candidate) => candidate.id === categoryId);
if (!category) return res.status(404).json({ error: 'Category not found' }); if (!category) return res.status(404).json({ error: 'Category not found' });
@@ -908,10 +197,9 @@ app.post('/api/options', (req, res) => {
name, name,
desc, desc,
url, url,
voterName: guest.name, voterName,
lat, lat,
lng, lng,
details,
approved: false, approved: false,
}); });
@@ -931,25 +219,6 @@ app.post('/api/options/:id/approve', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
app.patch('/api/options/:id', (req, res) => {
const option = data.options.find((candidate) => candidate.id === req.params.id);
if (!option) return res.status(404).json({ error: 'Not found' });
const { categoryId, name, desc, url, lat, lng, details, approved } = req.body;
if (categoryId !== undefined) option.categoryId = categoryId;
if (name !== undefined) option.name = String(name || '').trim();
if (desc !== undefined) option.desc = String(desc || '').trim();
if (url !== undefined) option.url = String(url || '').trim() || null;
if (lat !== undefined) option.lat = Number.isFinite(Number(lat)) ? Number(lat) : null;
if (lng !== undefined) option.lng = Number.isFinite(Number(lng)) ? Number(lng) : null;
if (details !== undefined) option.details = normalizeDetails(details);
if (approved !== undefined) option.approved = Boolean(approved);
saveData(data);
broadcast({ type: 'option_updated', option });
res.json({ success: true, option });
});
app.delete('/api/options/:id', (req, res) => { app.delete('/api/options/:id', (req, res) => {
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id); const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' }); if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
@@ -1029,12 +298,8 @@ wss.on('connection', (ws) => {
const msg = JSON.parse(raw); const msg = JSON.parse(raw);
if (msg.type === 'vote') { if (msg.type === 'vote') {
const { optionId, remove } = msg; const { optionId, voterName, remove } = msg;
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null); if (!voterName || !optionId) return;
if (!guest || !optionId) {
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
return;
}
if (!data.pollsOpen) { if (!data.pollsOpen) {
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
return; return;
@@ -1044,39 +309,35 @@ wss.on('connection', (ws) => {
if (!option || !option.approved) return; if (!option || !option.approved) return;
if (remove) { if (remove) {
option.votes = option.votes.filter((vote) => vote.name !== guest.name); option.votes = option.votes.filter((vote) => vote.name !== voterName);
} else { } else {
const previousVote = data.options.find((candidate) => ( const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === guest.name) && candidate.votes.some((vote) => vote.name === voterName)
)); ));
if (previousVote) { if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name); previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
} }
option.votes.push({ name: guest.name, timestamp: Date.now() }); option.votes.push({ name: voterName, timestamp: Date.now() });
} }
if (!data.voters.find((voter) => voter.name === guest.name)) { if (!data.voters.find((voter) => voter.name === voterName)) {
data.voters.push({ name: guest.name, joinedAt: Date.now() }); data.voters.push({ name: voterName, joinedAt: Date.now() });
} }
saveData(data); saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
} else if (msg.type === 'add_option') { } else if (msg.type === 'add_option') {
const { categoryId, name, desc, url, lat, lng } = msg; const { categoryId, name, desc, url, voterName, lat, lng } = msg;
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null); if (!categoryId || !name || !voterName) return;
if (!guest || !categoryId || !name) {
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
return;
}
const newOption = createUserOption({ const newOption = createUserOption({
categoryId, categoryId,
name, name,
desc, desc,
url, url,
voterName: guest.name, voterName,
lat, lat,
lng, lng,
approved: true, approved: true,
@@ -1093,8 +354,6 @@ wss.on('connection', (ws) => {
}); });
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0'; server.listen(PORT, '0.0.0.0', () => {
console.log(`🏄 Cabo Voting App → http://0.0.0.0:${PORT}`);
server.listen(PORT, HOST, () => {
console.log(`🏄 Cabo Voting App → http://${HOST}:${PORT}`);
}); });