Compare commits

100 Commits

Author SHA1 Message Date
TopherMayor
48e52bfa23 Refresh Cabo price watch data 2026-06-12 15:47:02 -07:00
TopherMayor
4f764e6dbb Correct Cabo trip dates to Feb 2-6 2026-06-12 13:18:43 -07:00
TopherMayor
8d6b3fbb4e Fix date-matched travel quotes 2026-06-12 11:59:03 -07:00
TopherMayor
468cb88552 Update Cabo price watch data 2026-06-12 11:44:58 -07:00
TopherMayor
ecf1859821 Align card links with price sources 2026-06-12 11:19:05 -07:00
TopherMayor
4cce703544 Fix option card readability and image loading 2026-06-12 11:13:16 -07:00
TopherMayor
fa0a7f44b7 Refactor app to React Router and Tailwind 2026-06-12 10:35:19 -07:00
TopherMayor
11f5d1b225 Update Cabo price watch report 2026-06-12 10:11:49 -07:00
TopherMayor
69f87c8b6b price-watch: refresh Cabo live pricing 2026-06-10 08:22:06 -07:00
TopherMayor
42cbe03276 Refresh Cabo price watch 2026-06-09 11:27:28 -07:00
TopherMayor
b9376e82ee Refresh Cabo price watch data 2026-06-08 15:15:59 -07:00
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
20 changed files with 3955 additions and 411 deletions

View File

@@ -32,6 +32,7 @@ System seed data auto-refreshes researched options while preserving existing vot
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. 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. 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. 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. 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. 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.

1
client/dist/assets/index-B5xoFPr6.css vendored Normal file

File diff suppressed because one or more lines are too long

18
client/dist/assets/index-CY3ZP8YS.js vendored Normal file

File diff suppressed because one or more lines are too long

13
client/dist/index.html vendored Normal file
View File

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

12
client/index.html Normal file
View File

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

6
client/postcss.config.js Normal file
View File

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

View File

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

59
client/src/index.css Normal file
View File

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

13
client/src/main.jsx Normal file
View File

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

21
client/vite.config.js Normal file
View File

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

2025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@
"description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.", "description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"dev": "vite --config client/vite.config.js --host 0.0.0.0",
"build": "vite build --config client/vite.config.js",
"start": "node server.js" "start": "node server.js"
}, },
"engines": { "engines": {
@@ -13,7 +15,18 @@
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"leaflet": "^1.9.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.17.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"ws": "^8.18.2" "ws": "^8.18.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.17",
"vite": "^8.0.16"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,49 +1,36 @@
# Cabo Price Watch # Cabo Price Watch - 2026-06-12
Checked: 2026-04-30 (America/Los_Angeles) Trip window per current watch contract: 2027-02-02 through 2027-02-06 (4 nights)
## Biggest price changes ## Biggest Changes
- Added exact-date KAYAK flights for LAX to SJD. Cheapest is $273 per person, best nonstop is $380, and Delta nonstop is $377. Source: [KAYAK LAX search](https://www.kayak.com/flights/LAX-SJD/2027-02-03/2027-02-07?sort=bestflight_a) - CheapCaribbean now exposes a live Los Cabos package ladder: Dreams Los Cabos at $893 pp, Riu Palace Cabo San Lucas at $919 pp, Breathless at $1,091 pp, Secrets at $1,073 pp, Paradisus at $1,127 pp, Grand Fiesta Americana at $1,242 pp, and Hard Rock at $1,373 pp.
- Added exact-date KAYAK flights for ONT to SJD. Cheapest is $412 per person, best is $413, and quickest is $827. Source: [KAYAK ONT search](https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a) - Cabo Bash now shows current nightlife and day-club pricing with real tiers for Bagatelle, Taboo, Mandala, La Vaquita, and El Squid Roe.
- 12-person budget scenario moved from $1,392 to $1,139 per person after replacing placeholders with live flight, hotel, golf, excursion, and nightlife inputs. - Golf pricing is still live and usable: Quivira is $360 for the current twilight window that covers February, and Palmillas public marketplace rate is $205 for twilight 18 holes.
- 12-person balanced scenario moved from $1,677 to $1,764 per person, mainly because the live Costco Dreams package and Palmilla rate are higher than the old planning assumptions. - Cabo Adventures and Cabo Villas both surfaced current water/activity pricing again, including whale watching from $76, private whale watching from $1,504, private sailing from $855.94, and the 46-foot yacht from $1,753.38.
- 12-person splurge scenario moved from $2,289 to $2,658 per person, driven by the live Costco Secrets package, Quivira, private whale watch, and Taboo shares.
## Missing prices ## Stable Anchors
- Cabo del Sol public pricing is still not visible from the official booking pages checked today. - Exact-date KAYAK flight anchors and the Hotwire Hotel Colli stay anchor remained the live standalone baselines from the previous run.
- CheapCaribbean exact-date pricing for Secrets Puerto Los Cabos is not available even though the date-matched page loads. - Current KAYAK hotel floors worth tracking are Breathless at $419/night, Secrets at $305/night, Grand Fiesta Americana at $212/night, Solmar Resort at $98/night, Grand Solmar Lands End at $138/night, Hyatt Ziva at $314/night, and JW Marriott at $331/night.
- No sold-out items surfaced in this pass.
## Sold out or unavailable ## Missing Or Gated
- Costco Travel search for LAX, Feb 3, 2027 to Feb 7, 2027 showed Corazon Cabo as Not Available. - Costco Travel package pricing remains login/continue-gated, but the Los Cabos package lineup is still visible and continues to surface Hacienda del Mar, Grand Fiesta Americana, Secrets, Grand Velas, Hilton, Le Blanc, and related options.
- Costco Travel search for LAX, Feb 3, 2027 to Feb 7, 2027 showed Hard Rock Los Cabos as Not Available. - Apple Vacations hotel/package pages continue to expose inclusions and promos, but I could not verify a clean date-matched package price for the target window from the page text available in this pass.
- The Cabo Adventures ATV page still hides the clean base price in the captured text, but it does clearly expose the mandatory $25 entrance fee and $35 damage waiver.
## Login-required sources ## New Options Worth Adding
- Costco Travel - Add CheapCaribbean package cards for Dreams, Breathless, Secrets, Paradisus, Grand Fiesta Americana, Riu Palace Cabo San Lucas, and Hard Rock.
- Add hotel-only cards for Hyatt Ziva, JW Marriott, Solmar Resort, and Grand Solmar Lands End.
- Add nightlife cards for Cabo Bash Taboo, Mandala, and Bagatelle, plus The Cabo Agencys Cabo Wabo table and booth options.
- Add excursion cards for private whale watching and the Cabo Villas private sail/yacht options.
## New options worth adding ## Budget Impact
- LAX flight tracking option on KAYAK for the Feb 3, 2027 to Feb 7, 2027 window. Cheapest $273, best nonstop $380. Source: [KAYAK LAX search](https://www.kayak.com/flights/LAX-SJD/2027-02-03/2027-02-07?sort=bestflight_a) - Budget: Dreams package + Palmilla + whale watch lands near $1,174 pp for 8 guests.
- ONT flight tracking option on KAYAK for the same dates. Cheapest $412, best $413. Source: [KAYAK ONT search](https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a) - Balanced: Breathless package + Quivira + private sail + Mandala lands near $1,853.31 pp for 10 guests.
- Hacienda del Mar Los Cabos remains worth adding as a Costco package candidate because the results flow surfaced it as a live package path with savings messaging, even though todays run did not capture a visible final total. - Splurge: Hard Rock package + Quivira + premium yacht + Taboo presidential deck lands near $2,352.45 pp for 12 guests.
- Package-vs-standalone caveat: package prices already include flight and hotel, so flight or room-only costs must not be added again unless the package explicitly excludes them.
## Current package and standalone signals
- Costco package prices carried into this run: Breathless $1,678.99 pp, Dreams $1,447.80 pp, Secrets $2,005.80 pp, Zoetry $1,717.42 pp.
- Apple Vacations exact-date package prices carried into this run include Breathless $2,016 pp, Grand Fiesta $2,111 pp, Dreams $1,757 pp, Hyatt Ziva $2,178 pp, Riu Palace Cabo San Lucas $1,529 pp, and Hard Rock $3,343 pp.
- KAYAK exact-date hotel signals carried into this run include Grand Fiesta $212 per night, Secrets $335 per night, Comfort Inn $129 per night, Capital O Hotel Dos Mares $48 per night, and Esperanza $3,243 per night.
- Public activity pricing carried into this run includes Palmilla $194.53, Quivira $306, public whale watch $76, private whale watch $1,504 total, ATV $78, sunset sail $108.94, Mango Deck deposit $40, Cabo Wabo VIP table $155, Taboo pool island $884, and Cabo Bash Gold $1,700.
## Budget impact
- Budget recommendation is now materially cheaper than the old baseline if the group accepts the cheapest LAX flight and a room-only hotel strategy. New per-person totals: 8 guys $1,150, 10 guys $1,143, 12 guys $1,139.
- Balanced recommendation stays the cleanest value package mix. New per-person totals: 8 guys $1,771, 10 guys $1,767, 12 guys $1,764.
- Splurge recommendation is now clearly premium-tier. New per-person totals: 8 guys $2,721, 10 guys $2,727, 12 guys $2,658.
## Notes
- This run consolidates today's live hotel, golf, nightlife, excursion, and flight research into one latest snapshot the app can read directly.
- Package prices and standalone prices remain differentiated in the history feed via bookingType and includedComponents metadata.

View File

@@ -9,8 +9,8 @@
"markLoginRequiredSources": true "markLoginRequiredSources": true
}, },
"tripDates": { "tripDates": {
"checkIn": "2027-02-03", "checkIn": "2027-02-02",
"checkOut": "2027-02-07", "checkOut": "2027-02-06",
"nights": 4, "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." "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."
}, },
@@ -44,8 +44,11 @@
"categories": ["flight"], "categories": ["flight"],
"bookingType": "standalone", "bookingType": "standalone",
"requiredChecks": [ "requiredChecks": [
"LAX to SJD date-matched round trip", "LAX to SJD date-matched round trip on airline and travel sites",
"ONT to SJD date-matched round trip", "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" "capture airline, stops, schedule window, baggage caveats, and total price per traveler"
] ]
}, },
@@ -164,6 +167,7 @@
"notes": [ "notes": [
"Use seed-data.js as the current baseline for names, links, and budget assumptions.", "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.", "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.", "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 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 standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",

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

View File

@@ -9,9 +9,14 @@
<style> <style>
/* ── Map tab ─────────────────────────────────────────────── */ /* ── Map tab ─────────────────────────────────────────────── */
#map-view { #map-view {
position: relative; position: sticky;
height: calc(100vh - 120px); top: 86px;
min-height: 400px; height: calc(100vh - 118px);
min-height: 560px;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: #0a0a14;
} }
#cabo-map { #cabo-map {
position: absolute; position: absolute;
@@ -136,6 +141,23 @@
min-width: 120px; min-width: 120px;
} }
#map-search-input::placeholder { color: #7a8499; } #map-search-input::placeholder { color: #7a8499; }
#flight-origin-select {
background: transparent;
border: none;
border-left: 1px solid #252a38;
color: #e0e6f0;
font-size: 0.72rem;
font-weight: 700;
padding: 8px 10px;
outline: none;
cursor: pointer;
flex-shrink: 0;
max-width: 96px;
}
#flight-origin-select option {
background: #13161f;
color: #e0e6f0;
}
.provider-tabs { .provider-tabs {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -160,6 +182,36 @@
.provider-tab.active-yelp { color: #ff6b35; background: rgba(255,107,53,0.12); } .provider-tab.active-yelp { color: #ff6b35; background: rgba(255,107,53,0.12); }
.provider-tab.active-osm { color: #fbbf24; background: rgba(251,191,36,0.10); } .provider-tab.active-osm { color: #fbbf24; background: rgba(251,191,36,0.10); }
.provider-tab.active-all { color: #00d4ff; background: rgba(0,212,255,0.08); } .provider-tab.active-all { color: #00d4ff; background: rgba(0,212,255,0.08); }
.flight-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px 10px;
border-top: 1px solid #252a38;
background: rgba(9,11,16,0.55);
}
.flight-shortcut-btn {
border: 1px solid #252a38;
background: rgba(255,255,255,0.03);
color: #c9d2e3;
font-size: 0.67rem;
font-weight: 700;
letter-spacing: 0.2px;
border-radius: 999px;
padding: 5px 9px;
cursor: pointer;
transition: all 0.15s ease;
}
.flight-shortcut-btn:hover {
border-color: #00d4ff;
color: #ffffff;
background: rgba(0,212,255,0.08);
}
.flight-shortcut-btn.primary {
border-color: rgba(0,212,255,0.45);
color: #7de3ff;
background: rgba(0,212,255,0.08);
}
#map-search-btn { #map-search-btn {
border: none; border: none;
background: transparent; background: transparent;
@@ -551,7 +603,21 @@
.tab.active .tab-count { background: rgba(0,212,255,0.15); color: var(--accent); } .tab.active .tab-count { background: rgba(0,212,255,0.15); color: var(--accent); }
/* ── Main content ──────────────────────────────────────── */ /* ── Main content ──────────────────────────────────────── */
main { flex: 1; overflow-y: auto; padding: 16px; max-width: 700px; margin: 0 auto; width: 100%; } main { flex: 1; overflow-y: auto; padding: 16px; max-width: 1440px; margin: 0 auto; width: 100%; }
.content-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 42vw);
gap: 16px;
align-items: start;
}
.list-pane,
.map-pane {
min-width: 0;
}
.mobile-view-toggle {
display: none;
}
/* ── Status bar ─────────────────────────────────────────── */ /* ── Status bar ─────────────────────────────────────────── */
.status-bar { .status-bar {
@@ -612,6 +678,32 @@
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
} }
.option-media {
display: grid;
grid-template-columns: minmax(112px, 34%) 1fr;
gap: 14px;
align-items: stretch;
}
.option-image-wrap {
min-height: 158px;
border-radius: 10px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(0,212,255,0.16), rgba(251,191,36,0.10)),
var(--surface2);
border: 1px solid rgba(255,255,255,0.06);
}
.option-image {
width: 100%;
height: 100%;
min-height: 158px;
display: block;
object-fit: cover;
filter: saturate(1.05) contrast(1.02);
}
.option-content {
min-width: 0;
}
.option-actions { .option-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -620,6 +712,25 @@
margin: 10px 0 2px; margin: 10px 0 2px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.option-book-btn {
border: 1px solid rgba(251,191,36,0.38);
background: rgba(251,191,36,0.12);
color: #ffefbf;
border-radius: 999px;
padding: 7px 12px;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.2px;
cursor: pointer;
text-decoration: none;
transition: transform 0.15s, border-color 0.2s, background 0.2s;
}
.option-book-btn:hover {
border-color: rgba(251,191,36,0.62);
background: rgba(251,191,36,0.18);
transform: translateY(-1px);
text-decoration: none;
}
.option-vote-btn { .option-vote-btn {
border: 1px solid rgba(0,212,255,0.32); border: 1px solid rgba(0,212,255,0.32);
background: rgba(0,212,255,0.10); background: rgba(0,212,255,0.10);
@@ -844,8 +955,15 @@
stroke: rgba(255, 255, 255, 0.85); stroke: rgba(255, 255, 255, 0.85);
stroke-width: 1.5; stroke-width: 1.5;
} }
.price-trend-line-back {
stroke-width: 5;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.35;
}
.price-trend-line { .price-trend-line {
stroke-width: 2.4; stroke-width: 3.2;
fill: none; fill: none;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
@@ -1397,9 +1515,54 @@
/* Main adjusts for bottom tabs */ /* Main adjusts for bottom tabs */
main { padding: 12px; max-width: 100%; } main { padding: 12px; max-width: 100%; }
.content-shell {
display: block;
}
.mobile-view-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(19,22,31,0.92);
}
.mobile-view-toggle button {
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
border-radius: 9px;
padding: 9px 10px;
font-size: 0.76rem;
font-weight: 800;
cursor: pointer;
}
.mobile-view-toggle button.active {
background: rgba(0,212,255,0.12);
border-color: rgba(0,212,255,0.35);
color: var(--accent);
}
body.mobile-map-active .list-pane,
body:not(.mobile-map-active) .map-pane {
display: none;
}
#map-view {
position: relative;
top: auto;
height: calc(100vh - 172px);
min-height: 420px;
}
/* Option cards — larger tap targets */ /* Option cards — larger tap targets */
.option-card { min-height: 72px; padding: 14px 16px; } .option-card { min-height: 72px; padding: 14px 16px; }
.option-media {
grid-template-columns: 1fr;
}
.option-image-wrap,
.option-image {
min-height: 178px;
}
.option-name { font-size: 0.92rem; } .option-name { font-size: 0.92rem; }
.option-desc { font-size: 0.76rem; } .option-desc { font-size: 0.76rem; }
.option-actions { justify-content: flex-start; } .option-actions { justify-content: flex-start; }
@@ -1513,6 +1676,13 @@
<!-- Main --> <!-- Main -->
<main> <main>
<div class="mobile-view-toggle" id="mobileViewToggle" aria-label="Mobile view toggle">
<button type="button" id="mobileListBtn" class="active" onclick="setMobileView('list')">List</button>
<button type="button" id="mobileMapBtn" onclick="setMobileView('map')">Map</button>
</div>
<div class="content-shell">
<section class="list-pane" id="listPane">
<div class="sort-bar" id="sortBar"> <div class="sort-bar" id="sortBar">
<label for="sortModeSelect">Sort by</label> <label for="sortModeSelect">Sort by</label>
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)"> <select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)">
@@ -1533,14 +1703,43 @@
<div class="empty-state"><div class="empty-emoji"></div>Loading options…</div> <div class="empty-state"><div class="empty-emoji"></div>Loading options…</div>
</div> </div>
<!-- Add option -->
<div class="add-section">
<h3> Suggest a Place</h3>
<div class="form-grid">
<input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" />
<input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" />
<textarea id="addDetails" placeholder="Details on separate lines — price, inclusions, caveats, or notes…" maxlength="500" rows="3" style="background:transparent;border:1px solid #252a38;border-radius:10px;color:#e0e6f0;padding:10px;resize:vertical;min-height:84px;"></textarea>
<input type="url" id="addUrl" placeholder="Website URL (optional)" />
<div class="btn-row">
<select id="addCategory">
<option value="hotel">🏨 Hotel</option>
<option value="flight">✈️ Flight</option>
<option value="golf">⛳ Golf</option>
<option value="nightlife">🎧 Nightlife</option>
<option value="excursion">🚤 Excursion</option>
<option value="itinerary">🗺️ Full Itinerary</option>
<option value="budget">💸 Budget Idea</option>
</select>
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
</div>
</div>
</div>
</section>
<aside class="map-pane" id="mapPane" aria-label="Map view">
<!-- Map view --> <!-- Map view -->
<div id="map-view" style="display:none;"> <div id="map-view">
<div id="cabo-map"></div> <div id="cabo-map"></div>
<div class="map-overlay"> <div class="map-overlay">
<!-- Row 1: Multi-provider search --> <!-- Row 1: Multi-provider search -->
<div id="map-search-wrap"> <div id="map-search-wrap">
<span style="color:#7a8499;font-size:0.8rem;padding-left:8px;flex-shrink:0;">🔍</span> <span style="color:#7a8499;font-size:0.8rem;padding-left:8px;flex-shrink:0;">🔍</span>
<input type="text" id="map-search-input" placeholder="Search Los Cabos…" autocomplete="off" /> <input type="text" id="map-search-input" placeholder="Search Los Cabos…" autocomplete="off" />
<select id="flight-origin-select" aria-label="Flight origin airport">
<option value="LAX">LAX</option>
<option value="ONT">ONT</option>
</select>
<div class="provider-tabs"> <div class="provider-tabs">
<button class="provider-tab active-yelp" id="tab-yelp" onclick="setProvider('yelp')">🍴 Yelp</button> <button class="provider-tab active-yelp" id="tab-yelp" onclick="setProvider('yelp')">🍴 Yelp</button>
<button class="provider-tab" id="tab-osm" onclick="setProvider('osm')">📍 OSM</button> <button class="provider-tab" id="tab-osm" onclick="setProvider('osm')">📍 OSM</button>
@@ -1548,6 +1747,14 @@
</div> </div>
<button id="map-search-btn" onclick="mapDoSearch()"></button> <button id="map-search-btn" onclick="mapDoSearch()"></button>
</div> </div>
<div class="flight-shortcuts" aria-label="Flight search shortcuts">
<button class="flight-shortcut-btn primary" onclick="quickBook('flights-google')">Google Flights</button>
<button class="flight-shortcut-btn" onclick="quickBook('flights-kayak')">KAYAK</button>
<button class="flight-shortcut-btn" onclick="quickBook('flights-united')">United</button>
<button class="flight-shortcut-btn" onclick="quickBook('flights-delta')">Delta</button>
<button class="flight-shortcut-btn" onclick="quickBook('flights-alaska')">Alaska</button>
<button class="flight-shortcut-btn" onclick="quickBook('flights-expedia')">Expedia</button>
</div>
<div id="map-search-results"></div> <div id="map-search-results"></div>
<!-- Row 2: Category filters --> <!-- Row 2: Category filters -->
<div class="map-filter-row"> <div class="map-filter-row">
@@ -1572,27 +1779,7 @@
<div class="legend-item"><div class="legend-dot" style="background:conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%);width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,0.3)"></div> All sources</div> <div class="legend-item"><div class="legend-dot" style="background:conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%);width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,0.3)"></div> All sources</div>
</div> </div>
</div> </div>
</aside>
<!-- Add option -->
<div class="add-section">
<h3> Suggest a Place</h3>
<div class="form-grid">
<input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" />
<input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" />
<input type="url" id="addUrl" placeholder="Website URL (optional)" />
<div class="btn-row">
<select id="addCategory">
<option value="hotel">🏨 Hotel</option>
<option value="flight">✈️ Flight</option>
<option value="golf">⛳ Golf</option>
<option value="nightlife">🎧 Nightlife</option>
<option value="excursion">🚤 Excursion</option>
<option value="itinerary">🗺️ Full Itinerary</option>
<option value="budget">💸 Budget Idea</option>
</select>
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
</div>
</div>
</div> </div>
</main> </main>
@@ -1618,6 +1805,8 @@
budgetScenarios: [], budgetScenarios: [],
priceUpdatedAt: '', priceUpdatedAt: '',
priceHistoryRunCount: 0, priceHistoryRunCount: 0,
tripCheckIn: '',
tripCheckOut: '',
sortMode: localStorage.getItem('cabo_sort_mode') || 'vote-desc', sortMode: localStorage.getItem('cabo_sort_mode') || 'vote-desc',
budgetGuestCount: Number(localStorage.getItem('cabo_budget_guest_count') || 0), budgetGuestCount: Number(localStorage.getItem('cabo_budget_guest_count') || 0),
priceSourceSelections: (() => { priceSourceSelections: (() => {
@@ -1638,6 +1827,7 @@
let pendingVoteOptionId = null; let pendingVoteOptionId = null;
let pendingVoteRemove = false; let pendingVoteRemove = false;
let pendingStableOptionOrder = null; let pendingStableOptionOrder = null;
const BUNDLE_TAB_ID = 'bundles';
// ── Init ─────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────
function init() { function init() {
@@ -1661,8 +1851,9 @@
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const num = parseInt(e.key); const num = parseInt(e.key);
if (num >= 1 && num <= state.categories.length) { const tabIds = getPrimaryTabIds();
setTab(state.categories[num - 1].id); if (num >= 1 && num <= tabIds.length) {
setTab(tabIds[num - 1]);
} }
}); });
@@ -1832,6 +2023,8 @@
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'init') { if (msg.type === 'init') {
state.categories = msg.categories; state.categories = msg.categories;
state.tripCheckIn = msg.tripCheckIn || '';
state.tripCheckOut = msg.tripCheckOut || '';
state.guestRoster = msg.guestRoster || []; state.guestRoster = msg.guestRoster || [];
state.options = msg.options; state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || []; state.budgetScenarios = msg.budgetScenarios || [];
@@ -1850,13 +2043,15 @@
}); });
render(); render();
if (mapInitialized) mapRefreshMarkers(); if (mapInitialized) mapRefreshMarkers();
} else if (msg.type === 'option_added' || msg.type === 'option_approved') { } else if (msg.type === 'option_added' || msg.type === 'option_approved' || msg.type === 'option_updated') {
if (!state.options.find(o => o.id === msg.option.id)) { if (!state.options.find(o => o.id === msg.option.id)) {
state.options.push(msg.option); state.options.push(msg.option);
} else {
state.options = state.options.map(o => o.id === msg.option.id ? { ...o, ...msg.option } : o);
}
renderTabs(); renderTabs();
render(); render();
if (mapInitialized) mapRefreshMarkers(); if (mapInitialized) mapRefreshMarkers();
}
} else if (msg.type === 'option_deleted') { } else if (msg.type === 'option_deleted') {
state.options = state.options.filter(o => o.id !== msg.id); state.options = state.options.filter(o => o.id !== msg.id);
renderTabs(); renderTabs();
@@ -1957,6 +2152,21 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function formatTripDate(value) {
if (!value) return '';
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function formatTripDateRange(checkIn, checkOut) {
const start = formatTripDate(checkIn);
const end = formatTripDate(checkOut);
if (!start && !end) return '';
if (start && end) return `${start} to ${end}`;
return start || end;
}
function formatBookingType(bookingType, priceBasis) { function formatBookingType(bookingType, priceBasis) {
const typeLabels = { const typeLabels = {
package: 'Package', package: 'Package',
@@ -1977,6 +2187,89 @@
return [typeLabel, basisLabel].filter(Boolean).join(' · '); return [typeLabel, basisLabel].filter(Boolean).join(' · ');
} }
function getTabDefinitions() {
const tabs = Array.isArray(state.categories) ? state.categories.map((cat) => ({ ...cat })) : [];
const bundleTab = { id: BUNDLE_TAB_ID, name: 'Bundles', emoji: '🧳' };
const hotelIndex = tabs.findIndex((cat) => cat.id === 'hotel');
if (hotelIndex >= 0) {
tabs.splice(hotelIndex + 1, 0, bundleTab);
} else {
tabs.unshift(bundleTab);
}
return tabs;
}
function getPrimaryTabIds() {
return getTabDefinitions().map((tab) => tab.id);
}
function getTabMeta(id) {
if (id === 'map') return { id: 'map', name: 'Map', emoji: '🗺️' };
return getTabDefinitions().find((tab) => tab.id === id) || { id, name: id, emoji: '📁' };
}
function isBundleSource(source) {
return String(source?.bookingType || '').toLowerCase() === 'package';
}
function isStandaloneComponentTab(tabId) {
return ['hotel', 'flight', 'golf', 'nightlife', 'excursion'].includes(tabId);
}
function isPackageLink(link) {
const haystack = `${link?.label || ''} ${link?.url || ''}`.toLowerCase();
return /\b(costco|apple vacations|cheapcaribbean|package|bundle|flight[- ]?hotel|hotel[- ]?flight)\b/.test(haystack);
}
function getVisibleLinksForTab(opt, tabId = activeTab) {
const links = Array.isArray(opt.links) ? opt.links : [];
if (tabId === BUNDLE_TAB_ID || !isStandaloneComponentTab(tabId)) {
return links;
}
return links.filter((link) => !isPackageLink(link));
}
function getAvailableSourcesForTab(opt, tabId = activeTab) {
const sources = getAvailableSources(opt);
if (tabId === BUNDLE_TAB_ID) {
return sources.filter(isBundleSource);
}
if (isStandaloneComponentTab(tabId)) {
return sources.filter((source) => !isBundleSource(source));
}
return sources;
}
function getPreferredSourceForTab(opt, tabId = activeTab) {
const sources = getAvailableSourcesForTab(opt, tabId);
if (!sources.length) return null;
return [...sources].sort((a, b) => {
const aPoints = a.pointCount || 0;
const bPoints = b.pointCount || 0;
if (aPoints !== bPoints) return bPoints - aPoints;
const aChecked = Date.parse(a.latestCheckedAt || '') || 0;
const bChecked = Date.parse(b.latestCheckedAt || '') || 0;
if (aChecked !== bChecked) return bChecked - aChecked;
return String(a.sourceLabel || '').localeCompare(String(b.sourceLabel || ''));
})[0] || null;
}
function getVisibleOptionsForTab(tabId = activeTab) {
const sourcesForTab = (opt) => getAvailableSourcesForTab(opt, tabId).length > 0;
if (tabId === BUNDLE_TAB_ID) {
return state.options.filter((opt) => opt.approved && sourcesForTab(opt));
}
return state.options.filter((opt) => opt.categoryId === tabId && opt.approved && sourcesForTab(opt));
}
function getTabOptionCount(tabId) {
if (tabId === 'map' || tabId === 'results') return '';
return String(getVisibleOptionsForTab(tabId).length);
}
function normalizeSourceKey(value) { function normalizeSourceKey(value) {
return String(value || '') return String(value || '')
.trim() .trim()
@@ -2009,31 +2302,35 @@
}]; }];
} }
function getOptionSelectedSourceKey(opt) { function getOptionSelectedSourceKey(opt, tabId = activeTab) {
const availableSources = getAvailableSources(opt); const availableSources = getAvailableSourcesForTab(opt, tabId);
if (!availableSources.length) {
return '';
}
const stored = state.priceSourceSelections?.[opt.id]; const stored = state.priceSourceSelections?.[opt.id];
const normalizedStored = stored ? normalizeSourceKey(stored) : ''; const normalizedStored = stored ? normalizeSourceKey(stored) : '';
if (normalizedStored && availableSources.some(source => source.sourceKey === normalizedStored)) { if (normalizedStored && availableSources.some(source => source.sourceKey === normalizedStored)) {
return normalizedStored; return normalizedStored;
} }
const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.defaultSourceKey || availableSources[0]?.sourceKey); const preferredKey = getPreferredSourceForTab(opt, tabId)?.sourceKey;
const defaultKey = normalizeSourceKey(preferredKey || opt.currentSourceKey || opt.defaultSourceKey || availableSources[0]?.sourceKey);
return availableSources.some(source => source.sourceKey === defaultKey) return availableSources.some(source => source.sourceKey === defaultKey)
? defaultKey ? defaultKey
: availableSources[0]?.sourceKey || 'unknown-source'; : availableSources[0]?.sourceKey || 'unknown-source';
} }
function getOptionSourceSeries(opt, sourceKey = getOptionSelectedSourceKey(opt)) { function getOptionSourceSeries(opt, sourceKey, tabId = activeTab) {
const normalizedKey = normalizeSourceKey(sourceKey); const normalizedKey = normalizeSourceKey(sourceKey || getOptionSelectedSourceKey(opt, tabId));
if (opt.priceHistoryBySource && Array.isArray(opt.priceHistoryBySource[normalizedKey])) { if (opt.priceHistoryBySource && Array.isArray(opt.priceHistoryBySource[normalizedKey])) {
return opt.priceHistoryBySource[normalizedKey]; return opt.priceHistoryBySource[normalizedKey];
} }
return Array.isArray(opt.priceHistory) ? opt.priceHistory : []; return Array.isArray(opt.priceHistory) ? opt.priceHistory : [];
} }
function getOptionSourceMeta(opt, sourceKey = getOptionSelectedSourceKey(opt)) { function getOptionSourceMeta(opt, sourceKey, tabId = activeTab) {
const normalizedKey = normalizeSourceKey(sourceKey); const normalizedKey = normalizeSourceKey(sourceKey || getOptionSelectedSourceKey(opt, tabId));
return getAvailableSources(opt).find(source => source.sourceKey === normalizedKey) || null; return getAvailableSourcesForTab(opt, tabId).find(source => source.sourceKey === normalizedKey) || null;
} }
function setOptionSource(optionId, sourceKey) { function setOptionSource(optionId, sourceKey) {
@@ -2067,8 +2364,8 @@
return new Map(state.options.map((opt, index) => [opt.id, index])); return new Map(state.options.map((opt, index) => [opt.id, index]));
} }
function getSelectedOptionPrice(opt) { function getSelectedOptionPrice(opt, tabId = activeTab) {
const selectedSeries = getOptionSourceSeries(opt); const selectedSeries = getOptionSourceSeries(opt, undefined, tabId);
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null; const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
return typeof selectedPoint?.price === 'number' ? selectedPoint.price : null; return typeof selectedPoint?.price === 'number' ? selectedPoint.price : null;
} }
@@ -2088,8 +2385,8 @@
return ascending ? aVotes - bVotes : bVotes - aVotes; return ascending ? aVotes - bVotes : bVotes - aVotes;
} }
} else { } else {
const aPrice = getSelectedOptionPrice(a); const aPrice = getSelectedOptionPrice(a, activeTab);
const bPrice = getSelectedOptionPrice(b); const bPrice = getSelectedOptionPrice(b, activeTab);
const aHasPrice = typeof aPrice === 'number'; const aHasPrice = typeof aPrice === 'number';
const bHasPrice = typeof bPrice === 'number'; const bHasPrice = typeof bPrice === 'number';
@@ -2153,6 +2450,41 @@
.filter(Boolean))]; .filter(Boolean))];
} }
function formatComponentLabel(value) {
const key = String(value || '').trim().toLowerCase();
const labels = {
flight: 'Flight',
flights: 'Flight',
hotel: 'Hotel',
hoteltransfer: 'Hotel transfer',
hoteltransfers: 'Hotel transfers',
transfer: 'Transfer',
transfers: 'Transfers',
golf: 'Golf',
nightlife: 'Nightlife',
excursion: 'Excursion',
incidentals: 'Incidentals',
amenities: 'Amenities',
};
if (labels[key]) return labels[key];
return String(value || '')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function renderComponentChips(items) {
const chips = normalizeTextList(items).map(formatComponentLabel);
if (!chips.length) return '';
return `
<div class="option-detail-list">
${chips.map(item => `<span class="option-detail-chip">${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
function renderTextChips(items) { function renderTextChips(items) {
const chips = normalizeTextList(items); const chips = normalizeTextList(items);
if (!chips.length) return ''; if (!chips.length) return '';
@@ -2172,16 +2504,17 @@
function getPointContext(point) { function getPointContext(point) {
if (!point) return ''; if (!point) return '';
const tripDates = formatTripDateRange(point.tripCheckIn, point.tripCheckOut);
const unitContext = point.tripNights && point.unitPrice const unitContext = point.tripNights && point.unitPrice
? `${formatCurrency(point.unitPrice, point.currency || 'USD')} x ${point.tripNights} nights` ? `${formatCurrency(point.unitPrice, point.currency || 'USD')} x ${point.tripNights} nights`
: ''; : '';
return [unitContext, point.unitDisplayPrice || point.displayPrice || point.decisionNote || ''] return [tripDates ? `Dates: ${tripDates}` : '', unitContext, point.unitDisplayPrice || point.displayPrice || point.decisionNote || '']
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
} }
function renderSourceSelect(opt) { function renderSourceSelect(opt) {
const sources = getAvailableSources(opt); const sources = getAvailableSourcesForTab(opt);
if (sources.length <= 1) { if (sources.length <= 1) {
const source = sources[0] || null; const source = sources[0] || null;
const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source'; const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
@@ -2212,11 +2545,15 @@
} }
function renderOptionFacts(opt) { function renderOptionFacts(opt) {
const availableSources = getAvailableSources(opt); const tabId = activeTab;
const selectedSourceKey = getOptionSelectedSourceKey(opt); const availableSources = getAvailableSourcesForTab(opt, tabId);
const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey); const selectedSourceKey = getOptionSelectedSourceKey(opt, tabId);
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey); const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey, tabId);
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null; const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey, tabId);
const selectedPoint = selectedSeries.at(-1) || ((tabId === BUNDLE_TAB_ID || !isStandaloneComponentTab(tabId)) ? opt.latestPricePoint : null);
const tripCheckIn = selectedPoint?.tripCheckIn || opt.latestPricePoint?.tripCheckIn || state.tripCheckIn || '';
const tripCheckOut = selectedPoint?.tripCheckOut || opt.latestPricePoint?.tripCheckOut || state.tripCheckOut || '';
const tripDates = formatTripDateRange(tripCheckIn, tripCheckOut);
const insights = selectedPoint ? { const insights = selectedPoint ? {
source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed', source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null, sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
@@ -2226,7 +2563,16 @@
decisionNote: selectedPoint.decisionNote || null, decisionNote: selectedPoint.decisionNote || null,
displayPrice: selectedPoint.displayPrice || null, displayPrice: selectedPoint.displayPrice || null,
currency: selectedPoint.currency || 'USD', currency: selectedPoint.currency || 'USD',
} : (opt.automationInsights || {}); } : {
source: selectedMeta?.sourceLabel || opt.automationInsights?.source || 'Automation feed',
sourceUrl: selectedMeta?.sourceUrl || opt.automationInsights?.sourceUrl || null,
bookingType: selectedMeta?.bookingType || null,
priceBasis: selectedMeta?.priceBasis || null,
availability: null,
decisionNote: null,
displayPrice: null,
currency: 'USD',
};
const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : ''; const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : '';
const priceLabel = currentPrice || insights.displayPrice || 'Not yet tracked'; const priceLabel = currentPrice || insights.displayPrice || 'Not yet tracked';
const priceContext = getPointContext(selectedPoint); const priceContext = getPointContext(selectedPoint);
@@ -2236,12 +2582,20 @@
selectedMeta?.priceBasis || insights.priceBasis, selectedMeta?.priceBasis || insights.priceBasis,
); );
const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search'; const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search';
const overviewItems = normalizeTextList(opt.details); const packageDetailPattern = /\b(package|bundle|bundled|costco|apple|flight[- ]?included|transfer[- ]included|flight\+hotel|hotel\+flight)\b/i;
const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights); const filterStandaloneDetails = (items) => (tabId === BUNDLE_TAB_ID || !isStandaloneComponentTab(tabId))
const features = normalizeTextList(selectedPoint?.features || opt.automationInsights?.features); ? normalizeTextList(items)
const amenities = normalizeTextList(selectedPoint?.amenities || opt.automationInsights?.amenities); : normalizeTextList(items).filter((item) => !packageDetailPattern.test(item));
const inclusions = normalizeTextList(selectedPoint?.inclusions || opt.automationInsights?.inclusions); const overviewItems = filterStandaloneDetails(opt.details);
const limitations = normalizeTextList(selectedPoint?.limitations || opt.automationInsights?.limitations); const autoHighlights = filterStandaloneDetails(selectedPoint?.highlights || opt.automationInsights?.highlights);
const features = filterStandaloneDetails(selectedPoint?.features || opt.automationInsights?.features);
const amenities = filterStandaloneDetails(selectedPoint?.amenities || opt.automationInsights?.amenities);
const inclusions = filterStandaloneDetails(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
const limitations = filterStandaloneDetails(selectedPoint?.limitations || opt.automationInsights?.limitations);
const includedComponents = normalizeTextList(selectedPoint?.includedComponents || opt.automationInsights?.includedComponents);
const excludedComponents = normalizeTextList(selectedPoint?.excludedComponents || opt.automationInsights?.excludedComponents);
const packageHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
const packageInclusions = normalizeTextList(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
const sourceMetaLine = selectedMeta const sourceMetaLine = selectedMeta
? [formatSourcePrice(selectedMeta) || priceLabel, selectedMeta.pointCount ? `${selectedMeta.pointCount} point${selectedMeta.pointCount === 1 ? '' : 's'}` : ''] ? [formatSourcePrice(selectedMeta) || priceLabel, selectedMeta.pointCount ? `${selectedMeta.pointCount} point${selectedMeta.pointCount === 1 ? '' : 's'}` : '']
.filter(Boolean) .filter(Boolean)
@@ -2270,6 +2624,10 @@
<span class="option-fact-label">Status</span> <span class="option-fact-label">Status</span>
<div class="option-fact-value">${escapeHtml(statusLabel)}</div> <div class="option-fact-value">${escapeHtml(statusLabel)}</div>
</div> </div>
<div class="option-fact">
<span class="option-fact-label">Dates</span>
<div class="option-fact-value">${escapeHtml(tripDates || 'Not specified')}</div>
</div>
</div> </div>
`); `);
@@ -2316,6 +2674,42 @@
`); `);
} }
if (tabId === BUNDLE_TAB_ID && includedComponents.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">What's included</div>
${renderComponentChips(includedComponents)}
</div>
`);
}
if (tabId === BUNDLE_TAB_ID && excludedComponents.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Not included</div>
${renderComponentChips(excludedComponents)}
</div>
`);
}
if (tabId === BUNDLE_TAB_ID && packageHighlights.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Package perks</div>
${renderTextChips(packageHighlights)}
</div>
`);
}
if (tabId === BUNDLE_TAB_ID && packageInclusions.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Package inclusions</div>
${renderTextChips(packageInclusions)}
</div>
`);
}
if (limitations.length) { if (limitations.length) {
sections.push(` sections.push(`
<div class="option-detail-section"> <div class="option-detail-section">
@@ -2331,10 +2725,11 @@
} }
function renderPriceTrend(opt) { function renderPriceTrend(opt) {
const selectedSourceKey = getOptionSelectedSourceKey(opt); const tabId = activeTab;
const series = getOptionSourceSeries(opt, selectedSourceKey) const selectedSourceKey = getOptionSelectedSourceKey(opt, tabId);
const series = getOptionSourceSeries(opt, selectedSourceKey, tabId)
.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price)); .filter(point => typeof point.price === 'number' && !Number.isNaN(point.price));
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey); const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey, tabId);
if (series.length === 0) { if (series.length === 0) {
return ` return `
@@ -2408,6 +2803,7 @@
</linearGradient> </linearGradient>
</defs> </defs>
<path d="${areaPath}" fill="url(#priceTrendFill-${chartKey})" opacity="0.8"></path> <path d="${areaPath}" fill="url(#priceTrendFill-${chartKey})" opacity="0.8"></path>
<path d="${path}" class="price-trend-line-back" stroke="rgba(255,255,255,0.22)"></path>
<path d="${path}" class="price-trend-line" stroke="url(#priceTrendStroke-${chartKey})"></path> <path d="${path}" class="price-trend-line" stroke="url(#priceTrendStroke-${chartKey})"></path>
${points.map((point, index) => ` ${points.map((point, index) => `
<circle class="price-trend-points" cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="${index === points.length - 1 ? 3.4 : 2.4}"> <circle class="price-trend-points" cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="${index === points.length - 1 ? 3.4 : 2.4}">
@@ -2518,7 +2914,7 @@
// ── Tabs ─────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────
function renderTabs() { function renderTabs() {
const bar = document.getElementById('tabsBar'); const bar = document.getElementById('tabsBar');
const catsWithMap = [...state.categories, { id: 'map', name: 'Map', emoji: '🗺️' }]; const catsWithMap = [...getTabDefinitions(), { id: 'map', name: 'Map', emoji: '🗺️' }];
bar.innerHTML = catsWithMap.map(cat => ` bar.innerHTML = catsWithMap.map(cat => `
<div class="tab${cat.id === activeTab ? ' active' : ''}" <div class="tab${cat.id === activeTab ? ' active' : ''}"
role="tab" role="tab"
@@ -2530,15 +2926,15 @@
onkeydown="handleTabKey(event, '${cat.id}')"> onkeydown="handleTabKey(event, '${cat.id}')">
<span class="tab-emoji">${cat.emoji}</span> <span class="tab-emoji">${cat.emoji}</span>
${cat.name} ${cat.name}
<span class="tab-count" id="tab-count-${cat.id}">${cat.id === 'results' ? '' : cat.id === 'map' ? '' : state.options.filter(o => o.categoryId === cat.id).length}</span> <span class="tab-count" id="tab-count-${cat.id}">${getTabOptionCount(cat.id)}</span>
</div> </div>
`).join(''); `).join('');
bar.setAttribute('role', 'tablist'); bar.setAttribute('role', 'tablist');
bar.setAttribute('aria-label', 'Voting categories'); bar.setAttribute('aria-label', 'Trip tabs');
} }
function handleTabKey(event, catId) { function handleTabKey(event, catId) {
const cats = [...state.categories.map(c => c.id), 'map']; const cats = [...getPrimaryTabIds(), 'map'];
const idx = cats.indexOf(catId); const idx = cats.indexOf(catId);
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
@@ -2571,7 +2967,7 @@
optionsList.style.display = 'block'; optionsList.style.display = 'block';
if (addSection) addSection.style.display = ''; if (addSection) addSection.style.display = '';
} }
document.getElementById('optionsList')?.setAttribute('aria-label', state.categories.find(c => c.id === id)?.name + ' options'); document.getElementById('optionsList')?.setAttribute('aria-label', `${getTabMeta(id)?.name || id} options`);
} }
// ── Render options ──────────────────────────────────────── // ── Render options ────────────────────────────────────────
@@ -2640,13 +3036,16 @@
} }
// ── Regular voting tabs ───────────────────────────────── // ── Regular voting tabs ─────────────────────────────────
const opts = state.options.filter(o => o.categoryId === activeTab && o.approved); const opts = getVisibleOptionsForTab(activeTab);
document.getElementById('totalVotersCount').textContent = document.getElementById('totalVotersCount').textContent =
state.totalVoters ? `👥 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''}` : ''; state.totalVoters ? `👥 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''}` : '';
if (opts.length === 0) { if (opts.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-emoji">🗳️</div>No options yet. Be the first to add one below!</div>`; const emptyCopy = activeTab === BUNDLE_TAB_ID
? 'No bundle options yet. Check back after the next live pricing run.'
: 'No options yet. Be the first to add one below!';
list.innerHTML = `<div class="empty-state"><div class="empty-emoji">🗳️</div>${emptyCopy}</div>`;
return; return;
} }
@@ -2660,11 +3059,14 @@
const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0; const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0;
const hasVoted = state.voterName && voteEntries.some(v => v.name === state.voterName); const hasVoted = state.voterName && voteEntries.some(v => v.name === state.voterName);
const voteList = voteEntries.map(v => v.name).join(', '); const voteList = voteEntries.map(v => v.name).join(', ');
const linkPills = opt.links && opt.links.length const visibleLinks = getVisibleLinksForTab(opt, activeTab);
? `<div class="option-links">${opt.links.map(link => ` const linkPills = visibleLinks.length
? `<div class="option-links">${visibleLinks.map(link => `
<a href="${link.url}" target="_blank" rel="noopener noreferrer" class="option-pill" onclick="event.stopPropagation()">${link.label}</a> <a href="${link.url}" target="_blank" rel="noopener noreferrer" class="option-pill" onclick="event.stopPropagation()">${link.label}</a>
`).join('')}</div>` `).join('')}</div>`
: (opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : ''); : (opt.url && !isPackageLink({ label: opt.name, url: opt.url })
? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>`
: '');
return ` return `
<div class="option-card${hasVoted ? ' voted' : ''}" data-option-id="${escapeHtml(opt.id)}"> <div class="option-card${hasVoted ? ' voted' : ''}" data-option-id="${escapeHtml(opt.id)}">
@@ -2909,6 +3311,7 @@
function submitNewOption() { function submitNewOption() {
const name = document.getElementById('addName').value.trim(); const name = document.getElementById('addName').value.trim();
const desc = document.getElementById('addDesc').value.trim(); const desc = document.getElementById('addDesc').value.trim();
const details = document.getElementById('addDetails').value.trim();
const url = document.getElementById('addUrl').value.trim(); const url = document.getElementById('addUrl').value.trim();
const catId = document.getElementById('addCategory').value; const catId = document.getElementById('addCategory').value;
@@ -2921,11 +3324,12 @@
return; return;
} }
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName, authToken: state.guestAuthToken }); wsSend({ type: 'add_option', categoryId: catId, name, desc, details, url, voterName: state.voterName, authToken: state.guestAuthToken });
// Clear form // Clear form
document.getElementById('addName').value = ''; document.getElementById('addName').value = '';
document.getElementById('addDesc').value = ''; document.getElementById('addDesc').value = '';
document.getElementById('addDetails').value = '';
document.getElementById('addUrl').value = ''; document.getElementById('addUrl').value = '';
showToast(`Submitted "${name}" for approval!`, 'success'); showToast(`Submitted "${name}" for approval!`, 'success');
} }
@@ -3128,10 +3532,34 @@
function quickBook(type) { function quickBook(type) {
const q = (document.getElementById('map-search-input').value || '').trim() || 'Los Cabos Mexico'; const q = (document.getElementById('map-search-input').value || '').trim() || 'Los Cabos Mexico';
const originSelect = document.getElementById('flight-origin-select');
const origin = originSelect?.value || (/ont\b/i.test(q) ? 'ONT' : /lax\b/i.test(q) ? 'LAX' : 'LAX');
const depart = '2027-02-02';
const ret = '2027-02-06';
const googleFlightsQuery = `Flights from ${origin} to SJD on ${depart} to ${ret}`;
const expediaFlightUrl = `https://www.expedia.com/Flights-Search?trip=roundtrip&leg1=from:${origin},to:SJD,departure:${depart}TANYT&leg2=from:SJD,to:${origin},departure:${ret}TANYT&passengers=adults:1&options=cabinclass:economy&mode=search`;
let url; let url;
switch(type) { switch(type) {
case 'gmaps': url = `https://www.google.com/maps/search/${encodeURIComponent(q)}`; break; case 'gmaps': url = `https://www.google.com/maps/search/${encodeURIComponent(q)}`; break;
case 'flights': url = `https://www.google.com/travel/flights/search?q=${encodeURIComponent(q)}&tfpla=on`; break; case 'flights':
case 'flights-google':
url = `https://www.google.com/travel/flights/search?q=${encodeURIComponent(googleFlightsQuery)}&tfpla=on`;
break;
case 'flights-kayak':
url = `https://www.kayak.com/flights/${origin}-SJD/${depart}/${ret}?sort=bestflight_a`;
break;
case 'flights-expedia':
url = expediaFlightUrl;
break;
case 'flights-united':
url = `https://www.united.com/en-us/flights?f=1&from=${origin}&to=SJD&d=${depart}&tt=${ret}&px=1`;
break;
case 'flights-delta':
url = `https://www.delta.com/flight-search/search?tripType=roundtrip&departureAirportCode=${origin}&arrivalAirportCode=SJD&departureDate=${depart}&returnDate=${ret}&adultPassengerCount=1`;
break;
case 'flights-alaska':
url = `https://www.alaskaair.com/`;
break;
case 'hotels': url = `https://www.kayak.com/hotels/${encodeURIComponent(q)}/2admins`; break; case 'hotels': url = `https://www.kayak.com/hotels/${encodeURIComponent(q)}/2admins`; break;
case 'viator': url = `https://www.viator.com/search/${encodeURIComponent(q)}`; break; case 'viator': url = `https://www.viator.com/search/${encodeURIComponent(q)}`; break;
case 'expedia': url = `https://www.expedia.com/Thotel-Search?destination=${encodeURIComponent(q)}`; break; case 'expedia': url = `https://www.expedia.com/Thotel-Search?destination=${encodeURIComponent(q)}`; break;

View File

@@ -1,5 +1,5 @@
const SEED_VERSION = 6; const SEED_VERSION = 7;
const PRICE_UPDATED_AT = '2026-04-29'; const PRICE_UPDATED_AT = '2026-05-01';
const CATEGORY_META = { const CATEGORY_META = {
hotel: { emoji: '🏨', color: '#3b82f6' }, hotel: { emoji: '🏨', color: '#3b82f6' },
@@ -176,6 +176,12 @@ const BUDGET_SCENARIOS = [
}, },
]; ];
function buildOptionImageUrl(option) {
if (option.imageUrl) return option.imageUrl;
const primaryUrl = option.links?.[0]?.url || option.url || option.bookingUrl || '';
return primaryUrl ? `/api/preview-image?url=${encodeURIComponent(primaryUrl)}` : null;
}
function createOption(option) { function createOption(option) {
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888'; const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
const primaryUrl = option.links?.[0]?.url || option.url || null; const primaryUrl = option.links?.[0]?.url || option.url || null;
@@ -187,9 +193,13 @@ function createOption(option) {
links: [], links: [],
categoryColor, categoryColor,
url: primaryUrl, url: primaryUrl,
bookingUrl: option.bookingUrl || primaryUrl,
imageUrl: buildOptionImageUrl(option),
...option, ...option,
categoryColor, categoryColor,
url: primaryUrl, url: primaryUrl,
bookingUrl: option.bookingUrl || primaryUrl,
imageUrl: buildOptionImageUrl(option),
}; };
} }
@@ -238,7 +248,7 @@ function buildSeedData() {
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' }, { label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' }, { label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' }, { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-03' }, { label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-02' },
], ],
}), }),
createOption({ createOption({
@@ -253,7 +263,7 @@ function buildSeedData() {
links: [ links: [
{ label: 'Official', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' }, { label: 'Official', url: 'https://www.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: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=4&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=2027-02-03&vendorcode=APV' }, { label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=4&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=2027-02-02&vendorcode=APV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' }, { label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
], ],
}), }),
@@ -269,7 +279,7 @@ function buildSeedData() {
links: [ links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' }, { label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' }, { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-03&vendorcode=CCV' }, { label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-02&vendorcode=CCV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' }, { label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
], ],
}), }),
@@ -452,6 +462,28 @@ function buildSeedData() {
{ label: 'Auberge', url: 'https://aubergeresorts.com/esperanza/' }, { label: 'Auberge', url: 'https://aubergeresorts.com/esperanza/' },
], ],
}), }),
createOption({
id: 'flight-ont-sjd-kayak-cheapest',
seedKey: 'flight-ont-sjd-kayak-cheapest',
categoryId: 'flight',
name: 'ONT to SJD on KAYAK - Cheapest',
desc: 'Lowest exact-date round-trip flight quote from Ontario to San Jose del Cabo for the target trip window.',
details: ['$402 RT pp', 'Volaris, 1 stop', 'Best budget-flight anchor'],
links: [
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
],
}),
createOption({
id: 'flight-ont-sjd-kayak-best',
seedKey: 'flight-ont-sjd-kayak-best',
categoryId: 'flight',
name: 'ONT to SJD on KAYAK - Best',
desc: 'Higher-comfort exact-date round-trip flight quote for the target trip window.',
details: ['$605 RT pp', 'American, 1 stop', 'Best-value comfort anchor'],
links: [
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
],
}),
createOption({ createOption({
id: 'golf-palmilla', id: 'golf-palmilla',
seedKey: 'golf-palmilla', seedKey: 'golf-palmilla',

150
server.js
View File

@@ -12,6 +12,9 @@ const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
const PUBLIC_DIR = path.join(__dirname, 'public');
const CLIENT_DIST_DIR = path.join(__dirname, 'client', 'dist');
const CLIENT_INDEX_FILE = path.join(CLIENT_DIST_DIR, 'index.html');
const DEFAULT_DATA_DIR = path.join(__dirname, 'data'); const DEFAULT_DATA_DIR = path.join(__dirname, 'data');
const DATA_DIR = process.env.DATA_DIR const DATA_DIR = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR) ? path.resolve(process.env.DATA_DIR)
@@ -23,17 +26,22 @@ const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
? path.resolve(process.env.PRICE_HISTORY_FILE) ? path.resolve(process.env.PRICE_HISTORY_FILE)
: DEFAULT_PRICE_HISTORY_FILE; : DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03'; const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-02';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07'; const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-06';
const PREVIEW_IMAGE_CACHE_MS = 1000 * 60 * 60 * 24;
const previewImageCache = new Map();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.get('/admin', (req, res) => { app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html')); res.sendFile(path.join(PUBLIC_DIR, 'admin.html'));
}); });
app.use(express.static(path.join(__dirname, 'public'))); if (fs.existsSync(CLIENT_INDEX_FILE)) {
app.use(express.static(CLIENT_DIST_DIR));
}
app.use(express.static(PUBLIC_DIR));
function loadData() { function loadData() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -78,6 +86,48 @@ function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source'; return String(value || 'Unknown source').trim() || 'Unknown source';
} }
function parseHttpUrl(value) {
try {
const url = new URL(String(value || '').trim());
if (!['http:', 'https:'].includes(url.protocol)) return null;
return url;
} catch {
return null;
}
}
function resolveHtmlUrl(value, baseUrl) {
if (!value) return null;
try {
const resolved = new URL(value, baseUrl);
if (!['http:', 'https:'].includes(resolved.protocol)) return null;
return resolved.toString();
} catch {
return null;
}
}
function extractPreviewImage(html, pageUrl) {
const patterns = [
/<meta[^>]+property=["']og:image:secure_url["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:secure_url["'][^>]*>/i,
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["'][^>]*>/i,
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["'][^>]*>/i,
/<link[^>]+rel=["'][^"']*image_src[^"']*["'][^>]+href=["']([^"']+)["'][^>]*>/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*image_src[^"']*["'][^>]*>/i,
];
for (const pattern of patterns) {
const match = html.match(pattern);
const imageUrl = resolveHtmlUrl(match?.[1], pageUrl);
if (imageUrl) return imageUrl;
}
return null;
}
function formatCurrencyValue(value, currency = 'USD') { function formatCurrencyValue(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return ''; if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
@@ -463,8 +513,8 @@ function loadPriceHistoryState() {
unitPrice: tripPrice.unitPrice, unitPrice: tripPrice.unitPrice,
tripTotalPrice: tripPrice.tripTotalPrice, tripTotalPrice: tripPrice.tripTotalPrice,
tripNights: tripPrice.tripNights, tripNights: tripPrice.tripNights,
tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null, tripCheckIn: point.tripCheckIn || defaults.tripCheckIn || (tripPrice.tripNights ? TRIP_CHECK_IN : null),
tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null, tripCheckOut: point.tripCheckOut || defaults.tripCheckOut || (tripPrice.tripNights ? TRIP_CHECK_OUT : null),
currency, currency,
displayPrice: tripPrice.displayPrice, displayPrice: tripPrice.displayPrice,
unitDisplayPrice: rawDisplayPrice, unitDisplayPrice: rawDisplayPrice,
@@ -573,7 +623,7 @@ function buildPriceHistoryBySource(priceHistory) {
return { return {
sourceKey: bucket.sourceKey, sourceKey: bucket.sourceKey,
sourceLabel: bucket.sourceLabel, sourceLabel: bucket.sourceLabel,
sourceUrl: bucket.sourceUrl, sourceUrl: latestPoint?.sourceUrl || bucket.sourceUrl,
bookingType: latestPoint?.bookingType || null, bookingType: latestPoint?.bookingType || null,
priceBasis: latestPoint?.priceBasis || null, priceBasis: latestPoint?.priceBasis || null,
pointCount: bucket.points.length, pointCount: bucket.points.length,
@@ -691,6 +741,8 @@ function buildRealtimeSnapshot() {
type: 'init', type: 'init',
pollsOpen: data.pollsOpen, pollsOpen: data.pollsOpen,
categories: data.categories, categories: data.categories,
tripCheckIn: TRIP_CHECK_IN,
tripCheckOut: TRIP_CHECK_OUT,
guestRoster: getGuestRoster().map((guest) => ({ guestRoster: getGuestRoster().map((guest) => ({
name: guest.name, name: guest.name,
role: guest.role || 'guest', role: guest.role || 'guest',
@@ -704,7 +756,20 @@ function buildRealtimeSnapshot() {
}; };
} }
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) { function normalizeDetails(details) {
if (Array.isArray(details)) {
return details.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof details === 'string') {
return details
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved, details }) {
return { return {
id: uuidv4(), id: uuidv4(),
seedKey: null, seedKey: null,
@@ -718,7 +783,7 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
addedBy: voterName, addedBy: voterName,
approved, approved,
votes: [], votes: [],
details: [], details: normalizeDetails(details),
categoryColor: CATEGORY_META[categoryId]?.color || '#888', categoryColor: CATEGORY_META[categoryId]?.color || '#888',
}; };
} }
@@ -779,6 +844,41 @@ app.get('/api/options', (req, res) => {
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState)); res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
}); });
app.get('/api/preview-image', async (req, res) => {
const pageUrl = parseHttpUrl(req.query.url);
if (!pageUrl) return res.status(400).send('A valid http(s) url is required');
const cacheKey = pageUrl.toString();
const cached = previewImageCache.get(cacheKey);
if (cached && Date.now() - cached.checkedAt < PREVIEW_IMAGE_CACHE_MS) {
return res.redirect(302, cached.imageUrl);
}
const fallbackUrl = new URL('/favicon.ico', pageUrl.origin).toString();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(pageUrl, {
signal: controller.signal,
headers: {
Accept: 'text/html,application/xhtml+xml',
'User-Agent': 'Mozilla/5.0 (compatible; CaboVotePreview/1.0)',
},
});
clearTimeout(timeout);
if (!response.ok) throw new Error(`Preview fetch failed: ${response.status}`);
const html = await response.text();
const imageUrl = extractPreviewImage(html.slice(0, 250000), pageUrl.toString()) || fallbackUrl;
previewImageCache.set(cacheKey, { imageUrl, checkedAt: Date.now() });
return res.redirect(302, imageUrl);
} catch (error) {
previewImageCache.set(cacheKey, { imageUrl: fallbackUrl, checkedAt: Date.now() });
return res.redirect(302, fallbackUrl);
}
});
app.get('/api/results', (req, res) => { app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState(); const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || []; const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
@@ -877,7 +977,7 @@ app.delete('/api/vote/:optionId', (req, res) => {
}); });
app.post('/api/options', (req, res) => { app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, lat, lng } = req.body; const { categoryId, name, desc, url, lat, lng, details } = req.body;
const guest = requireGuestAuth(req, res, req.body); const guest = requireGuestAuth(req, res, req.body);
if (!categoryId || !name) { if (!categoryId || !name) {
@@ -896,6 +996,7 @@ app.post('/api/options', (req, res) => {
voterName: guest.name, voterName: guest.name,
lat, lat,
lng, lng,
details,
approved: false, approved: false,
}); });
@@ -915,6 +1016,25 @@ app.post('/api/options/:id/approve', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
app.patch('/api/options/:id', (req, res) => {
const option = data.options.find((candidate) => candidate.id === req.params.id);
if (!option) return res.status(404).json({ error: 'Not found' });
const { categoryId, name, desc, url, lat, lng, details, approved } = req.body;
if (categoryId !== undefined) option.categoryId = categoryId;
if (name !== undefined) option.name = String(name || '').trim();
if (desc !== undefined) option.desc = String(desc || '').trim();
if (url !== undefined) option.url = String(url || '').trim() || null;
if (lat !== undefined) option.lat = Number.isFinite(Number(lat)) ? Number(lat) : null;
if (lng !== undefined) option.lng = Number.isFinite(Number(lng)) ? Number(lng) : null;
if (details !== undefined) option.details = normalizeDetails(details);
if (approved !== undefined) option.approved = Boolean(approved);
saveData(data);
broadcast({ type: 'option_updated', option });
res.json({ success: true, option });
});
app.delete('/api/options/:id', (req, res) => { app.delete('/api/options/:id', (req, res) => {
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id); const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' }); if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
@@ -986,6 +1106,16 @@ app.get('/api/yelp', async (req, res) => {
} }
}); });
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path === '/admin') {
return next();
}
if (!fs.existsSync(CLIENT_INDEX_FILE)) {
return next();
}
return res.sendFile(CLIENT_INDEX_FILE);
});
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
ws.send(JSON.stringify(buildRealtimeSnapshot())); ws.send(JSON.stringify(buildRealtimeSnapshot()));

20
tailwind.config.js Normal file
View File

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