Compare commits
100 Commits
538de0039c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e52bfa23 | ||
|
|
4f764e6dbb | ||
|
|
8d6b3fbb4e | ||
|
|
468cb88552 | ||
|
|
ecf1859821 | ||
|
|
4cce703544 | ||
|
|
fa0a7f44b7 | ||
|
|
11f5d1b225 | ||
|
|
69f87c8b6b | ||
|
|
42cbe03276 | ||
|
|
b9376e82ee | ||
|
|
8389c020c0 | ||
|
|
82db6219f5 | ||
|
|
99e1050d71 | ||
|
|
43c7a94b9c | ||
|
|
6348c34461 | ||
|
|
cda296e25a | ||
|
|
7aea0c7831 | ||
|
|
a89ed35994 | ||
|
|
e565037179 | ||
|
|
6ebfab1710 | ||
|
|
3488934180 | ||
|
|
3b0ee11fc3 | ||
|
|
39df63e0c2 | ||
|
|
1acc36824d | ||
|
|
cb4f431b89 | ||
|
|
16a8fb6ac3 | ||
|
|
27565b305e | ||
|
|
5afbbe122d | ||
|
|
028445ee55 | ||
|
|
ad72bb33c5 | ||
|
|
49bbf5271b | ||
|
|
0235fa5ddf | ||
|
|
0428c54e9c | ||
|
|
90bdc1dd0a | ||
|
|
66d214f737 | ||
|
|
a7c0417a2c | ||
|
|
1ec2204184 | ||
|
|
c75b7e9654 | ||
|
|
a940f0f2e5 | ||
|
|
7a10e4d3c9 | ||
|
|
e5136bd193 | ||
|
|
91c1db2a24 | ||
|
|
60147b822b | ||
|
|
525e91a76d | ||
|
|
4dc36199f5 | ||
|
|
a64a677af6 | ||
|
|
92b5190f74 | ||
|
|
db11f51a19 | ||
|
|
556cd91fbe | ||
|
|
c0ac120721 | ||
|
|
1f91dfcd17 | ||
|
|
8ce85470f9 | ||
|
|
edf6937f1f | ||
|
|
7646aec58c | ||
|
|
b36291ef63 | ||
|
|
cf4ce56b82 | ||
|
|
b6bab181fc | ||
|
|
3c4dbb7f2a | ||
|
|
3678c49fb4 | ||
|
|
0cf58c9c41 | ||
|
|
0bf602d5d9 | ||
|
|
a1dda7fc42 | ||
|
|
b88fc35d11 | ||
|
|
1bc20741b6 | ||
|
|
9f4eb64c4d | ||
|
|
6df5d058ac | ||
|
|
3fdc435e5f | ||
|
|
c3827c23e0 | ||
|
|
d9feaf0ee1 | ||
|
|
573b5a6c01 | ||
|
|
62c754fc61 | ||
|
|
bff108faca | ||
|
|
1708f2f46f | ||
|
|
c080c181d9 | ||
|
|
05740fe537 | ||
|
|
bd7af07d19 | ||
|
|
38ae3f3dd8 | ||
|
|
4de0e5a472 | ||
|
|
6b0eb82fb6 | ||
|
|
e04f8e27b7 | ||
|
|
0eac4c81ac | ||
|
|
5f9edc3ed7 | ||
|
|
0b6d698ba7 | ||
|
|
e3dfd90ecc | ||
|
|
afa501c838 | ||
|
|
974a483d6c | ||
|
|
ed98d4ea70 | ||
|
|
09cf482d92 | ||
|
|
83b07326de | ||
|
|
4930d7d37b | ||
|
|
a990adcb80 | ||
|
|
d5a7e85417 | ||
|
|
1e36d45976 | ||
|
|
1674930435 | ||
|
|
86733522eb | ||
|
|
16a0252647 | ||
|
|
b768990e05 | ||
|
|
bee992e10b | ||
|
|
7d139fead9 |
@@ -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
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
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
13
client/dist/index.html
vendored
Normal 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
12
client/index.html
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
retry = 1000
|
||||||
|
setState((current) => ({ ...current, connected: true }))
|
||||||
|
}
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
if (message.type === 'init') {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
categories: message.categories || [],
|
||||||
|
options: message.options || [],
|
||||||
|
budgetScenarios: message.budgetScenarios || [],
|
||||||
|
guestRoster: message.guestRoster || [],
|
||||||
|
pollsOpen: message.pollsOpen,
|
||||||
|
totalVoters: message.totalVoters || 0,
|
||||||
|
priceUpdatedAt: message.priceUpdatedAt || '',
|
||||||
|
priceHistoryRunCount: message.priceHistoryRunCount || 0,
|
||||||
|
tripCheckIn: message.tripCheckIn || '',
|
||||||
|
tripCheckOut: message.tripCheckOut || '',
|
||||||
|
}))
|
||||||
|
} else if (message.type === 'vote_update') {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
options: current.options.map((option) => {
|
||||||
|
const update = message.results?.find((result) => result.id === option.id)
|
||||||
|
return update ? { ...option, votes: (update.voters || []).map((name) => ({ name })) } : option
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
} else if (['option_added', 'option_approved', 'option_updated'].includes(message.type)) {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
options: current.options.some((option) => option.id === message.option.id)
|
||||||
|
? current.options.map((option) => option.id === message.option.id ? { ...option, ...message.option } : option)
|
||||||
|
: [...current.options, message.option],
|
||||||
|
}))
|
||||||
|
} else if (message.type === 'option_deleted') {
|
||||||
|
setState((current) => ({ ...current, options: current.options.filter((option) => option.id !== message.id) }))
|
||||||
|
} else if (message.type === 'polls_status') {
|
||||||
|
setState((current) => ({ ...current, pollsOpen: message.open }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
setState((current) => ({ ...current, connected: false }))
|
||||||
|
if (!closed) {
|
||||||
|
timer = setTimeout(connect, retry)
|
||||||
|
retry = Math.min(retry * 2, 30000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onerror = () => ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
wsRef.current?.close()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleVote = useCallback((option, removed = false) => {
|
const send = (message) => {
|
||||||
if (!voterName) return
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
if (!pollsOpen || pollsExpired) return
|
wsRef.current.send(JSON.stringify(message))
|
||||||
const opt = options.find(o => o.id === option.id)
|
|
||||||
if (!opt) return
|
|
||||||
|
|
||||||
const alreadyVoted = opt.votes?.some(v => v.name === voterName)
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
setOptions(prev => prev.map(o => {
|
|
||||||
if (o.id !== option.id) return o
|
|
||||||
if (removed || alreadyVoted) {
|
|
||||||
return { ...o, votes: o.votes.filter(v => v.name !== voterName) }
|
|
||||||
} else {
|
|
||||||
return { ...o, votes: [...(o.votes || []), { name: voterName, timestamp: Date.now() }] }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const ws = wsRef.current
|
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'vote', optionId: option.id, voterName, remove: removed || alreadyVoted }))
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (removed || alreadyVoted) {
|
return { state, setState, send }
|
||||||
playRemoveSound()
|
}
|
||||||
showToast(`Removed vote for ${opt.name}`)
|
|
||||||
} else {
|
|
||||||
playVoteSound()
|
|
||||||
showToast(`Voted for ${opt.name}!`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Social toast for others (shows voter name + option name)
|
function useGuestSession() {
|
||||||
setSocialToast({ voterName, optionName: opt.name, categoryId: opt.categoryId })
|
const [guest, setGuest] = useState(null)
|
||||||
clearTimeout(socialToastTimer.current)
|
const [token, setToken] = useState(() => localStorage.getItem(AUTH_TOKEN_KEY) || '')
|
||||||
socialToastTimer.current = setTimeout(() => setSocialToast(null), 3000)
|
const [ready, setReady] = useState(false)
|
||||||
}, [voterName, options, pollsOpen, pollsExpired, wsRef, playVoteSound, playRemoveSound, showToast])
|
|
||||||
|
|
||||||
const handleRemoveVote = useCallback((optionId) => {
|
|
||||||
const opt = options.find(o => o.id === optionId)
|
|
||||||
if (opt) handleVote(opt, true)
|
|
||||||
}, [options, handleVote])
|
|
||||||
|
|
||||||
// Check URL params on load
|
|
||||||
useEffect(() => {
|
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()
|
|
||||||
}
|
}
|
||||||
}, []) // eslint-disable-line
|
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 handleAddSubmit = useCallback((data) => {
|
const login = async (name, pin) => {
|
||||||
if (!voterName) { showToast('Enter your name first', 'error'); return }
|
const response = await fetch('/api/auth/login', {
|
||||||
const ws = wsRef.current
|
method: 'POST',
|
||||||
if (ws?.readyState === WebSocket.OPEN) {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
ws.send(JSON.stringify({ type: 'add_option', ...data, voterName }))
|
body: JSON.stringify({ name, pin }),
|
||||||
}
|
|
||||||
document.getElementById('add-name').value = ''
|
|
||||||
document.getElementById('add-desc').value = ''
|
|
||||||
document.getElementById('add-url').value = ''
|
|
||||||
showToast(`Submitted "${data.name}" for approval!`, 'success')
|
|
||||||
}, [voterName, wsRef, showToast])
|
|
||||||
|
|
||||||
const toggleSound = useCallback(() => {
|
|
||||||
setSoundEnabled(prev => {
|
|
||||||
const next = !prev
|
|
||||||
localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
|
|
||||||
return next
|
|
||||||
})
|
})
|
||||||
}, [])
|
const 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 toggleTheme = useCallback(() => {
|
const logout = () => {
|
||||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||||
}, [])
|
setToken('')
|
||||||
|
setGuest(null)
|
||||||
|
}
|
||||||
|
|
||||||
const votingCats = categories.filter(c => c.id !== 'results')
|
return { guest, token, ready, login, logout }
|
||||||
const optionCounts = votingCats.reduce((acc, cat) => {
|
}
|
||||||
acc[cat.id] = options.filter(o => o.categoryId === cat.id).length
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
// "Your Votes" — all options this voter voted for
|
function buildTabs(categories) {
|
||||||
const yourVotes = options.filter(o => o.votes?.some(v => v.name === voterName))
|
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">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<Header
|
<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)]')}>
|
||||||
voterName={voterName}
|
<section className={cx(showMap && mobileView === 'map' && 'hidden md:block')}>
|
||||||
pollsOpen={pollsOpen}
|
<OptionList tabId={tabId} tab={tab} options={visibleOptions} data={data} guest={guest} token={token} send={send} />
|
||||||
totalVoters={totalVoters}
|
<AddOptionForm categories={data.categories} token={token} guest={guest} />
|
||||||
wsConnected={wsConnected}
|
</section>
|
||||||
onChangeName={clearVoter}
|
{showMap && (
|
||||||
soundEnabled={soundEnabled}
|
<aside className={cx('md:block', mobileView === 'list' && 'hidden')}>
|
||||||
onToggleSound={toggleSound}
|
<CaboMap options={data.options.filter((option) => option.approved)} onVote={(optionId, remove) => send({ type: 'vote', optionId, remove, authToken: token })} guest={guest} />
|
||||||
theme={theme}
|
</aside>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && (
|
</>
|
||||||
<AddOption
|
)
|
||||||
categories={categories}
|
}
|
||||||
voterName={voterName}
|
|
||||||
onSubmit={handleAddSubmit}
|
function OptionList({ tabId, tab, options, data, guest, token, send }) {
|
||||||
onNeedsName={() => showToast('Enter your name first', 'error')}
|
const [sortMode, setSortMode] = useState('vote-desc')
|
||||||
/>
|
const sorted = useMemo(() => {
|
||||||
)}
|
return [...options].sort((a, b) => {
|
||||||
</main>
|
if (sortMode.startsWith('vote')) {
|
||||||
|
const delta = getVotes(a).length - getVotes(b).length
|
||||||
{/* Option detail modal */}
|
return sortMode === 'vote-asc' ? delta : -delta
|
||||||
{selectedOption && (
|
}
|
||||||
<OptionModal
|
const aPrice = getLatestPoint(a, tabId)?.price
|
||||||
option={selectedOption}
|
const bPrice = getLatestPoint(b, tabId)?.price
|
||||||
voterName={voterName}
|
const aHas = typeof aPrice === 'number'
|
||||||
onClose={() => setSelectedOption(null)}
|
const bHas = typeof bPrice === 'number'
|
||||||
onVote={handleVote}
|
if (aHas && bHas) return sortMode === 'price-asc' ? aPrice - bPrice : bPrice - aPrice
|
||||||
categories={categories}
|
if (aHas !== bHas) return aHas ? -1 : 1
|
||||||
/>
|
return 0
|
||||||
)}
|
})
|
||||||
|
}, [options, sortMode, tabId])
|
||||||
{/* Your votes modal */}
|
|
||||||
{yourVotesOpen && (
|
return (
|
||||||
<YourVotesModal
|
<div>
|
||||||
votes={yourVotes}
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
|
||||||
voterName={voterName}
|
<div className="flex items-center gap-3">
|
||||||
onClose={() => setYourVotesOpen(false)}
|
<span className={cx('h-2 w-2 rounded-full', data.connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||||
onRemoveVote={handleRemoveVote}
|
<span>{data.pollsOpen ? 'Polls open' : 'Polls closed'}</span>
|
||||||
onViewOption={setSelectedOption}
|
<span>{data.totalVoters} voter{data.totalVoters === 1 ? '' : 's'}</span>
|
||||||
categories={categories}
|
</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)}>
|
||||||
<WsOverlay connected={wsConnected} onReconnect={reconnect} />
|
<option value="vote-desc">Votes: High to Low</option>
|
||||||
{toast && <Toast msg={toast.msg} type={toast.type} />}
|
<option value="vote-asc">Votes: Low to High</option>
|
||||||
{socialToast && (
|
<option value="price-asc">Price: Low to High</option>
|
||||||
<SocialToast
|
<option value="price-desc">Price: High to Low</option>
|
||||||
voterName={socialToast.voterName}
|
</select>
|
||||||
optionName={socialToast.optionName}
|
</label>
|
||||||
categoryId={socialToast.categoryId}
|
</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: '© OpenStreetMap, © 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
59
client/src/index.css
Normal 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
13
client/src/main.jsx
Normal 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
21
client/vite.config.js
Normal 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
2025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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
@@ -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 Palmilla’s 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 Land’s 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 Land’s End.
|
||||||
|
- Add nightlife cards for Cabo Bash Taboo, Mandala, and Bagatelle, plus The Cabo Agency’s 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 today’s 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.
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,86 +1676,110 @@
|
|||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
<main>
|
<main>
|
||||||
<div class="sort-bar" id="sortBar">
|
<div class="mobile-view-toggle" id="mobileViewToggle" aria-label="Mobile view toggle">
|
||||||
<label for="sortModeSelect">Sort by</label>
|
<button type="button" id="mobileListBtn" class="active" onclick="setMobileView('list')">List</button>
|
||||||
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)">
|
<button type="button" id="mobileMapBtn" onclick="setMobileView('map')">Map</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="content-shell">
|
||||||
<div><span class="status-dot" id="wsDot"></span><span id="wsStatus">Connecting…</span></div>
|
<section class="list-pane" id="listPane">
|
||||||
<div><span class="polls-badge open" id="pollsBadge">POLLS OPEN</span></div>
|
<div class="sort-bar" id="sortBar">
|
||||||
<div><span id="totalVotersCount"></span></div>
|
<label for="sortModeSelect">Sort by</label>
|
||||||
</div>
|
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)">
|
||||||
|
<option value="vote-desc">Votes: High to Low</option>
|
||||||
<div class="options-list" id="optionsList" role="tabpanel" aria-label="Voting options" aria-live="polite">
|
<option value="vote-asc">Votes: Low to High</option>
|
||||||
<div class="empty-state"><div class="empty-emoji">⏳</div>Loading options…</div>
|
<option value="price-asc">Price: Low to High</option>
|
||||||
</div>
|
<option value="price-desc">Price: High to Low</option>
|
||||||
|
|
||||||
<!-- Map view -->
|
|
||||||
<div id="map-view" style="display:none;">
|
|
||||||
<div id="cabo-map"></div>
|
|
||||||
<div class="map-overlay">
|
|
||||||
<!-- Row 1: Multi-provider search -->
|
|
||||||
<div id="map-search-wrap">
|
|
||||||
<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" />
|
|
||||||
<div class="provider-tabs">
|
|
||||||
<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-all" onclick="setProvider('all')">⚡ All</button>
|
|
||||||
</div>
|
|
||||||
<button id="map-search-btn" onclick="mapDoSearch()">→</button>
|
|
||||||
</div>
|
|
||||||
<div id="map-search-results"></div>
|
|
||||||
<!-- Row 2: Category filters -->
|
|
||||||
<div class="map-filter-row">
|
|
||||||
<span class="row-label">Show</span>
|
|
||||||
<button class="map-cat-btn active" id="cat-btn-hotel" onclick="mapToggleCat('hotel')">🏨 Hotels</button>
|
|
||||||
<button class="map-cat-btn active" id="cat-btn-golf" onclick="mapToggleCat('golf')">⛳ Golf</button>
|
|
||||||
<button class="map-cat-btn active" id="cat-btn-nightlife" onclick="mapToggleCat('nightlife')">🎧 Nightlife</button>
|
|
||||||
<button class="map-cat-btn active" id="cat-btn-excursion" onclick="mapToggleCat('excursion')">🚤 Excursions</button>
|
|
||||||
<button class="map-cat-btn active" id="cat-btn-itinerary" onclick="mapToggleCat('itinerary')">🗺️ Itineraries</button>
|
|
||||||
<button class="map-cat-btn" id="cat-btn-clear" onclick="mapClearAllCats()" style="margin-left:4px;border-color:#f87171;color:#f87171;">✕ Clear</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="map-legend">
|
|
||||||
<h4>Legend</h4>
|
|
||||||
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div> Hotel</div>
|
|
||||||
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> Golf</div>
|
|
||||||
<div class="legend-item"><div class="legend-dot" style="background:#06b6d4"></div> Excursion</div>
|
|
||||||
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div> Nightlife</div>
|
|
||||||
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Itinerary</div>
|
|
||||||
<div class="legend-item"><div class="legend-dot" style="background:#ff6b35;opacity:0.8"></div> Yelp</div>
|
|
||||||
<div class="legend-item"><div class="legend-dot legend-dot-osm"></div> OSM</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>
|
|
||||||
|
|
||||||
<!-- 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>
|
</select>
|
||||||
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="status-bar">
|
||||||
|
<div><span class="status-dot" id="wsDot"></span><span id="wsStatus">Connecting…</span></div>
|
||||||
|
<div><span class="polls-badge open" id="pollsBadge">POLLS OPEN</span></div>
|
||||||
|
<div><span id="totalVotersCount"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-list" id="optionsList" role="tabpanel" aria-label="Voting options" aria-live="polite">
|
||||||
|
<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">
|
||||||
|
<div id="cabo-map"></div>
|
||||||
|
<div class="map-overlay">
|
||||||
|
<!-- Row 1: Multi-provider search -->
|
||||||
|
<div id="map-search-wrap">
|
||||||
|
<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" />
|
||||||
|
<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">
|
||||||
|
<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-all" onclick="setProvider('all')">⚡ All</button>
|
||||||
|
</div>
|
||||||
|
<button id="map-search-btn" onclick="mapDoSearch()">→</button>
|
||||||
|
</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>
|
||||||
|
<!-- Row 2: Category filters -->
|
||||||
|
<div class="map-filter-row">
|
||||||
|
<span class="row-label">Show</span>
|
||||||
|
<button class="map-cat-btn active" id="cat-btn-hotel" onclick="mapToggleCat('hotel')">🏨 Hotels</button>
|
||||||
|
<button class="map-cat-btn active" id="cat-btn-golf" onclick="mapToggleCat('golf')">⛳ Golf</button>
|
||||||
|
<button class="map-cat-btn active" id="cat-btn-nightlife" onclick="mapToggleCat('nightlife')">🎧 Nightlife</button>
|
||||||
|
<button class="map-cat-btn active" id="cat-btn-excursion" onclick="mapToggleCat('excursion')">🚤 Excursions</button>
|
||||||
|
<button class="map-cat-btn active" id="cat-btn-itinerary" onclick="mapToggleCat('itinerary')">🗺️ Itineraries</button>
|
||||||
|
<button class="map-cat-btn" id="cat-btn-clear" onclick="mapClearAllCats()" style="margin-left:4px;border-color:#f87171;color:#f87171;">✕ Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="map-legend">
|
||||||
|
<h4>Legend</h4>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div> Hotel</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> Golf</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#06b6d4"></div> Excursion</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div> Nightlife</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Itinerary</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#ff6b35;opacity:0.8"></div> Yelp</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot legend-dot-osm"></div> OSM</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>
|
||||||
|
</aside>
|
||||||
</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);
|
||||||
renderTabs();
|
} else {
|
||||||
render();
|
state.options = state.options.map(o => o.id === msg.option.id ? { ...o, ...msg.option } : o);
|
||||||
if (mapInitialized) mapRefreshMarkers();
|
|
||||||
}
|
}
|
||||||
|
renderTabs();
|
||||||
|
render();
|
||||||
|
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;
|
||||||
|
|||||||
42
seed-data.js
42
seed-data.js
@@ -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
150
server.js
@@ -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
20
tailwind.config.js
Normal 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: [],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user