Compare commits

83 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
18 changed files with 3369 additions and 425 deletions

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

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.",
"main": "server.js",
"scripts": {
"dev": "vite --config client/vite.config.js --host 0.0.0.0",
"build": "vite build --config client/vite.config.js",
"start": "node server.js"
},
"engines": {
@@ -13,7 +15,18 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"leaflet": "^1.9.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.17.0",
"uuid": "^11.1.0",
"ws": "^8.18.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.17",
"vite": "^8.0.16"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,106 +1,36 @@
# Cabo Price Watch
# Cabo Price Watch - 2026-06-12
Checked at: 2026-05-01T18:44:43Z
Trip window per current watch contract: 2027-02-02 through 2027-02-06 (4 nights)
## Biggest Changes
- `Comfort Inn Suites Los Cabos` improved to `$75/night` (`$300` for 4 nights), down from the earlier `$129/night` exact-date anchor.
- CheapCaribbean now exposes a live Los Cabos package ladder: Dreams Los Cabos at $893 pp, Riu Palace Cabo San Lucas at $919 pp, Breathless at $1,091 pp, Secrets at $1,073 pp, Paradisus at $1,127 pp, Grand Fiesta Americana at $1,242 pp, and Hard Rock at $1,373 pp.
- Cabo Bash now shows current nightlife and day-club pricing with real tiers for Bagatelle, Taboo, Mandala, La Vaquita, and El Squid Roe.
- Golf pricing is still live and usable: Quivira is $360 for the current twilight window that covers February, and Palmillas public marketplace rate is $205 for twilight 18 holes.
- Cabo Adventures and Cabo Villas both surfaced current water/activity pricing again, including whale watching from $76, private whale watching from $1,504, private sailing from $855.94, and the 46-foot yacht from $1,753.38.
- `Cabo Real` now shows `$260` morning / `$215` early twilight / `$190` twilight, and `Quivira` now shows `$380` prime / `$323` mid / `$275` twilight. Both are higher than the earlier public anchors.
## Stable Anchors
- New nightlife packages surfaced: `Bagatelle` deposit-per-person access, `El Squid Roe` 12-guest package at `$1,275`, and `Mandala` tiered table pricing starting at `$300`.
- Exact-date KAYAK flight anchors and the Hotwire Hotel Colli stay anchor remained the live standalone baselines from the previous run.
- Current KAYAK hotel floors worth tracking are Breathless at $419/night, Secrets at $305/night, Grand Fiesta Americana at $212/night, Solmar Resort at $98/night, Grand Solmar Lands End at $138/night, Hyatt Ziva at $314/night, and JW Marriott at $331/night.
- No sold-out items surfaced in this pass.
- New premium excursions surfaced: `Cabo Adventures` gray-whale expedition from `$553 pp`, `Cabo Villas` sailing from `$109 pp`, and private yacht options from roughly `$778` to `$1,276` depending boat size.
## Current Anchors
### Flights
- `LAX -> SJD` cheapest KAYAK exact-date result: `$273 pp` (`Viva`, 1 stop).
- `LAX -> SJD` best KAYAK exact-date result: `$380 pp` (`Alaska`, nonstop).
- `LAX -> SJD` Delta nonstop alternative: `$377 pp`.
- `ONT -> SJD` cheapest KAYAK exact-date result: `$412 pp` (`Volaris`, 1 stop).
- `ONT -> SJD` best KAYAK exact-date result: `$413 pp` (`Volaris`, 1 stop).
- `ONT -> SJD` quickest KAYAK exact-date result: `$827 pp` (`United`, 1 stop).
### Hotels
- Cheapest room-only floor: `Capital O Hotel Dos Mares` at `$48/night`.
- `Comfort Inn Suites Los Cabos`: `$75/night` (`$300` total for 4 nights).
- `Breathless Cabo San Lucas`: `$393/night`.
- `Grand Fiesta Americana Los Cabos`: `$212/night`.
- `Secrets Puerto Los Cabos`: `$335/night`.
- `Zoetry Casa del Mar`: `$337/night`.
### Packages
- `Costco Dreams Los Cabos`: `$1,448.62 pp` / `$2,897.24 total` including flight + hotel + transfers.
- `Costco Breathless`: `$1,679.81 pp` / `$3,359.63 total` including flight + hotel + transfers.
- `Costco Zoetry`: `$1,718.24 pp` / `$3,436.49 total` including flight + hotel + transfers.
- `Costco Secrets`: `$2,006.62 pp` / `$4,013.24 total` including flight + hotel + transfers.
- Apple exact-date package anchors from the latest captured run range from `ME Cabo` at `$1,533 pp` up to `Hard Rock` at `$3,343 pp`.
## Missing or Gated
- Costco Travel exact-price quote flow was not re-rendered in this text-only pass; the report uses the latest exact-date quotes captured earlier today.
- CheapCaribbean exact-date package search did not surface a numeric total in the current capture.
- `Corazon` and `Hard Rock` still behave as availability/login-gated package checks in the Costco flow.
## Package vs Standalone
- Package totals already include flight, hotel, and transfers. Do not add separate flight or hotel costs on top of them.
- Standalone hotel rates are room-only and still need flights, transfers, golf, nightlife, and excursions added separately.
- Golf rates are per round, nightlife is per table or per-person deposit, and excursion pricing can be per person or per group.
## Missing Or Gated
- Costco Travel package pricing remains login/continue-gated, but the Los Cabos package lineup is still visible and continues to surface Hacienda del Mar, Grand Fiesta Americana, Secrets, Grand Velas, Hilton, Le Blanc, and related options.
- Apple Vacations hotel/package pages continue to expose inclusions and promos, but I could not verify a clean date-matched package price for the target window from the page text available in this pass.
- The Cabo Adventures ATV page still hides the clean base price in the captured text, but it does clearly expose the mandatory $25 entrance fee and $35 damage waiver.
## New Options Worth Adding
- `Le Blanc Spa Resort` package from Costco Travel.
- `Hacienda del Mar`, `Villa la Estancia`, and `Marquis` package pages from Costco Travel.
- `Bagatelle` VIP beach-club access and `El Squid Roe` table packages from Cabo Bash.
- `Luxury Whale Watching In Cabo`, `Cabo ATV Tour & Desert Adventure`, and `Cabo Sailing` private/shared options.
- Add CheapCaribbean package cards for Dreams, Breathless, Secrets, Paradisus, Grand Fiesta Americana, Riu Palace Cabo San Lucas, and Hard Rock.
- Add hotel-only cards for Hyatt Ziva, JW Marriott, Solmar Resort, and Grand Solmar Lands End.
- Add nightlife cards for Cabo Bash Taboo, Mandala, and Bagatelle, plus The Cabo Agencys Cabo Wabo table and booth options.
- Add excursion cards for private whale watching and the Cabo Villas private sail/yacht options.
## Budget Impact
- Budget recommendation: about `$1,026 pp` / `$12,311` for 12.
- Balanced recommendation: about `$2,143 pp` / `$25,718` for 12.
- Splurge recommendation: about `$3,733 pp` / `$44,795` for 12.
| Tier | 8 | 10 | 12 |
| --- | ---: | ---: | ---: |
| Budget | `$1,043` / `$8,347` | `$1,033` / `$10,329` | `$1,026` / `$12,311` |
| Balanced | `$2,196` / `$17,570` | `$2,164` / `$21,644` | `$2,143` / `$25,718` |
| Splurge | `$3,837` / `$30,697` | `$3,775` / `$37,746` | `$3,733` / `$44,795` |
- Budget: Dreams package + Palmilla + whale watch lands near $1,174 pp for 8 guests.
- Balanced: Breathless package + Quivira + private sail + Mandala lands near $1,853.31 pp for 10 guests.
- Splurge: Hard Rock package + Quivira + premium yacht + Taboo presidential deck lands near $2,352.45 pp for 12 guests.
- Package-vs-standalone caveat: package prices already include flight and hotel, so flight or room-only costs must not be added again unless the package explicitly excludes them.

View File

@@ -9,8 +9,8 @@
"markLoginRequiredSources": true
},
"tripDates": {
"checkIn": "2027-02-03",
"checkOut": "2027-02-07",
"checkIn": "2027-02-02",
"checkOut": "2027-02-06",
"nights": 4,
"note": "All per-night or per-day rates should be converted to the full check-in/check-out total for comparison, while preserving the unit rate in the display label."
},

View File

@@ -9,9 +9,14 @@
<style>
/* ── Map tab ─────────────────────────────────────────────── */
#map-view {
position: relative;
height: calc(100vh - 120px);
min-height: 400px;
position: sticky;
top: 86px;
height: calc(100vh - 118px);
min-height: 560px;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: #0a0a14;
}
#cabo-map {
position: absolute;
@@ -598,7 +603,21 @@
.tab.active .tab-count { background: rgba(0,212,255,0.15); color: var(--accent); }
/* ── 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 {
@@ -659,6 +678,32 @@
font-size: 0.85rem;
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 {
display: flex;
align-items: center;
@@ -667,6 +712,25 @@
margin: 10px 0 2px;
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 {
border: 1px solid rgba(0,212,255,0.32);
background: rgba(0,212,255,0.10);
@@ -891,8 +955,15 @@
stroke: rgba(255, 255, 255, 0.85);
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 {
stroke-width: 2.4;
stroke-width: 3.2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
@@ -1444,9 +1515,54 @@
/* Main adjusts for bottom tabs */
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-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-desc { font-size: 0.76rem; }
.option-actions { justify-content: flex-start; }
@@ -1560,6 +1676,13 @@
<!-- 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">
<label for="sortModeSelect">Sort by</label>
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)">
@@ -1580,8 +1703,33 @@
<div class="empty-state"><div class="empty-emoji"></div>Loading options…</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 -->
<div id="map-view" style="display:none;">
<div id="map-view">
<div id="cabo-map"></div>
<div class="map-overlay">
<!-- Row 1: Multi-provider search -->
@@ -1631,28 +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>
</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>
</aside>
</div>
</main>
@@ -2676,6 +2803,7 @@
</linearGradient>
</defs>
<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>
${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}">
@@ -3406,8 +3534,8 @@
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-03';
const ret = '2027-02-07';
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;

View File

@@ -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) {
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
const primaryUrl = option.links?.[0]?.url || option.url || null;
@@ -187,9 +193,13 @@ function createOption(option) {
links: [],
categoryColor,
url: primaryUrl,
bookingUrl: option.bookingUrl || primaryUrl,
imageUrl: buildOptionImageUrl(option),
...option,
categoryColor,
url: primaryUrl,
bookingUrl: option.bookingUrl || primaryUrl,
imageUrl: buildOptionImageUrl(option),
};
}
@@ -238,7 +248,7 @@ function buildSeedData() {
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-03' },
{ 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({
@@ -253,7 +263,7 @@ function buildSeedData() {
links: [
{ label: 'Official', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=4&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=2027-02-03&vendorcode=APV' },
{ label: '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' },
],
}),
@@ -269,7 +279,7 @@ function buildSeedData() {
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-03&vendorcode=CCV' },
{ label: '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' },
],
}),
@@ -460,7 +470,7 @@ function buildSeedData() {
desc: 'Lowest exact-date round-trip flight quote from Ontario to San Jose del Cabo for the target trip window.',
details: ['$402 RT pp', 'Volaris, 1 stop', 'Best budget-flight anchor'],
links: [
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a' },
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
],
}),
createOption({
@@ -471,7 +481,7 @@ function buildSeedData() {
desc: 'Higher-comfort exact-date round-trip flight quote for the target trip window.',
details: ['$605 RT pp', 'American, 1 stop', 'Best-value comfort anchor'],
links: [
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a' },
{ label: 'KAYAK Flight Search', url: 'https://www.kayak.com/flights/ONT-SJD/2027-02-02/2027-02-06?sort=bestflight_a' },
],
}),
createOption({

109
server.js
View File

@@ -12,6 +12,9 @@ const app = express();
const server = http.createServer(app);
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 DATA_DIR = 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
? path.resolve(process.env.PRICE_HISTORY_FILE)
: DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-02';
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(express.json());
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() {
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';
}
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') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', {
@@ -463,8 +513,8 @@ function loadPriceHistoryState() {
unitPrice: tripPrice.unitPrice,
tripTotalPrice: tripPrice.tripTotalPrice,
tripNights: tripPrice.tripNights,
tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null,
tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null,
tripCheckIn: point.tripCheckIn || defaults.tripCheckIn || (tripPrice.tripNights ? TRIP_CHECK_IN : null),
tripCheckOut: point.tripCheckOut || defaults.tripCheckOut || (tripPrice.tripNights ? TRIP_CHECK_OUT : null),
currency,
displayPrice: tripPrice.displayPrice,
unitDisplayPrice: rawDisplayPrice,
@@ -573,7 +623,7 @@ function buildPriceHistoryBySource(priceHistory) {
return {
sourceKey: bucket.sourceKey,
sourceLabel: bucket.sourceLabel,
sourceUrl: bucket.sourceUrl,
sourceUrl: latestPoint?.sourceUrl || bucket.sourceUrl,
bookingType: latestPoint?.bookingType || null,
priceBasis: latestPoint?.priceBasis || null,
pointCount: bucket.points.length,
@@ -794,6 +844,41 @@ app.get('/api/options', (req, res) => {
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) => {
const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
@@ -1021,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) => {
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: [],
}