Compare commits

...

120 Commits

Author SHA1 Message Date
TopherMayor
8389c020c0 Refresh Cabo price watch report 2026-06-08 11:21:41 -07:00
TopherMayor
82db6219f5 Refresh Cabo price watch data 2026-06-08 07:44:16 -07:00
TopherMayor
99e1050d71 Refresh Cabo price watch data 2026-06-08 06:00:46 -07:00
TopherMayor
43c7a94b9c Refresh Cabo price watch data 2026-06-07 22:29:41 -07:00
TopherMayor
6348c34461 Refresh Cabo price watch data 2026-06-07 15:04:20 -07:00
TopherMayor
cda296e25a Refresh Cabo price watch data 2026-06-07 11:05:34 -07:00
TopherMayor
7aea0c7831 Refresh Cabo price watch data 2026-06-07 07:02:08 -07:00
TopherMayor
a89ed35994 Refresh Cabo price watch data 2026-06-07 03:00:21 -07:00
TopherMayor
e565037179 Update Cabo price watch report 2026-06-06 23:05:34 -07:00
TopherMayor
6ebfab1710 Refresh Cabo price watch data 2026-06-06 15:02:04 -07:00
TopherMayor
3488934180 Refresh Cabo price watch data 2026-06-06 11:00:16 -07:00
TopherMayor
3b0ee11fc3 Refresh Cabo price watch data 2026-06-06 06:59:04 -07:00
TopherMayor
39df63e0c2 Refresh Cabo price watch data 2026-06-06 02:59:22 -07:00
TopherMayor
1acc36824d Refresh Cabo price watch data 2026-06-05 23:04:56 -07:00
TopherMayor
cb4f431b89 Update Cabo price watch data 2026-06-05 20:16:42 -07:00
TopherMayor
16a8fb6ac3 Update Cabo price watch report 2026-06-05 14:56:02 -07:00
TopherMayor
27565b305e Record blocked Cabo price watch run 2026-06-05 10:42:34 -07:00
TopherMayor
5afbbe122d Refresh Cabo price watch data 2026-06-05 06:47:50 -07:00
TopherMayor
028445ee55 Refresh cabo price watch data 2026-06-05 02:50:05 -07:00
TopherMayor
ad72bb33c5 Update Cabo price watch run 2026-06-04 22:52:24 -07:00
TopherMayor
49bbf5271b Refresh cabo price watch data 2026-06-04 20:07:11 -07:00
TopherMayor
0235fa5ddf Fix Cabo price watch history newline 2026-06-04 14:36:13 -07:00
TopherMayor
0428c54e9c Refresh Cabo price watch snapshot 2026-06-04 14:34:41 -07:00
TopherMayor
90bdc1dd0a Refresh Cabo price watch 2026-06-04 09:33:56 -07:00
TopherMayor
66d214f737 Refresh Cabo price watch data 2026-06-03 21:08:51 -07:00
TopherMayor
a7c0417a2c Refresh Cabo price watch data 2026-06-03 20:33:54 -07:00
TopherMayor
1ec2204184 Refresh Cabo price watch data 2026-06-03 09:18:26 -07:00
TopherMayor
c75b7e9654 Refresh cabo price watch snapshot 2026-06-03 05:03:53 -07:00
TopherMayor
a940f0f2e5 Update Cabo price watch data 2026-06-03 01:14:16 -07:00
TopherMayor
7a10e4d3c9 Refresh Cabo price watch data 2026-06-02 21:47:16 -07:00
TopherMayor
e5136bd193 Update Cabo price watch blocked run 2026-05-22 10:15:49 -07:00
TopherMayor
91c1db2a24 Record blocked Cabo price watch run 2026-05-22 06:15:24 -07:00
TopherMayor
60147b822b Record blocked Cabo price watch run 2026-05-22 02:15:29 -07:00
TopherMayor
525e91a76d Record blocked Cabo price watch run 2026-05-12 08:04:55 -07:00
TopherMayor
4dc36199f5 Record blocked Cabo price watch run 2026-05-12 04:03:45 -07:00
TopherMayor
a64a677af6 Record blocked price watch run 2026-05-12 00:02:31 -07:00
TopherMayor
92b5190f74 Record blocked Cabo price watch run 2026-05-11 20:01:10 -07:00
TopherMayor
db11f51a19 Record blocked Cabo price watch run 2026-05-11 15:59:54 -07:00
TopherMayor
556cd91fbe Record blocked price watch run 2026-05-11 11:58:05 -07:00
TopherMayor
c0ac120721 Record blocked Cabo price watch run 2026-05-11 07:57:41 -07:00
TopherMayor
1f91dfcd17 Record blocked cabo price watch run 2026-05-11 03:56:01 -07:00
TopherMayor
8ce85470f9 Record blocked cabo price watch run 2026-05-10 23:54:16 -07:00
TopherMayor
edf6937f1f Record blocked Cabo price watch run 2026-05-10 19:54:01 -07:00
TopherMayor
7646aec58c Record blocked Cabo price watch run 2026-05-10 19:30:58 -07:00
TopherMayor
b36291ef63 Record blocked Cabo price watch run 2026-05-10 11:47:02 -07:00
TopherMayor
cf4ce56b82 Record blocked Cabo price watch run 2026-05-10 07:42:33 -07:00
TopherMayor
b6bab181fc Record blocked Cabo price watch run 2026-05-10 03:37:33 -07:00
TopherMayor
3c4dbb7f2a Record blocked Cabo price watch run 2026-05-09 23:31:39 -07:00
TopherMayor
3678c49fb4 Record blocked Cabo price watch run 2026-05-09 20:31:24 -07:00
TopherMayor
0cf58c9c41 Update Cabo price watch blocked run 2026-05-09 19:30:29 -07:00
TopherMayor
0bf602d5d9 Update Cabo price watch blocked run 2026-05-09 17:29:41 -07:00
TopherMayor
a1dda7fc42 Update Cabo price watch blocked run 2026-05-09 14:43:22 -07:00
TopherMayor
b88fc35d11 Record blocked Cabo price watch run 2026-05-09 11:23:07 -07:00
TopherMayor
1bc20741b6 Record blocked price watch run 2026-05-09 00:01:16 -07:00
TopherMayor
9f4eb64c4d Update Cabo price watch blocked run 2026-05-08 15:02:13 -07:00
TopherMayor
6df5d058ac Record blocked Cabo price watch run 2026-05-07 04:19:02 -07:00
TopherMayor
3fdc435e5f Record blocked Cabo price watch run 2026-05-07 00:17:47 -07:00
TopherMayor
c3827c23e0 Update Cabo price-watch blocked run 2026-05-06 20:16:51 -07:00
TopherMayor
d9feaf0ee1 Record blocked price watch run 2026-05-06 16:14:52 -07:00
TopherMayor
573b5a6c01 Record blocked price watch run 2026-05-06 12:13:04 -07:00
TopherMayor
62c754fc61 Update Cabo price watch report 2026-05-06 08:12:22 -07:00
TopherMayor
bff108faca Update Cabo price watch blocked run 2026-05-06 04:10:40 -07:00
TopherMayor
1708f2f46f Update Cabo price watch blocked run 2026-05-06 00:08:50 -07:00
TopherMayor
c080c181d9 price-watch: blocked run 2026-05-06 - Computer Use tools not available in session 2026-05-05 20:12:56 -07:00
TopherMayor
05740fe537 Refresh Cabo price watch with live pricing 2026-05-04 12:29:22 -07:00
TopherMayor
bd7af07d19 Record blocked Cabo price watch run 2026-05-04 11:59:20 -07:00
TopherMayor
38ae3f3dd8 Record blocked Cabo price watch run 2026-05-04 07:58:16 -07:00
TopherMayor
4de0e5a472 Update Cabo price watch blocked run 2026-05-04 07:43:35 -07:00
TopherMayor
6b0eb82fb6 Update Cabo price watch report 2026-05-03 21:09:32 -07:00
TopherMayor
e04f8e27b7 Record blocked Cabo price watch run 2026-05-03 13:50:04 -07:00
TopherMayor
0eac4c81ac Record blocked cabo price watch run 2026-05-03 13:48:32 -07:00
TopherMayor
5f9edc3ed7 Make price trend lines more visible 2026-05-01 13:04:15 -07:00
TopherMayor
0b6d698ba7 Prefer richer price history by default 2026-05-01 12:04:14 -07:00
TopherMayor
e3dfd90ecc Update Cabo price watch snapshot 2026-05-01 11:46:16 -07:00
TopherMayor
afa501c838 Show trip dates on option cards 2026-05-01 11:20:26 -07:00
TopherMayor
974a483d6c Show package inclusions in bundles 2026-05-01 11:09:07 -07:00
TopherMayor
ed98d4ea70 Add bundles tab for package pricing 2026-05-01 10:44:14 -07:00
TopherMayor
09cf482d92 Show flights included on hotel cards 2026-05-01 10:35:32 -07:00
TopherMayor
83b07326de Add Cabo flight seed options 2026-05-01 10:34:17 -07:00
TopherMayor
4930d7d37b Refresh Cabo price watch with Costco quotes 2026-05-01 08:29:33 -07:00
TopherMayor
a990adcb80 Refresh Cabo price watch data 2026-05-01 07:50:03 -07:00
TopherMayor
d5a7e85417 Add inline admin option editor 2026-04-30 22:43:09 -07:00
TopherMayor
1e36d45976 Add editable option approval flow 2026-04-30 22:39:51 -07:00
TopherMayor
1674930435 Surface flight search shortcuts 2026-04-30 21:29:10 -07:00
TopherMayor
86733522eb Add explicit flight origin selector 2026-04-30 21:21:06 -07:00
TopherMayor
16a0252647 Tighten flight search links 2026-04-30 21:18:46 -07:00
TopherMayor
b768990e05 Expand flight search coverage 2026-04-30 21:17:48 -07:00
TopherMayor
bee992e10b Update cabo price watch snapshot 2026-04-30 21:15:54 -07:00
TopherMayor
7d139fead9 Refresh Cabo automation snapshot 2026-04-30 20:57:37 -07:00
TopherMayor
538de0039c Switch budget tab to attendee dropdown 2026-04-30 20:34:33 -07:00
TopherMayor
1e0a072231 Make budget tab scale with roster size 2026-04-30 20:26:49 -07:00
TopherMayor
bdd2e5968f Add guest auth for Cabo voters 2026-04-30 19:58:22 -07:00
TopherMayor
e5079cbce4 Update Cabo price watch with live flight data 2026-04-30 19:22:29 -07:00
8685527155 [ice] admin PIN: 1584 (Chris) 2026-04-30 18:22:23 -07:00
cf13c71132 [ice] add PIN auth: 2-step name+last4 login gate against groomsmen list 2026-04-30 18:21:03 -07:00
1926839a4b [ice] budget tab: dynamic live prices from details, hide options without scraped data 2026-04-30 18:12:27 -07:00
accf9a57f6 feat: add BudgetTab with live vote-driven cost calculation
- BudgetTab shows current leaders per category (hotel/golf/nightlife/excursion)
- Dominant tier (budget/balanced/splurge) auto-detected from votes
- Per-person and group totals for 8/10/12 guy scenarios
- Built on seed data pricing signals for accuracy
2026-04-30 17:46:40 -07:00
TopherMayor
ad6c89f68d fix: replace blocked comfort inn link 2026-04-30 13:24:52 -07:00
TopherMayor
bcd80f3795 fix: route option links to detail pages 2026-04-30 12:26:22 -07:00
TopherMayor
4a83abf05e fix: display numeric tracked prices 2026-04-30 12:15:43 -07:00
TopherMayor
3e0a462431 fix: calculate stay totals 2026-04-30 12:07:45 -07:00
TopherMayor
071a18e4a4 fix: keep source selector stable 2026-04-30 12:03:35 -07:00
TopherMayor
106270117d fix: parse gathered price labels 2026-04-30 11:59:21 -07:00
TopherMayor
a3b8e9a4b0 feat: expand price automation contract 2026-04-30 11:53:18 -07:00
TopherMayor
4ad51ed2c6 feat: require vote confirmation 2026-04-30 11:28:33 -07:00
TopherMayor
8726eb7cf9 feat: add option sort dropdown 2026-04-30 11:25:10 -07:00
TopherMayor
3ee4fb7914 fix: keep option cards in stable order 2026-04-30 11:23:49 -07:00
TopherMayor
bdc0a5d8e6 feat: track prices by source 2026-04-30 11:21:06 -07:00
TopherMayor
81c82949f0 feat: add date-matched hotel listings 2026-04-30 11:16:39 -07:00
TopherMayor
c17e97b46f fix: deploy tracked price history 2026-04-30 11:12:03 -07:00
TopherMayor
09982c2d9c fix: alias source-prefixed price history keys 2026-04-30 11:10:21 -07:00
TopherMayor
b7a4386e00 feat: surface automation option details 2026-04-30 11:07:26 -07:00
TopherMayor
899c9cb30b feat: add price history support 2026-04-30 10:52:21 -07:00
TopherMayor
516bcd0a44 feat: support external runtime state for hosting 2026-04-29 22:19:10 -07:00
TopherMayor
8f31b80647 feat: add cabo package planning and price watch assets 2026-04-29 21:29:20 -07:00
Hermes Agent
88ee723981 [cabo-voting-app] Multi-provider map search: Yelp/OSM/All tabs + quick-book aggregators + legend update 2026-04-29 11:05:16 -07:00
47acadc88c Add Yelp Fusion API proxy endpoint for dynamic map search 2026-04-29 15:43:40 +00:00
0d01b83af6 Map: independent category toggles + Yelp dynamic search with dashed markers 2026-04-29 15:43:32 +00:00
e7fa88567a Add unified Map tab with Leaflet, CARTO dark tiles, live vote counts, and venue search 2026-04-29 14:57:04 +00:00
f47dac1e41 Add Cabo map tab with Leaflet integration and lat/lng seed data 2026-04-29 14:56:57 +00:00
17 changed files with 6979 additions and 262 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 voting_app cd cabo-voting-app
npm install npm install
node server.js node server.js
# → http://localhost:3001 # → http://localhost:3001
@@ -14,7 +14,13 @@ node server.js
## Features ## Features
- **Real-time WebSocket voting** — all clients update instantly - **Real-time WebSocket voting** — all clients update instantly
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries - **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries
- **Budget planner tab** — compares 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
- **Guest authentication** — bachelor-party voters sign in with their name and the last 4 digits of their phone number
- **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
@@ -22,10 +28,24 @@ 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
Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via `cabo-voting.yml`. The app can run directly under `systemd` with:
```bash
PORT=3021 DATA_DIR=/srv/state/cabo-voting node server.js
```
Traefik can then reverse proxy to the chosen host port.
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.

256
client/src/App.jsx Normal file
View File

@@ -0,0 +1,256 @@
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

@@ -0,0 +1,218 @@
.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

@@ -0,0 +1,371 @@
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

@@ -0,0 +1,100 @@
.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

@@ -0,0 +1,107 @@
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>
)
}

43
client/src/groommen.js Normal file
View File

@@ -0,0 +1,43 @@
// 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 Normal file
View File

@@ -0,0 +1,908 @@
{
"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
}
}
}
}
}

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"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"
}
}

1
price-watch/.gitignore vendored Normal file
View File

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

90
price-watch/history.jsonl Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,45 @@
# Cabo Price Watch - 2026-06-08
Trip window: 2027-02-03 through 2027-02-07 (4 nights)
## Biggest Changes
- Breathless room-only floor is now $551/night on Expedia, up from the earlier $381/night floor.
- Grand Fiesta Americana room-only floor is now $480/night on Expedia, up from $212/night.
- Hard Rock room-only floor is now $436/night on Expedia, up from $277/night.
- Nobu room-only floor is now $479/night on Expedia, up from $294/night.
- Grand Velas room-only floor is now $903/night on Expedia, down from $1,135/night.
- Taboo pool island is now $910, up from $884.
- Cabo ATV Tour & Desert Adventure is now $91, up from $78.
- Off Road UTV Tour is now $139, up from $119.
- Outdoor Adventure 4x4 + Cabo Zipline + Rappel is now $97, up from $89.
- Camels + ATV + Lunch is now $110, up from $101.
## Stable Anchors
- CheapCaribbean package floors remain at $568 per person for Riu Santa Fe through $1,332 per person for Hard Rock Hotel Los Cabos.
- Expedia flight route floors remain about $340 LAX-SJD and $396 ONT-SJD.
- Country Club, Palmilla, and Quivira golf anchors remain at $183.60, $213.98, and $275.
- Oceanus Sunset Party Cruise remains $62 and the low-cost nightlife anchors still sit at Nowhere Bar $50 and Cabo Wabo $155.
## Missing Or Gated
- Costco Travel package totals are still login-required; Hacienda del Mar and Hard Rock package totals were visible only up to the continue step.
- Apple Vacations package pages still show Get Price / quote-only flows for Breathless, Grand Fiesta Americana, and Secrets.
- No sold-out items were visible in this run.
- Direct in-app browser navigation to KAYAK and Expedia was blocked, so the refreshed hotel floors came from public search-result snippets and accessible result pages.
## New Options Worth Adding
- Hyatt Ziva Los Cabos and Riu Palace Cabo San Lucas remain strong standalone hotel candidates.
- La Vaquita gold and Clandestinna deposit are new nightlife options worth tracking.
- Tierra Sagrada Beach & Adventure Park at $209 adds a stronger bundle-friendly excursion option.
- Private luxury whale watching at $3,009.30 and private luxury sailing at $2,799 expand the top-end group excursion set.
## Budget Impact
- Budget path stays about $819.85 pp or $6,558.80 for 8.
- Balanced path lands about $1,447.48 pp or $14,474.80 for 10.
- Splurge path lands about $2,027.94 pp or $24,335.30 for 12.
- Package-vs-standalone caveat: package prices already bundle flight and hotel, so do not stack separate flight or hotel costs on top unless a package explicitly excludes them.
- Current standalone hotel floors are public quote snapshots, not exact Feb 3-7, 2027 searches, so treat them as current market floors rather than date-matched stay totals.

View File

@@ -0,0 +1,181 @@
{
"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,8 +120,87 @@
.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);
@@ -140,6 +219,8 @@
@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>
@@ -185,6 +266,44 @@
<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">
@@ -211,6 +330,7 @@ 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');
@@ -240,6 +360,7 @@ 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();
@@ -249,6 +370,14 @@ 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;
@@ -278,6 +407,7 @@ 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>
@@ -295,12 +425,56 @@ 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

794
seed-data.js Normal file
View File

@@ -0,0 +1,794 @@
const SEED_VERSION = 7;
const PRICE_UPDATED_AT = '2026-05-01';
const CATEGORY_META = {
hotel: { emoji: '🏨', color: '#3b82f6' },
flight: { emoji: '✈️', color: '#38bdf8' },
golf: { emoji: '⛳', color: '#22c55e' },
nightlife: { emoji: '🎧', color: '#a855f7' },
excursion: { emoji: '🚤', color: '#06b6d4' },
itinerary: { emoji: '🗺️', color: '#fbbf24' },
budget: { emoji: '💸', color: '#f97316' },
results: { emoji: '🏆', color: '#facc15' },
};
const GUEST_ROSTER = [
{ name: 'Jon', last4: '7506', role: 'groom' },
{ name: 'Toph', last4: '8116', role: 'best-man' },
{ name: 'Hans', last4: '6681', role: 'guest' },
{ name: 'Janno', last4: '2809', role: 'guest' },
{ name: 'JT', last4: '3286', role: 'guest' },
{ name: 'Cordero', last4: '0379', role: 'guest' },
{ name: 'Lester', last4: '8014', role: 'guest' },
{ name: 'Nick', last4: '6044', role: 'guest' },
{ name: 'David', last4: '5993', role: 'guest' },
{ name: 'Poalo', last4: '9922', role: 'guest' },
{ name: 'Justin', last4: '2329', role: 'guest' },
{ name: 'Ben Stewart', last4: '1957', role: 'guest' },
{ name: 'Joseph', last4: '4976', role: 'guest' },
{ name: 'Francis', last4: '4934', role: 'guest' },
];
const BUDGET_SCENARIOS = [
{
id: 'budget-8',
tier: 'Budget',
groupSize: 8,
perPerson: 1405,
groupTotal: 11240,
summary: 'Corazon + Palmilla + one shared activity + one nightlife night',
notes: [
'Flight estimate: $350',
'Hotel estimate: $450',
'Golf: Palmilla from $130',
'Activity: public sail / whale watch about $95',
'Food + drinks buffer: $275',
'Private round-trip transfer share: about $33',
],
},
{
id: 'budget-10',
tier: 'Budget',
groupSize: 10,
perPerson: 1398,
groupTotal: 13980,
summary: 'Corazon + Palmilla + one shared activity + one nightlife night',
notes: [
'Flight estimate: $350',
'Hotel estimate: $450',
'Golf: Palmilla from $130',
'Activity: public sail / whale watch about $95',
'Food + drinks buffer: $275',
'Private round-trip transfer share: about $26',
],
},
{
id: 'budget-12',
tier: 'Budget',
groupSize: 12,
perPerson: 1392,
groupTotal: 16704,
summary: 'Corazon + Palmilla + one shared activity + one nightlife night',
notes: [
'Flight estimate: $350',
'Hotel estimate: $450',
'Golf: Palmilla from $130',
'Activity: public sail / whale watch about $95',
'Food + drinks buffer: $275',
'Private round-trip transfer share: about $22',
],
},
{
id: 'balanced-8',
tier: 'Balanced',
groupSize: 8,
perPerson: 1688,
groupTotal: 13504,
summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night',
notes: [
'Flight estimate: $350',
'All-inclusive stay: $850',
'Golf: Cabo del Sol / similar from $180',
'Sunset sail: about $125',
'Nightlife + covers: $100',
'Transfer + resort buffer: about $83',
],
},
{
id: 'balanced-10',
tier: 'Balanced',
groupSize: 10,
perPerson: 1681,
groupTotal: 16810,
summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night',
notes: [
'Flight estimate: $350',
'All-inclusive stay: $850',
'Golf: Cabo del Sol / similar from $180',
'Sunset sail: about $125',
'Nightlife + covers: $100',
'Transfer + resort buffer: about $76',
],
},
{
id: 'balanced-12',
tier: 'Balanced',
groupSize: 12,
perPerson: 1677,
groupTotal: 20124,
summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night',
notes: [
'Flight estimate: $350',
'All-inclusive stay: $850',
'Golf: Cabo del Sol / similar from $180',
'Sunset sail: about $125',
'Nightlife + covers: $100',
'Transfer + resort buffer: about $72',
],
},
{
id: 'splurge-8',
tier: 'Splurge',
groupSize: 8,
perPerson: 2484,
groupTotal: 19872,
summary: 'Breathless or Secrets + premium golf + private charter + VIP table',
notes: [
'Flight estimate: $400',
'Upscale all-inclusive stay: $1250',
'Premium golf: Quivira / similar about $250',
'Private whale or charter share: about $188',
'VIP nightlife share: about $213',
'Transfers + premium dinner buffer: about $183',
],
},
{
id: 'splurge-10',
tier: 'Splurge',
groupSize: 10,
perPerson: 2346,
groupTotal: 23460,
summary: 'Breathless or Secrets + premium golf + private charter + VIP table',
notes: [
'Flight estimate: $400',
'Upscale all-inclusive stay: $1250',
'Premium golf: Quivira / similar about $250',
'Private whale or charter share: about $150',
'VIP nightlife share: about $170',
'Transfers + premium dinner buffer: about $126',
],
},
{
id: 'splurge-12',
tier: 'Splurge',
groupSize: 12,
perPerson: 2289,
groupTotal: 27468,
summary: 'Breathless or Secrets + premium golf + private charter + VIP table',
notes: [
'Flight estimate: $400',
'Upscale all-inclusive stay: $1250',
'Premium golf: Quivira / similar about $250',
'Private whale or charter share: about $125',
'VIP nightlife share: about $142',
'Transfers + premium dinner buffer: about $122',
],
},
];
function createOption(option) {
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
const primaryUrl = option.links?.[0]?.url || option.url || null;
return {
approved: true,
addedBy: 'system',
votes: [],
details: [],
links: [],
categoryColor,
url: primaryUrl,
...option,
categoryColor,
url: primaryUrl,
};
}
function buildSeedData() {
return {
seedVersion: SEED_VERSION,
priceUpdatedAt: PRICE_UPDATED_AT,
categories: [
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
{ id: 'flight', name: 'Flights', emoji: '✈️' },
{ id: 'golf', name: 'Golf', emoji: '⛳' },
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
{ id: 'itinerary', name: 'Itineraries', emoji: '🗺️' },
{ id: 'budget', name: 'Budget', emoji: '💸' },
{ id: 'results', name: 'Results', emoji: '🏆' },
],
guestRoster: GUEST_ROSTER,
budgetScenarios: BUDGET_SCENARIOS,
options: [
createOption({
id: 'hotel-corazon',
seedKey: 'hotel-corazon',
categoryId: 'hotel',
name: 'Corazon Cabo Resort & Spa',
desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.',
lat: 23.0639,
lng: -109.6991,
details: ['Costco package availability only', 'KAYAK no fresh rates', 'Walk to marina nightlife'],
links: [
{ label: 'Official', url: 'https://www.corazoncabo.com/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Cabo-Villas-Beach-Resort-Spa.58565.ksp' },
],
}),
createOption({
id: 'hotel-breathless',
seedKey: 'hotel-breathless',
categoryId: 'hotel',
name: 'Breathless Cabo San Lucas',
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
lat: 23.0628,
lng: -109.6981,
details: ['Apple exact-date quote: $2,016 pp', 'Costco package: $1,678.99 pp', 'Adults-only'],
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-03' },
],
}),
createOption({
id: 'hotel-grand-fiesta',
seedKey: 'hotel-grand-fiesta',
categoryId: 'hotel',
name: 'Grand Fiesta Americana Los Cabos',
desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.',
lat: 23.0949,
lng: -109.7067,
details: ['Apple exact-date quote: $2,111 pp', 'KAYAK from $212/night', 'Golf-friendly'],
links: [
{ label: 'Official', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ 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: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
],
}),
createOption({
id: 'hotel-secrets',
seedKey: 'hotel-secrets',
categoryId: 'hotel',
name: 'Secrets Puerto Los Cabos',
desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
lat: 23.0227,
lng: -109.7062,
details: ['KAYAK exact-date room rate: $335/night', 'Costco package: $2,005.80 pp', 'Adults-only'],
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-03&vendorcode=CCV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
],
}),
createOption({
id: 'hotel-pacifica',
seedKey: 'hotel-pacifica',
categoryId: 'hotel',
name: 'Pueblo Bonito Pacifica',
desc: 'Luxury adults-only option with Quivira access. Best if the trip is really a premium golf weekend with nightlife as a side quest.',
lat: 23.0474,
lng: -109.7053,
details: ['Adults-only', 'Quivira access', 'Luxury retreat'],
links: [
{ label: 'Official', url: 'https://www.pueblobonito.com/resorts/pacifica?resort=4' },
{ 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({
id: 'golf-palmilla',
seedKey: 'golf-palmilla',
categoryId: 'golf',
name: 'Palmilla Golf Club',
desc: 'Best public price signal I found with transparent inclusions: green fee, shared cart, practice facilities, and bottled water.',
lat: 23.0519,
lng: -109.7058,
details: ['From $130 pp', 'Shared cart included', 'Strong budget-track pick'],
links: [
{ label: 'Cabo Villas Golf', url: 'https://www.cabovillas.com/golf/palmilla' },
{ label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' },
],
}),
createOption({
id: 'golf-cabo-del-sol',
seedKey: 'golf-cabo-del-sol',
categoryId: 'golf',
name: 'Cabo del Sol',
desc: 'The most natural golf pairing for Grand Fiesta Americana and the balanced-track itinerary.',
lat: 23.0569,
lng: -109.6962,
details: ['Use about $180 as current planning number', 'Best balanced-track fit', 'Ocean-desert layout'],
links: [
{ label: 'Official', url: 'https://cabodelsol.com/' },
{ label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' },
],
}),
createOption({
id: 'golf-quivira',
seedKey: 'golf-quivira',
categoryId: 'golf',
name: 'Quivira Golf Club',
desc: 'Premium golf move for the splurge weekend. Access is easiest through the Pueblo Bonito / Pacifica side of the destination.',
lat: 23.0403,
lng: -109.7221,
details: ['Use about $250 for planning', 'Pairs with Pacifica', 'Signature ocean holes'],
links: [
{ label: 'Official', url: 'https://www.quiviraloscabos.com/golf/' },
{ label: 'Pacifica FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
],
}),
createOption({
id: 'golf-puerto-los-cabos',
seedKey: 'golf-puerto-los-cabos',
categoryId: 'golf',
name: 'Puerto Los Cabos Golf',
desc: 'Most natural pairing with Secrets Puerto Los Cabos if the group picks the upscale San Jose side.',
lat: 23.0308,
lng: -109.6964,
details: ['Convenient from Secrets', 'Upscale east-cape feel', 'Good alternative to Quivira'],
links: [
{ label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' },
{ label: 'Secrets Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
],
}),
createOption({
id: 'nightlife-cabo-bash',
seedKey: 'nightlife-cabo-bash',
categoryId: 'nightlife',
name: 'Cabo Bash VIP Nightlife',
desc: 'Most turnkey option if you want the weekend hosted instead of DIY. They also coordinate yachts, villas, and day clubs.',
lat: 23.0627,
lng: -109.6989,
details: ['Gold package for 16 guests: $1,700', 'Concierge coordination', 'Strongest bachelor specialist'],
links: [
{ label: 'Bachelor Parties', url: 'https://www.cabobash.com/bachelor.html' },
{ label: 'Nightlife', url: 'https://www.cabobash.com/nightlife.html' },
{ label: 'Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' },
],
}),
createOption({
id: 'nightlife-cabo-agency',
seedKey: 'nightlife-cabo-agency',
categoryId: 'nightlife',
name: 'The Cabo Agency VIP Tables',
desc: 'Best if you want to book specific tables instead of a full concierge weekend.',
lat: 23.0692,
lng: -109.6993,
details: ['Cabo Wabo VIP table $155 with $100 credit', 'Booth $400 with $300 credit', 'A la carte nightlife'],
links: [
{ label: 'VIP Tables', url: 'https://www.thecaboagency.com/cabo_vip_tables.php' },
{ label: 'Entertainment Packages', url: 'https://www.thecaboagency.com/cabo_entertainment_packages.php' },
],
}),
createOption({
id: 'nightlife-taboo',
seedKey: 'nightlife-taboo',
categoryId: 'nightlife',
name: 'Taboo Beach Club',
desc: 'High-spend daytime flex. Better for a splashy afternoon than an all-weekend base.',
lat: 23.0637,
lng: -109.7001,
details: ['Pool island for 4: $884', 'Cabana for 8: $2,060', 'Use for splurge tier'],
links: [
{ label: 'Cabo Bash Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' },
],
}),
createOption({
id: 'nightlife-mango-deck',
seedKey: 'nightlife-mango-deck',
categoryId: 'nightlife',
name: 'Mango Deck / Office Zone',
desc: 'Best lower-spend party zone if you want daytime chaos close to Medano Beach without burning the whole budget.',
lat: 23.0631,
lng: -109.6995,
details: ['Mango Deck deposit from $40 pp', 'Easy with Corazon', 'Budget-track friendly'],
links: [
{ label: 'Cabo Bash Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' },
],
}),
createOption({
id: 'excursion-whale-public',
seedKey: 'excursion-whale-public',
categoryId: 'excursion',
name: 'Cabo Adventures Whale Watching',
desc: 'February is prime whale season, and this is the cleanest official public-tour price signal I found.',
lat: 23.0626,
lng: -109.7004,
details: ['From $76', 'Prime season in February', 'Dock fee and transport extras may apply'],
links: [
{ label: 'Official', url: 'https://www.cabo-adventures.com/en/' },
{ label: 'Whale Season Guide', url: 'https://www.visitloscabos.travel/blog/post/whale-watching-in-los-cabos-2025-the-ultimate-guide-to-an-unforgettable-season/' },
],
}),
createOption({
id: 'excursion-whale-private',
seedKey: 'excursion-whale-private',
categoryId: 'excursion',
name: 'Private Whale Watching Charter',
desc: 'Best splurge-group activity because the per-person hit gets much better as the group size rises.',
lat: 23.0626,
lng: -109.7004,
details: ['From $1,504 total', 'About $188 pp at 8', 'About $125 pp at 12'],
links: [
{ label: 'Cabo Adventures Private Tours', url: 'https://www.cabo-adventures.com/en/tours/private-cabo/' },
],
}),
createOption({
id: 'excursion-atv',
seedKey: 'excursion-atv',
categoryId: 'excursion',
name: 'ATV Desert Adventure',
desc: 'Classic bachelor-party activity with a real extra-fee caveat worth budgeting up front.',
lat: 23.0289,
lng: -109.6689,
details: ['About $78-$91', '$35 damage waiver per vehicle', 'Entrance fee noted at check-in'],
links: [
{ label: 'Tour Landing Page', url: 'https://www.cabo-adventures.com/en/tours/land-adventures/' },
{ label: 'ATV Details', url: 'https://www.cabo-adventures.com/en/tour/atv-desert-adventure/' },
],
}),
createOption({
id: 'excursion-sail',
seedKey: 'excursion-sail',
categoryId: 'excursion',
name: 'Sunset Sail / Public Yacht Day',
desc: 'Best balanced activity if the group wants a solid Cabo moment without chartering the entire day.',
lat: 23.0634,
lng: -109.6978,
details: ['Public sail from $109', 'Sunset sail $124.50', 'Private 38 foot sailboat from $855.94'],
links: [
{ label: 'Cabo Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
],
}),
createOption({
id: 'itinerary-budget',
seedKey: 'itinerary-budget',
categoryId: 'itinerary',
name: 'Budget Track: Corazon + Palmilla + Public Activity',
desc: 'Best option if the group wants a real Cabo bachelor trip while keeping the all-in number close to the low-$1.4k range before extra bar tabs.',
lat: 23.0639,
lng: -109.6991,
details: ['8 guys: about $1,405 pp', '10 guys: about $1,398 pp', '12 guys: about $1,392 pp'],
links: [
{ label: 'Corazon', url: 'https://www.corazoncabo.com/' },
{ label: 'Palmilla', url: 'https://www.cabovillas.com/golf/palmilla' },
{ label: 'Transfers', url: 'https://www.cabovillas.com/transportation' },
],
}),
createOption({
id: 'itinerary-balanced',
seedKey: 'itinerary-balanced',
categoryId: 'itinerary',
name: 'Balanced Track: Grand Fiesta + Golf + Sail',
desc: 'Best all-around answer for a group that wants fewer logistics, a nice resort, and one clean golf day.',
lat: 23.0949,
lng: -109.7067,
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
links: [
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
],
}),
createOption({
id: 'itinerary-splurge',
seedKey: 'itinerary-splurge',
categoryId: 'itinerary',
name: 'Splurge Track: Breathless or Secrets + Premium Golf + VIP Night',
desc: 'Best if the weekend is really about going big once, with the budget climbing above $2.2k per person depending on group size.',
lat: 23.0628,
lng: -109.6981,
details: ['8 guys: about $2,484 pp', '10 guys: about $2,346 pp', '12 guys: about $2,289 pp'],
links: [
{ label: 'Breathless', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Secrets', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
{ label: 'VIP Tables', url: 'https://www.thecaboagency.com/cabo_vip_tables.php' },
],
}),
createOption({
id: 'itinerary-concierge',
seedKey: 'itinerary-concierge',
categoryId: 'itinerary',
name: 'Concierge Route: Cabo Bash / Cabo Agency',
desc: 'Best if the group wants to stop spreadsheeting and hand flights, villas, yachts, transfers, and nightlife to a specialist.',
lat: 23.0633,
lng: -109.6992,
details: ['Most turnkey', 'Great for split budgets', 'Request quote for final pricing'],
links: [
{ label: 'Cabo Bash', url: 'https://www.cabobash.com/bachelor.html' },
{ label: 'The Cabo Agency', url: 'https://www.thecaboagency.com/cabo_bachelor_party.php' },
{ label: 'Blue Desert Package', url: 'https://www.bluedesertcabo.com/activities/packages/bachelor-party-vacation-package/' },
],
}),
createOption({
id: 'budget-option-budget',
seedKey: 'budget-option-budget',
categoryId: 'budget',
name: 'Budget Track',
desc: 'Corazon + one golf round + one public activity + one nightlife night. Best value if you want the trip fun but not financially reckless.',
details: ['8: $1,405 pp', '10: $1,398 pp', '12: $1,392 pp'],
links: [
{ label: 'Corazon', url: 'https://www.corazoncabo.com/' },
{ label: 'Palmilla', url: 'https://www.cabovillas.com/golf/palmilla' },
],
}),
createOption({
id: 'budget-option-balanced',
seedKey: 'budget-option-balanced',
categoryId: 'budget',
name: 'Balanced Track',
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.',
details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
links: [
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
],
}),
createOption({
id: 'budget-option-splurge',
seedKey: 'budget-option-splurge',
categoryId: 'budget',
name: 'Splurge Track',
desc: 'Adults-only resort, premium golf, private charter, and VIP nightlife. This is the blowout version.',
details: ['8: $2,484 pp', '10: $2,346 pp', '12: $2,289 pp'],
links: [
{ label: 'Breathless', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Private Tour', url: 'https://www.cabo-adventures.com/en/tours/private-cabo/' },
],
}),
],
voters: [],
pollsOpen: true,
};
}
function mergeSeedData(existing = {}) {
const seed = buildSeedData();
const existingOptions = Array.isArray(existing.options) ? existing.options : [];
const mergedSeedOptions = seed.options.map((seedOption) => {
const match = existingOptions.find((option) => (
option.seedKey === seedOption.seedKey
|| (option.addedBy === 'system' && option.categoryId === seedOption.categoryId && option.name === seedOption.name)
));
return {
...seedOption,
votes: Array.isArray(match?.votes) ? match.votes : [],
approved: typeof match?.approved === 'boolean' ? match.approved : seedOption.approved,
addedBy: match?.addedBy || seedOption.addedBy,
};
});
const preservedCustomOptions = existingOptions.filter((option) => {
if (option.addedBy === 'system') return false;
return !mergedSeedOptions.some((seedOption) => (
(seedOption.seedKey && option.seedKey === seedOption.seedKey)
|| (seedOption.categoryId === option.categoryId && seedOption.name === option.name)
));
});
const existingCategories = Array.isArray(existing.categories) ? existing.categories : [];
const preservedCustomCategories = existingCategories.filter(
(category) => !seed.categories.some((seedCategory) => seedCategory.id === category.id),
);
return {
...existing,
seedVersion: seed.seedVersion,
priceUpdatedAt: seed.priceUpdatedAt,
categories: [...seed.categories, ...preservedCustomCategories],
budgetScenarios: seed.budgetScenarios,
guestRoster: seed.guestRoster,
options: [...mergedSeedOptions, ...preservedCustomOptions],
voters: Array.isArray(existing.voters) ? existing.voters : [],
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,
};
}
module.exports = {
SEED_VERSION,
PRICE_UPDATED_AT,
CATEGORY_META,
buildSeedData,
mergeSeedData,
};

1120
server.js

File diff suppressed because it is too large Load Diff